Skip to content
commit-766c8d6756a1

Shared behavior

Chart controls

Every chart accepts the same renderer-neutral wrapper options. Configure lifecycle, controls, and export on the chart config; the wrapper supplies responsive actions, a large modal, fullscreen behavior, downloads, and focus management when present.

Defaults

Expand

Enabled by default and kept as the primary action.

Fullscreen

Disabled by default; opt in with Fullscreen: true.

Export

Enabled from the chart capability unless Export.Disabled is true.

Collapse

Not part of the public control API.

Change charts with form values

These forms map request values to typed chart configuration on the server. Changing a field swaps only its example through HTMX; Apply performs the same GET without JavaScript. Each source block includes both paths.

Native select controls keep lifecycle and orientation editable in the no-JavaScript path. The surrounding form, range, checkbox, button, and source presentation use Goshtoso components.

Static/vector line

Change server-rendered SVG options and all four wrapper lifecycle states. The dataset and filled-area intent follow the centrally recorded Line example.

Static chart form controls
Hidden keeps chart state in the DOM; omitted removes only the shared wrapper.

Whole pixels from 1 through 8. Applied: 3 px.

Unchecked sends omission as off.

Applied: enabled wrapper, 3 px stroke, area on.

Exact series values
LabelSeriesY axisValue
MonEmailY axis120
TueEmailY axis132
WedEmailY axis101
ThuEmailY axis134
FriEmailY axis90
SatEmailY axis230
SunEmailY axis210
Static form and chart · templ
package chartsdemo

import (
  "fmt"
  "net/http"
  "strconv"
  "strings"

  "github.com/a-h/templ"
  "github.com/araihu/goshtoso-charts/components/chartcontrol"
  "github.com/araihu/goshtoso-charts/components/line"
  "github.com/araihu/goshtoso/components/button"
  "github.com/araihu/goshtoso/components/checkbox"
  "github.com/araihu/goshtoso/components/form"
  rangeinput "github.com/araihu/goshtoso/components/range"
)

type staticState struct {
  Mode chartcontrol.WrapperMode
  ModeValue string
  Stroke int
  Area bool
  Errors []string
}

func staticStateFromRequest(r *http.Request) staticState {
  state := staticState{Mode: chartcontrol.WrapperModeEnabled, ModeValue: "enabled", Stroke: 3, Area: true}
  if r.URL.Query().Get("static_present") != "1" { return state }
  state.Area = r.URL.Query().Has("static_area")
  switch r.URL.Query().Get("static_mode") {
  case "enabled": state.Mode = chartcontrol.WrapperModeEnabled
  case "disabled": state.Mode, state.ModeValue = chartcontrol.WrapperModeDisabled, "disabled"
  case "hidden": state.Mode, state.ModeValue = chartcontrol.WrapperModeHidden, "hidden"
  case "omitted": state.Mode, state.ModeValue = chartcontrol.WrapperModeOmitted, "omitted"
  default: state.Errors = append(state.Errors, "Wrapper mode must be enabled, disabled, hidden, or omitted; enabled was restored.")
  }
  if stroke, err := strconv.Atoi(r.URL.Query().Get("static_stroke")); err == nil && stroke >= 1 && stroke <= 8 {
    state.Stroke = stroke
  } else {
    state.Errors = append(state.Errors, "Stroke width must be a whole number from 1 through 8; 3 was restored.")
  }
  return state
}

func staticConfig(state staticState) line.Config {
  minimum, noGap := 0.0, false
  area := line.AreaOptions{}
  if state.Area { area = line.AreaOptions{Enabled: true, Opacity: 150.0 / 255.0} }
  return line.Config{
    Label: "Controlled weekly email line",
    Caption: fmt.Sprintf("Seven exact values; %d px stroke; area fill %s; wrapper %s.", state.Stroke, onOff(state.Area), state.ModeValue),
    Title: line.Title{Text: "Weekly email"},
    Labels: []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"},
    Series: []line.Series{{Name: "Email", Values: []float64{120, 132, 101, 134, 90, 230, 210}}},
    Area: area,
    XAxis: line.CategoryAxisOptions{BoundaryGap: &noGap},
    YAxes: []line.Axis{{Min: &minimum}},
    StrokeWidth: float64(state.Stroke), Width: 600, Height: 400,
    Controls: chartcontrol.Options{Mode: state.Mode, Fullscreen: true},
    Export: &chartcontrol.ExportOptions{Filename: "controlled-weekly-email"},
  }
}

func onOff(value bool) string { if value { return "on" }; return "off" }

