Expand
Enabled by default and kept as the primary action.
Shared behavior
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.
Enabled by default and kept as the primary action.
Disabled by default; opt in with Fullscreen: true.
Enabled from the chart capability unless Export.Disabled is true.
Not part of the public control API.
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.
Change server-rendered SVG options and all four wrapper lifecycle states. The dataset and filled-area intent follow the centrally recorded Line example.
| Label | Series | Y axis | Value |
|---|---|---|---|
| Mon | Y axis | 120 | |
| Tue | Y axis | 132 | |
| Wed | Y axis | 101 | |
| Thu | Y axis | 134 | |
| Fri | Y axis | 90 | |
| Sat | Y axis | 230 | |
| Sun | Y axis | 210 |
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>
}Change orientation, data scale, and visible labels while the replacement initializes one responsive canvas and retains its exact-value disclosure.
| Category | Series | Value |
|---|---|---|
| Mon | Category A | 60 |
| Mon | Category B | 9 |
| Tue | Category A | 251 |
| Tue | Category B | 43 |
| Wed | Category A | 113 |
| Wed | Category B | 58 |
| Thu | Category A | 136 |
| Thu | Category B | 150 |
| Fri | Category A | 134 |
| Fri | Category B | 74 |
| Sat | Category A | 205 |
| Sat | Category B | 126 |
| Sun | Category A | 229 |
| Sun | Category B | 183 |
7 categories across 2 series.
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 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.
| Label | Series | Y axis | Value |
|---|---|---|---|
| Mon | Y axis | 120 | |
| Tue | Y axis | 132 | |
| Wed | Y axis | 101 | |
| Thu | Y axis | 134 | |
| Fri | Y axis | 90 | |
| Sat | Y axis | 230 | |
| Sun | Y axis | 210 |
| Slice | Value | Share of total |
|---|---|---|
| Search Engine | 1048 | 33.30155703844932% |
| Direct | 735 | 23.355576739752145% |
| 580 | 18.430251032729583% | |
| Union Ads | 484 | 15.379726723863998% |
| Video Ads | 300 | 9.532888465204957% |
| Y | X | Value |
|---|---|---|
| 0 | 0 | 4.4 |
| 0 | 1 | 4.9 |
| 0 | 2 | 7 |
| 0 | 3 | 7.5 |
| 0 | 4 | 4.3 |
| 1 | 0 | 2.6 |
| 1 | 1 | 5.9 |
| 1 | 2 | 9 |
| 1 | 3 | 6.4 |
| 1 | 4 | 2.3 |
| 2 | 0 | 3.3 |
| 2 | 1 | 6.4 |
| 2 | 2 | 7 |
| 2 | 3 | 4.9 |
| 2 | 4 | 3.2 |
| 3 | 0 | 1.9 |
| 3 | 1 | 6 |
| 3 | 2 | 9 |
| 3 | 3 | 5.9 |
| 3 | 4 | 2.6 |
| 4 | 0 | 4.4 |
| 4 | 1 | 5.9 |
| 4 | 2 | 7 |
| 4 | 3 | 6.4 |
| 4 | 4 | 4.6 |
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 Hû</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>
}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.
| Mode | Rendered result | Client transition | Use it when |
|---|---|---|---|
WrapperModeEnabled | Default. Wrapper, chart, available actions, and wrapper runtime are active. | Can enter from disabled or hidden. | People should see and operate the chart normally. |
WrapperModeDisabled | Chart 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. |
WrapperModeHidden | Wrapper 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. |
WrapperModeOmitted | Only 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. |
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.
@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,
},
})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.
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,
);<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.
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 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.
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.
Disabled and hidden charts keep the latest live snapshot and current theme. Reveal does not restore stale server data or recreate the renderer.
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.
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.
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.
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.
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.
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.
| Chart output | Default formats | Background | How it is captured |
|---|---|---|---|
| Static/vector SVG | SVG and PNG | Opaque or transparent | Serialize computed SVG styles; rasterize in-browser for PNG. |
| Interactive canvas | PNG | Opaque | Snapshot 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.
@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,
},
})@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},
},
},
})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.
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 affects shared wrapper actions only. Configure chart-specific tooltip, zoom, selection, animation, or live-data behavior separately when those interactions must also stop.
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.
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.
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.
These guides explain behavior and composition. pkg.go.dev is the canonical reference for exported types, functions, methods, and Go documentation.