func changeAttrs() templ.Attributes {
  return templ.Attributes{
    "hx-get": "/docs/chart-controls", "hx-target": "#static-chart-control-example",
    "hx-swap": "outerHTML focus-scroll:false", "hx-include": "closest form", "hx-trigger": "change",
  }
}

func falsePointer() *bool { value := false; return &value }

func StaticChartControlsHandler(w http.ResponseWriter, r *http.Request) {
  w.Header().Set("Content-Type", "text/html; charset=utf-8")
  if err := StaticChartControls(staticStateFromRequest(r)).Render(r.Context(), w); err != nil {
    http.Error(w, "render static chart controls", http.StatusInternalServerError)
  }
}

templ StaticChartControls(state staticState) {
  <div id="static-chart-control-example">
    @form.Form(form.Config{Action: "/docs/chart-controls#static-chart-control-example", Method: "get", PreventEnterSubmit: falsePointer(), HTMX: &form.HTMXConfig{Get: "/docs/chart-controls", Target: "#static-chart-control-example", Swap: "outerHTML focus-scroll:false"}}) {
      <input type="hidden" name="static_present" value="1"/>
      <label for="static-mode">Wrapper mode</label>
      <select id="static-mode" name="static_mode" { changeAttrs()... }>
        <option value="enabled" selected?={ state.ModeValue == "enabled" }>Enabled</option>
        <option value="disabled" selected?={ state.ModeValue == "disabled" }>Disabled</option>
        <option value="hidden" selected?={ state.ModeValue == "hidden" }>Hidden</option>
        <option value="omitted" selected?={ state.ModeValue == "omitted" }>Omitted</option>
      </select>
      @rangeinput.Range(rangeinput.Config{ID: "static-stroke", Name: "static_stroke", Label: "Stroke width", Value: strconv.Itoa(state.Stroke), Min: "1", Max: "8", InputAttrs: changeAttrs()})
      @checkbox.Checkbox(checkbox.Config{ID: "static-area", Name: "static_area", Value: "on", Label: "Fill area below line", Checked: state.Area, InputAttrs: changeAttrs()})
      @button.Button(button.WithType("submit"), button.WithID("static-apply")) { Apply static controls }
      if len(state.Errors) > 0 { <p role="alert">{ strings.Join(state.Errors, " ") }</p> }
      <p aria-live="polite">Applied: { state.ModeValue } wrapper, { strconv.Itoa(state.Stroke) } px stroke, area { onOff(state.Area) }.</p>
    }
    @line.Line(staticConfig(state))
  </div>
}

Interactive bar

Change orientation, data scale, and visible labels while the replacement initializes one responsive canvas and retains its exact-value disclosure.

Interactive chart form controls
Moves categories between horizontal and vertical axes.

10% steps from 50% through 150%. Applied: 100%.

Exact values remain in the disclosure either way.

Applied: vertical, 100% scale, labels on.

Exact category values
CategorySeriesValue
MonCategory A60
MonCategory B9
TueCategory A251
TueCategory B43
WedCategory A113
WedCategory B58
ThuCategory A136
ThuCategory B150
FriCategory A134
FriCategory B74
SatCategory A205
SatCategory B126
SunCategory A229
SunCategory B183

7 categories across 2 series.

Interactive form and chart · templ
package chartsdemo

import (
  "fmt"
  "math"
  "math/rand"
  "net/http"
  "strconv"
  "strings"

  "github.com/a-h/templ"
  "github.com/araihu/goshtoso-charts/components/chartcontrol"
  interactive "github.com/araihu/goshtoso-charts/components/interactive"
  "github.com/araihu/goshtoso/components/button"
  "github.com/araihu/goshtoso/components/checkbox"
  "github.com/araihu/goshtoso/components/form"
  rangeinput "github.com/araihu/goshtoso/components/range"
)

type interactiveState struct {
  Orientation interactive.BarOrientation
  Scale int
  Labels bool
  Errors []string
}

func interactiveStateFromRequest(r *http.Request) interactiveState {
  state := interactiveState{Orientation: interactive.BarOrientationVertical, Scale: 100, Labels: true}
  if r.URL.Query().Get("interactive_present") != "1" { return state }
  state.Labels = r.URL.Query().Has("interactive_labels")
  switch r.URL.Query().Get("interactive_orientation") {
  case "vertical":
  case "horizontal": state.Orientation = interactive.BarOrientationHorizontal
  default: state.Errors = append(state.Errors, "Orientation must be vertical or horizontal; vertical was restored.")
  }
  if scale, err := strconv.Atoi(r.URL.Query().Get("interactive_scale")); err == nil && scale >= 50 && scale <= 150 && scale%10 == 0 {
    state.Scale = scale
  } else {
    state.Errors = append(state.Errors, "Value scale must be a 10% step from 50% through 150%; 100% was restored.")
  }
  return state
}

func fixedData(seed int64, scale int) []interactive.BarData {
  source := rand.New(rand.NewSource(seed))
  data := make([]interactive.BarData, 7)
  for index := range data {
    scaled := float64(source.Intn(300)) * float64(scale) / 100
    data[index].Value = math.Round(scaled * 10) / 10
  }
  return data
}

func interactiveConfig(state interactiveState) interactive.BarConfig {
  return interactive.BarConfig{
    Label: "Controlled weekly category bar",
    Caption: fmt.Sprintf("Fourteen exact values at %d%% scale; %s orientation; value labels %s.", state.Scale, state.Orientation, interactiveOnOff(state.Labels)),
    XAxis: []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"},
    Series: []interactive.BarSeries{{Name: "Category A", Data: fixedData(11, state.Scale)}, {Name: "Category B", Data: fixedData(12, state.Scale)}},
    Orientation: state.Orientation,
    SeriesOptions: interactive.SeriesOptions{Label: &interactive.LabelOptions{Show: interactive.Bool(state.Labels), Position: "top"}},
    Options: interactive.ChartOptions{
      Title: &interactive.TitleOptions{Text: "Weekly categories"},
      Legend: &interactive.LegendOptions{Bottom: "0"},
      Tooltip: &interactive.TooltipOptions{Show: interactive.Bool(true), Trigger: "axis"},
      Controls: chartcontrol.Options{Fullscreen: true},
      Export: &chartcontrol.ExportOptions{Filename: "controlled-weekly-categories"},
    },
  }
}

func interactiveOnOff(value bool) string { if value { return "on" }; return "off" }

func interactiveChangeAttrs() templ.Attributes {
  return templ.Attributes{
    "hx-get": "/docs/chart-controls", "hx-target": "#interactive-chart-control-example",
    "hx-swap": "outerHTML focus-scroll:false", "hx-include": "closest form", "hx-trigger": "change",
  }
}

func interactiveFalsePointer() *bool { value := false; return &value }

func InteractiveChartControlsHandler(w http.ResponseWriter, r *http.Request) {
  w.Header().Set("Content-Type", "text/html; charset=utf-8")
  if err := InteractiveChartControls(interactiveStateFromRequest(r)).Render(r.Context(), w); err != nil {
    http.Error(w, "render interactive chart controls", http.StatusInternalServerError)
  }
}

templ InteractiveChartControls(state interactiveState) {
  <div id="interactive-chart-control-example">
    @form.Form(form.Config{Action: "/docs/chart-controls#interactive-chart-control-example", Method: "get", PreventEnterSubmit: interactiveFalsePointer(), HTMX: &form.HTMXConfig{Get: "/docs/chart-controls", Target: "#interactive-chart-control-example", Swap: "outerHTML focus-scroll:false"}}) {
      <input type="hidden" name="interactive_present" value="1"/>
      <label for="interactive-orientation">Orientation</label>
      <select id="interactive-orientation" name="interactive_orientation" { interactiveChangeAttrs()... }>
        <option value="vertical" selected?={ state.Orientation == interactive.BarOrientationVertical }>Vertical</option>
        <option value="horizontal" selected?={ state.Orientation == interactive.BarOrientationHorizontal }>Horizontal</option>
      </select>
      @rangeinput.Range(rangeinput.Config{ID: "interactive-scale", Name: "interactive_scale", Label: "Value scale (%)", Value: strconv.Itoa(state.Scale), Min: "50", Max: "150", Step: "10", InputAttrs: interactiveChangeAttrs()})
      @checkbox.Checkbox(checkbox.Config{ID: "interactive-labels", Name: "interactive_labels", Value: "on", Label: "Show value labels", Checked: state.Labels, InputAttrs: interactiveChangeAttrs()})
      @button.Button(button.WithType("submit"), button.WithID("interactive-apply")) { Apply interactive controls }
      if len(state.Errors) > 0 { <p role="alert">{ strings.Join(state.Errors, " ") }</p> }
      <p aria-live="polite">Applied: { string(state.Orientation) }, { strconv.Itoa(state.Scale) }% scale, labels { interactiveOnOff(state.Labels) }.</p>
    }
    @interactive.Bar(interactiveConfig(state))
  </div>
}

One palette, four chart semantics

One chart palette changes a line stroke and area, two bar-series fills, ordered pie sectors, and a cold-to-warm heat-map gradient together. Theme tokens are the default; the custom option sends four validated colors while exact values remain adjacent to every chart.

The Goshtoso swatch palette is an event-driven color-name picker. Native palette and color inputs are used here so the same server form remains editable without JavaScript.

Shared chart palette controls
Built-ins keep chart theme classes and tokens. Custom validates four explicit colors.

#0e7490

#2563eb

#d97706

#be123c

Applied: theme palette.

Exact series values
LabelSeriesY axisValue
MonEmailY axis120
TueEmailY axis132
WedEmailY axis101
ThuEmailY axis134
FriEmailY axis90
SatEmailY axis230
SunEmailY axis210
Exact slice values
SliceValueShare of total
Search Engine104833.30155703844932%
Direct73523.355576739752145%
Email58018.430251032729583%
Union Ads48415.379726723863998%
Video Ads3009.532888465204957%
Exact values
YXValue
004.4
014.9
027
037.5
044.3
102.6
115.9
129
136.4
142.3
203.3
216.4
227
234.9
243.2
301.9
316
329
335.9
342.6
404.4
415.9
427
436.4
444.6
Palette form and four charts · templ
package chartsdemo

import (
  "fmt"
  "net/http"
  "strings"

  "github.com/a-h/templ"
  "github.com/araihu/goshtoso-charts/components/bar"
  "github.com/araihu/goshtoso-charts/components/chartcontrol"
  "github.com/araihu/goshtoso-charts/components/charttheme"
  "github.com/araihu/goshtoso-charts/components/heatmap"
  "github.com/araihu/goshtoso-charts/components/line"
  "github.com/araihu/goshtoso-charts/components/pie"
  "github.com/araihu/goshtoso/components/button"
  "github.com/araihu/goshtoso/components/form"
)

var customDefaults = [4]string{"#0e7490", "#2563eb", "#d97706", "#be123c"}

type paletteState struct {
  Palette charttheme.Palette
  Value string
  Colors [4]string
  Custom bool
  Errors []string
}

func paletteStateFromRequest(r *http.Request) paletteState {
  state := paletteState{Value: "theme", Colors: customDefaults}
  if r.URL.Query().Get("palette_present") != "1" { return state }
  switch value := r.URL.Query().Get("chart_palette"); value {
  case "theme": state.Value = value
  case "araihu": state.Palette, state.Value = charttheme.PaletteAraiHu, value
  case "bold": state.Palette, state.Value = charttheme.PaletteBold, value
  case "neutral": state.Palette, state.Value = charttheme.PaletteNeutral, value
  case "pastel": state.Palette, state.Value = charttheme.PalettePastel, value
  case "status": state.Palette, state.Value = charttheme.PaletteStatus, value
  case "custom":
    state.Value, state.Custom = value, true
    for index := range state.Colors {
      name := fmt.Sprintf("palette_color_%d", index+1)
      if !r.URL.Query().Has(name) { continue }
      color := strings.ToLower(strings.TrimSpace(r.URL.Query().Get(name)))
      if validHex(color) { state.Colors[index] = color } else {
        state.Errors = append(state.Errors, fmt.Sprintf("Color %d must use six-digit hexadecimal notation; %s was restored.", index+1, customDefaults[index]))
      }
    }
  default: state.Errors = append(state.Errors, "Palette must be theme, Arai Hû, bold, neutral, pastel, status, or custom; theme was restored.")
  }
  return state
}

func validHex(value string) bool {
  if len(value) != 7 || value[0] != '#' { return false }
  for _, character := range value[1:] {
    if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f')) { return false }
  }
  return true
}

func style(state paletteState) charttheme.Style {
  result := charttheme.Style{Palette: state.Palette}
  if state.Custom { result.Colors = append([]string(nil), state.Colors[:]...) }
  return result
}

func lineConfig(state paletteState) line.Config {
  minimum, noGap := 0.0, false
  return line.Config{
    Label: "Shared-palette line and area", Caption: "Color 1 controls the line stroke and its translucent area.", Title: line.Title{Text: "Line"},
    Labels: []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"},
    Series: []line.Series{{Name: "Email", Values: []float64{120, 132, 101, 134, 90, 230, 210}}},
    Area: line.AreaOptions{Enabled: true, Opacity: 150.0 / 255.0},
    XAxis: line.CategoryAxisOptions{BoundaryGap: &noGap}, Legend: line.LegendOptions{Padding: line.Padding{Top: 5, Bottom: 10}}, YAxes: []line.Axis{{Min: &minimum}},
    Width: 600, Height: 400, Style: style(state), Controls: chartcontrol.Options{Fullscreen: true}, Export: &chartcontrol.ExportOptions{Filename: "shared-palette-line"},
  }
}

func barConfig(state paletteState) bar.Config {
  return bar.Config{
    Label: "Shared-palette grouped bars", Caption: "Colors 1 and 2 distinguish rainfall and evaporation.",
    Labels: []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun"},
    Series: []bar.Series{
      {Name: "Rainfall", Values: []float64{2, 4.9, 7, 23.2, 25.6, 76.7}},
      {Name: "Evaporation", Values: []float64{2.6, 5.9, 9, 26.4, 28.7, 70.7}},
    },
    Title: "Bar Chart", Legend: bar.LegendOptions{Placement: bar.LegendPlacementEnd, Overlay: true},
    Width: 600, Height: 400, Style: style(state), Controls: chartcontrol.Options{Fullscreen: true}, Export: &chartcontrol.ExportOptions{Filename: "shared-palette-bars"},
  }
}

func pieConfig(state paletteState) pie.Config {
  colors := style(state)
  if state.Custom { colors.Colors = append(colors.Colors, state.Colors[0]) }
  return pie.Config{
    Label: "Shared-palette channel shares", Caption: "Palette order maps to sectors; Color 1 repeats for the fifth custom sector.",
    Title: pie.TitleOptions{Text: "Pie Chart", Subtitle: "(Fake Data)", Placement: pie.PlacementCenter, FontSize: 16, SubtitleFontSize: 10},
    Legend: pie.LegendOptions{Orientation: pie.LegendVertical, LeftPercent: 80, VerticalPlacement: pie.VerticalPlacementBottom, FontSize: 10},
    Padding: pie.Padding{Top: 20, Right: 20, Bottom: 20, Left: 20},
    Slices: []pie.Slice{{Name: "Search Engine", Value: 1048}, {Name: "Direct", Value: 735}, {Name: "Email", Value: 580}, {Name: "Union Ads", Value: 484}, {Name: "Video Ads", Value: 300}},
    Width: 600, Height: 400, Style: colors, Controls: chartcontrol.Options{Fullscreen: true}, Export: &chartcontrol.ExportOptions{Filename: "shared-palette-pie", Background: chartcontrol.ExportBackgroundTransparent},
  }
}

func heatMapConfig(state paletteState) heatmap.Config {
  config := heatmap.Config{
    Label: "Shared-palette heat map", Caption: "The same palette becomes an ordered cold-to-warm value gradient.", Title: "Heat Map Chart",
    XAxis: heatmap.Axis{Title: "X-Axis", Labels: []string{"0", "1", "2", "3", "4"}}, YAxis: heatmap.Axis{Title: "Y-Axis", Labels: []string{"0", "1", "2", "3", "4"}},
    Rows: [][]float64{{4.4, 4.9, 7, 7.5, 4.3}, {2.6, 5.9, 9, 6.4, 2.3}, {3.3, 6.4, 7, 4.9, 3.2}, {1.9, 6, 9, 5.9, 2.6}, {4.4, 5.9, 7, 6.4, 4.6}},
    ValueRange: heatmap.ValueRange{Min: 1.9, Max: 9}, Width: 600, Height: 400,
    Style: charttheme.Style{Palette: state.Palette}, Controls: chartcontrol.Options{Fullscreen: true}, Export: &chartcontrol.ExportOptions{Filename: "shared-palette-heat-map"},
  }
  if state.Custom { config.Gradient = heatmap.Gradient{Stops: []heatmap.GradientStop{
    {At: 0, Color: state.Colors[0]}, {At: 1.0 / 3.0, Color: state.Colors[1]},
    {At: 2.0 / 3.0, Color: state.Colors[2]}, {At: 1, Color: state.Colors[3]},
  }} }
  return config
}

func paletteChangeAttrs() templ.Attributes {
  return templ.Attributes{"hx-get": "/docs/chart-controls", "hx-target": "#palette-chart-control-example", "hx-swap": "outerHTML focus-scroll:false", "hx-include": "closest form", "hx-trigger": "change"}
}

func paletteFalsePointer() *bool { value := false; return &value }

func PaletteChartsHandler(w http.ResponseWriter, r *http.Request) {
  w.Header().Set("Content-Type", "text/html; charset=utf-8")
  if err := PaletteCharts(paletteStateFromRequest(r)).Render(r.Context(), w); err != nil { http.Error(w, "render palette charts", http.StatusInternalServerError) }
}

templ PaletteCharts(state paletteState) {
  <div id="palette-chart-control-example">
    @form.Form(form.Config{ID: "palette-chart-control-form", Action: "/docs/chart-controls#palette-chart-control-example", Method: "get", PreventEnterSubmit: paletteFalsePointer(), HTMX: &form.HTMXConfig{Get: "/docs/chart-controls", Target: "#palette-chart-control-example", Swap: "outerHTML focus-scroll:false"}}) {
      <input type="hidden" name="palette_present" value="1"/>
      <label for="chart-palette">Chart palette</label>
      <select id="chart-palette" name="chart_palette" { paletteChangeAttrs()... }>
        <option value="theme" selected?={ state.Value == "theme" }>Theme tokens</option>
        <option value="araihu" selected?={ state.Value == "araihu" }>Arai </option>
        <option value="bold" selected?={ state.Value == "bold" }>Bold</option>
        <option value="neutral" selected?={ state.Value == "neutral" }>Neutral</option>
        <option value="pastel" selected?={ state.Value == "pastel" }>Pastel</option>
        <option value="status" selected?={ state.Value == "status" }>Status</option>
        <option value="custom" selected?={ state.Value == "custom" }>Custom four colors</option>
      </select>
      for index, color := range state.Colors {
        <label for={ fmt.Sprintf("palette-color-%d", index+1) }>Color { fmt.Sprintf("%d", index+1) }</label>
        <input id={ fmt.Sprintf("palette-color-%d", index+1) } type="color" name={ fmt.Sprintf("palette_color_%d", index+1) } value={ color } disabled?={ !state.Custom } { paletteChangeAttrs()... }/>
      }
      @button.Button(button.WithType("submit"), button.WithID("palette-apply")) { Apply palette }
      if len(state.Errors) > 0 { <p role="alert">{ strings.Join(state.Errors, " ") }</p> }
	  <p aria-live="polite">Applied palette: { state.Value }.</p>
    }
    <div class="grid gap-5 lg:grid-cols-2">
      @line.Line(lineConfig(state))
      @bar.Bar(barConfig(state))
      @pie.Pie(pieConfig(state))
      @heatmap.HeatMap(heatMapConfig(state))
    </div>
  </div>
}

Wrapper lifecycle

chartcontrol.Options.Mode is one closed state shared by static/vector and interactive charts. Its zero value and WrapperModeEnabled both mean enabled, so overlapping wrapper booleans and precedence rules do not exist. Mode applies before individual action settings: omitted returns the chart without a wrapper, while every non-omitted mode preserves the configured action set and wrapper runtime. Even an actionless non-omitted wrapper loads the versioned same-origin external runtime, so lifecycle events work without inline script. Omitted mode alone suppresses that runtime.

ModeRendered resultClient transitionUse it when
WrapperModeEnabledDefault. Wrapper, chart, available actions, and wrapper runtime are active.Can enter from disabled or hidden.People should see and operate the chart normally.
WrapperModeDisabledChart stays visible, live, and theme-aware. Wrapper actions remain visible but inert and expose disabled state.Can enter or leave with the set-mode event.Context remains useful while actions must be temporarily unavailable.
WrapperModeHiddenWrapper and chart remain in the DOM with runtime state intact, but the subtree is hidden, inert, and aria-hidden.Can enter or leave with an external trigger. Caller owns trigger placement and focus.The same initialized chart will be revealed again without a server round trip.
WrapperModeOmittedOnly the chart renders: no wrapper, actions, status region, modal, or wrapper-control runtime.Server-only. Change it by rerendering or swapping the chart.Consumer supplies its own shell or wants the smallest static/vector no-JavaScript surface.

Rendered DOM contract

Every non-omitted root exposes data-goshtoso-chart-wrapper and data-goshtoso-chart-wrapper-mode="enabled|disabled|hidden". When actions render, disabled applies native disabled and aria-disabled="true" to their fieldset; it does not disable chart exploration, pause live data, or hide the chart. Re-enabling removes only wrapper-added disabled state and preserves any action that was already unavailable. Hidden applies native hidden, inert, and aria-hidden="true" to the wrapper. Omitted is exact chart passthrough: no wrapper root or wrapper-control runtime exists.

Treat these attributes as observable state, not a mutation API. Use the public helper or request event so transient UI, focus, status, and resize behavior stay synchronized.

Choose the initial state in Go

templ
@line.Line(line.Config{
  Label:     "Weekly revenue",
  Labels:    []string{"Mon", "Tue", "Wed"},
  RootAttrs: templ.Attributes{"id": "weekly-revenue"},
  Series: []line.Series{{
    Name:   "Revenue",
    Values: []float64{12, 18, 14},
  }},
  Controls: chartcontrol.Options{
    // Omit Mode for WrapperModeEnabled, the zero/default.
    Mode: chartcontrol.WrapperModeHidden,
  },
})

Client transitions

Dispatch the stable bubbling goshtoso-charts:set-wrapper-mode event on a wrapper or any descendant. Its detail is { mode, focusReturn? }; mode accepts enabled, disabled, or hidden. After transient UI closes, a connected external HTMLElement in focusReturn receives focus only when focus remains inside the wrapper. Focus already outside the wrapper is preserved; focus still inside without a valid external target is blurred.

Plain JavaScript

JavaScript
const chart = document.querySelector("#weekly-revenue");
const wrapper = chart.closest("[data-goshtoso-chart-wrapper]");
const showButton = document.querySelector("#show-weekly-revenue");

wrapper.addEventListener("goshtoso-charts:wrapper-mode-change", (event) => {
  console.log(event.detail.previousMode, event.detail.mode);
});

// The helper returns false for an unknown mode or a missing wrapper.
const changed = window.__goshtosoChartsControls.setWrapperMode(
  wrapper,
  "hidden",
  showButton,
);

Alpine

templ
<div
  x-data="{
    mode: 'enabled',
    setMode(next, focusReturn) {
      const wrapper = this.$refs.chart
        .querySelector('[data-goshtoso-chart-wrapper]');
      wrapper.dispatchEvent(new CustomEvent(
        'goshtoso-charts:set-wrapper-mode',
        { bubbles: true, detail: { mode: next, focusReturn } },
      ));
    },
  }"
  @goshtoso-charts:wrapper-mode-change="mode = $event.detail.mode"
>
  <button type="button" @click="setMode('hidden', $el)">Hide chart</button>
  <button type="button" @click="setMode('enabled', $el)">Show chart</button>
  <div x-ref="chart">
    @line.Line(line.Config{
      Label:  "Weekly revenue",
      Labels: []string{"Mon", "Tue", "Wed"},
      Series: []line.Series{{
        Name: "Revenue", Values: []float64{12, 18, 14},
      }},
    })
  </div>
</div>

The equivalent public helper is window.__goshtosoChartsControls.setWrapperMode(wrapper, mode, focusReturn). For compatibility it accepts both the empty Go zero value and "enabled", but DOM state and change-event details always use the canonical value "enabled". The helper returns false when the wrapper is missing or the requested mode is omitted or unknown; otherwise it returns true. After applying a changed state, the wrapper emits goshtoso-charts:wrapper-mode-change with exact detail { previousMode, mode }. Listen on the wrapper or an ancestor; the event bubbles. Directly editing mode, hidden, inert, or fieldset attributes bypasses lifecycle guarantees.

HTMX swaps

Let the server return the desired initial mode when HTMX replaces a chart. The runtime observes htmx:load and htmx:afterSwap, and a MutationObserver covers other DOM insertion and removal. Preparation is idempotent: newly swapped wrappers rehydrate from their rendered mode without application re-init code. This also handles full wrapper replacement, including enabled-to-omitted and omitted-to-non-omitted transitions. An omitted chart itself has no wrapper to receive a client event.

templ
templ WeeklyRevenue(mode chartcontrol.WrapperMode) {
  <div id="weekly-revenue-slot">
    @line.Line(line.Config{
      Label:  "Weekly revenue",
      Labels: []string{"Mon", "Tue", "Wed"},
      Series: []line.Series{{
        Name: "Revenue", Values: []float64{12, 18, 14},
      }},
      Controls: chartcontrol.Options{Mode: mode},
    })
  </div>
}

<button
  hx-get="/charts/weekly-revenue?wrapper=hidden"
  hx-target="#weekly-revenue-slot"
  hx-swap="outerHTML"
>Hide chart</button>

Map request values to the four known WrapperMode constants before rendering; reject unknown values instead of casting arbitrary input.

State guarantees

Reveal and re-enable

The same DOM and renderer instance remain in place. The wrapper requests a settled resize after becoming visible or active, so interactive canvases and responsive static charts fit current layout.

Live data and theme

Disabled and hidden charts keep the latest live snapshot and current theme. Reveal does not restore stale server data or recreate the renderer.

Transient UI and focus

Disabling or hiding closes open menus, modal expansion, and fullscreen state first, then checks where focus actually settled. If focus remains inside, a connected external focusReturn is honored or the active element is blurred. Focus already outside is preserved. Hidden content leaves focus and accessibility traversal.

Export and validation

Wrapper mode never adds an unsupported format. Enabled, disabled, and hidden validate export configuration during server rendering. Omitted skips wrapper-only export validation because no wrapper action or export runtime renders.

No-JavaScript behavior

Static/vector charts remain readable when enabled or disabled; controls cannot act without JavaScript. Hidden stays hidden. Omitted leaves the static/vector chart visible with no wrapper runtime. Interactive charts still require their chart runtime regardless of wrapper mode. Client mode events and hidden-to-visible transitions require JavaScript; use an HTMX or full-page server response when the server must own state.

Expand and fullscreen

Expand

Moves the same chart DOM into a large, centered Goshtoso modal, requests a resize after layout settles, traps focus, closes with Escape, and restores both the chart and trigger focus.

Fullscreen

Uses the browser fullscreen API when available and a viewport fallback otherwise. It preserves the same chart instance, reports active state, resizes on entry and exit, and restores focus.

Responsive actions

The Goshtoso ActionGroup keeps the primary Expand action visible. When Expand and fullscreen are both enabled, a wide layout presents them as one stacked Expand dropdown. Multiple export formats use an Export dropdown. As space narrows, trailing groups are flattened into one accessible overflow menu—there are no nested submenus on constrained layouts.

Keyboard: Space, Enter, or Arrow Down opens a menu; Arrow Up and Arrow Down move through enabled actions; Escape closes it and returns focus to the trigger.

Export capabilities

Chart outputDefault formatsBackgroundHow it is captured
Static/vector SVGSVG and PNGOpaque or transparentSerialize computed SVG styles; rasterize in-browser for PNG.
Interactive canvasPNGOpaqueSnapshot the live chart instance.

PixelRatio defaults to 1. Filenames are normalized to a lowercase, filesystem-safe basename. Unsupported format and background requests fail during rendering instead of showing a control that cannot work. Wrapper lifecycle does not expand this capability matrix.

Configure controls

Static/vector

templ
@line.Line(line.Config{
  Label: "Weekly revenue",
  Labels: []string{"Mon", "Tue", "Wed"},
  Series: []line.Series{{Name: "Revenue", Values: []float64{12, 18, 14}}},
  Controls: chartcontrol.Options{
    Fullscreen: true,
  },
  Export: &chartcontrol.ExportOptions{
    Filename: "weekly-revenue",
    Formats: []chartcontrol.ExportFormat{
      chartcontrol.ExportSVG,
      chartcontrol.ExportPNG,
    },
    Background: chartcontrol.ExportBackgroundTransparent,
    PixelRatio: 2,
  },
})

Interactive

templ
@interactiveline.Line(interactiveline.Config{
  Label: "Weekly revenue",
  XAxis: []string{"Mon", "Tue", "Wed"},
  Series: []interactiveline.Series{{
    Name: "Revenue",
    Data: []interactiveline.Data{{Value: 12}, {Value: 18}, {Value: 14}},
  }},
  Options: interactive.ChartOptions{
    Controls: chartcontrol.Options{
      Expand: chartcontrol.Bool(false),
      Fullscreen: true,
    },
    Export: &chartcontrol.ExportOptions{
      Filename: "weekly-revenue",
      Formats: []chartcontrol.ExportFormat{chartcontrol.ExportPNG},
    },
  },
})

Accessibility and status

Controls form a named group based on the chart label. Modal focus is contained while open. Fullscreen exposes pressed state. Export actions become busy while processing and report completion or failure through a polite live region. These controls improve operation, but they do not make chart geometry equivalent to exact data; retain a caption, summary, disclosure, or adjacent table as the task requires.

Caller responsibilities

Hidden state

Place the reveal trigger outside the wrapper and supply focusReturn when hiding from inside it. The wrapper cannot invent application navigation or decide where focus belongs. Without JavaScript, hidden content cannot reveal itself.

Disabled state

Disabled affects shared wrapper actions only. Configure chart-specific tooltip, zoom, selection, animation, or live-data behavior separately when those interactions must also stop.

Exact data

Keep a caption, summary, disclosure, table, or download when exact values matter. Wrapper controls do not make chart pixels or SVG geometry an accessible data model.

Unknown modes

Validate request values against the four Go constants. Client requests for omitted or unknown modes are ignored; the public helper returns false and no change event fires.

Unsupported export

Request only formats and backgrounds proven by the capability table. Invalid server configuration fails rendering; browser capture failures are announced in the wrapper status region.

Go API

v0.0.1

These guides explain behavior and composition. pkg.go.dev is the canonical reference for exported types, functions, methods, and Go documentation.

Related guideStatic and interactive