adam_web_ui/spectrum.rs
1//! Dioxus element bindings and component wrappers for Spectrum Web Components.
2//!
3//! Import with `use crate::spectrum::*;` to bring component wrappers into scope.
4//! Callers only need the `SpXxx` component functions.
5
6#![allow(non_snake_case)]
7
8use dioxus::prelude::*;
9
10// ─── Component wrappers ─────────────────────────────────────────────────────
11// PascalCase functions are Dioxus components; RSX resolves them via function
12// call, not as element bindings. Each wraps one SWC custom element.
13//
14// Hyphenated identifiers in RSX (e.g. `sp-theme`) are parsed as custom-element
15// string literals by the RSX macro (ElementName::Custom), so no element module
16// declaration is required — the tag name is emitted verbatim.
17
18/// Provides Spectrum token context for all descendant SWC components.
19///
20/// Must be the root ancestor of any `SpXxx` component. Maps to `<sp-theme>`.
21#[component]
22pub fn SpTheme(color: String, scale: String, system: String, children: Element) -> Element {
23 rsx! {
24 sp-theme {
25 "color": "{color}",
26 "scale": "{scale}",
27 "system": "{system}",
28 {children}
29 }
30 }
31}
32
33/// Single-line text input.
34///
35/// Maps to `<sp-textfield>`. Fires standard DOM `input`, `focus`, and `blur`
36/// events. Setting `invalid` to `true` renders the SWC error state (red ring
37/// and `aria-invalid`). Setting `disabled` to `true` renders the SWC disabled
38/// state and blocks focus/input at the DOM level.
39#[component]
40pub fn SpTextfield(
41 id: String,
42 value: String,
43 invalid: bool,
44 disabled: bool,
45 oninput: EventHandler<FormEvent>,
46 onfocus: EventHandler<FocusEvent>,
47 onblur: EventHandler<FocusEvent>,
48) -> Element {
49 rsx! {
50 sp-textfield {
51 "id": "{id}",
52 "value": "{value}",
53 // Boolean attribute: omit entirely when false; presence = invalid.
54 "invalid": if invalid { "true" },
55 "disabled": if disabled { "true" },
56 oninput: move |e| oninput.call(e),
57 onfocus: move |e| onfocus.call(e),
58 onblur: move |e| onblur.call(e),
59 }
60 }
61}
62
63/// Single-line numeric input.
64///
65/// Maps to `<sp-number-field>`. Fires standard DOM `input`, `focus`, and `blur` events, exactly
66/// like [`SpTextfield`] — including the same custom-element caveat: Dioxus's event serializer
67/// never populates `event.target.value` for a custom element, so reading the live value off the
68/// DOM (not the synthetic event) is the caller's job. `value` is passed as its string
69/// representation; the element renders and edits it as a number internally. The field fills its
70/// container's inline size, leaving room for formatted values and validation icons. `min`/`max`, when
71/// present, are passed through to the underlying SWC element, which natively disables its
72/// increment/decrement stepper buttons once the value reaches the corresponding bound. Setting
73/// `readonly` to `true` renders the SWC read-only state — the value stays visible and
74/// selectable, but not editable — distinct from `disabled`, which additionally grays the field
75/// out and suppresses the `invalid` treatment (the input's border) entirely; `readonly` leaves
76/// `invalid`'s border alone but SWC's own `:host([readonly]) .input` rule pins the border color
77/// to transparent regardless, so on a `readonly` field the border itself never visibly reflects
78/// `invalid` either — confirmed against the rendered shadow-DOM stylesheet via the
79/// verifying-begin-ui skill; there is no documented or supported `readonly`+`invalid` border
80/// treatment in Spectrum. `invalid` still matters on a `readonly` field for two reasons that
81/// don't depend on the border: it sets `aria-invalid` for assistive tech, and it's exactly what
82/// gates whether SWC exposes the `negative-help-text` slot at all (see `children`) — so pass a
83/// slotted [`SpHelpText`] there rather than inventing a non-standard visual override to
84/// compensate for the missing border. `step`, when present, sets the amount the stepper
85/// buttons/arrow keys/scroll wheel move the value by — pass `"1"` for an integer-typed cell so
86/// the stepper can never land it on a fractional value. `format_options` supplies JSON-encoded
87/// `Intl.NumberFormat` options for locale-aware display without changing the stored value.
88/// `children` slots into the element's light DOM, e.g. an [`SpHelpText`] with
89/// `slot: "negative-help-text"` or `slot: "help-text"`.
90#[component]
91pub fn SpNumberfield(
92 id: String,
93 value: String,
94 min: Option<String>,
95 max: Option<String>,
96 step: Option<String>,
97 format_options: Option<&'static str>,
98 invalid: bool,
99 disabled: bool,
100 readonly: bool,
101 oninput: EventHandler<FormEvent>,
102 onfocus: EventHandler<FocusEvent>,
103 onblur: EventHandler<FocusEvent>,
104 #[props(default)] children: Element,
105) -> Element {
106 rsx! {
107 sp-number-field {
108 style: "inline-size: 100%;",
109 "id": "{id}",
110 "value": "{value}",
111 "min": min.as_deref(),
112 "max": max.as_deref(),
113 "step": step.as_deref(),
114 "format-options": format_options.unwrap_or("{}"),
115 "invalid": if invalid { "true" },
116 "disabled": if disabled { "true" },
117 "readonly": if readonly { "true" },
118 oninput: move |e| oninput.call(e),
119 onfocus: move |e| onfocus.call(e),
120 onblur: move |e| onblur.call(e),
121 {children}
122 }
123 }
124}
125
126/// A draggable slider for a numeric value with live min/max bounds, its own built-in label,
127/// and an optional inline editable number field.
128///
129/// Maps to `<sp-slider>`. `label` renders as part of the slider's own markup (its internal
130/// `sp-field-label`, linked to the handle or, when `editable` is set, to the paired
131/// `sp-number-field`) — a caller using this component does not also render a separate
132/// [`SpFieldLabel`] alongside it. `min`/`max` are passed as strings, recomputed by the caller on
133/// every render from the cell's current filter bounds (see `adam-web-ui/src/labels.rs`'s
134/// `CellMeta::range`), so a range driven by other cells stays live. `step`, when present, sets
135/// the amount the drag handle/stepper/arrow keys move the value by — pass `"1"` for an
136/// integer-typed cell, mirroring [`SpNumberfield`]'s own `step`, or `"0.01"` for a floating-point
137/// cell displayed to two decimal places. Omitting `step` leaves Spectrum's default of one,
138/// which also snaps the inline number field to whole numbers. Setting `editable` to `true`
139/// renders an internal `sp-number-field` alongside the track (SWC's own `Slider.render`),
140/// letting the user type an exact value instead of only dragging; both the drag handle and the
141/// editable field dispatch the same DOM `input` event on this element (SWC's
142/// `SliderHandle.dispatchInputEvent`), so a single `oninput` handler here covers both — reading
143/// the live numeric value off the DOM (not the synthetic event) is the caller's job, mirroring
144/// [`SpTextfield`]/[`SpNumberfield`]. `sp-slider` has no `invalid`/`readonly` state; a caller
145/// that needs to surface either (e.g. a failing `require` on a range-filtered out cell) has to
146/// render that separately, alongside this component. `onfocus`/`onblur` fire for both the drag
147/// handle and (when `editable` is set) the inline number field — neither SWC handle re-dispatches
148/// a bubbling event, but DOM `focus`/`blur` are `composed`, so a listener on this host element
149/// still observes them across the intervening shadow boundaries, mirroring
150/// [`SpTextfield`]/[`SpNumberfield`]. `format_options` supplies JSON-encoded `Intl.NumberFormat`
151/// options for both the value label and the inline number field without changing the stored value.
152/// Empty inline edits remain uncommitted rather than restoring the slider's last numeric value.
153/// A mounted slider captures unmanaged inline edits after Spectrum parses them, stores the raw
154/// localized text (including blanks) in `adamNumericDraft`, and redispatches a host `input` event.
155/// The Inspector's numeric input bridge consumes and deletes that draft once; handle drags instead
156/// read the canonical host value. Empty inline `change` events are suppressed to preserve blanks.
157#[component]
158pub fn SpSlider(
159 id: String,
160 label: String,
161 value: String,
162 min: String,
163 max: String,
164 step: Option<String>,
165 format_options: Option<&'static str>,
166 editable: bool,
167 disabled: bool,
168 oninput: EventHandler<FormEvent>,
169 onfocus: EventHandler<FocusEvent>,
170 onblur: EventHandler<FocusEvent>,
171) -> Element {
172 rsx! {
173 sp-slider {
174 "id": "{id}",
175 "label": "{label}",
176 "value": "{value}",
177 "min": "{min}",
178 "max": "{max}",
179 "step": step.as_deref(),
180 "format-options": format_options.unwrap_or("{}"),
181 "editable": if editable { "true" },
182 "disabled": if disabled { "true" },
183 oninput: move |e| oninput.call(e),
184 onfocus: move |e| onfocus.call(e),
185 onblur: move |e| onblur.call(e),
186 onmounted: move |_| {
187 let script = format!(
188 r#"
189 {bridge}
190 installNumericSliderBridge(document.getElementById({id:?}));
191 "#,
192 bridge = include_str!("numeric_input.js"),
193 );
194 document::eval(&script);
195 },
196 }
197 }
198}
199
200/// Label associated with a form control.
201///
202/// Maps to `<sp-field-label>`. The `for_` prop sets the `for` HTML attribute
203/// linking the label to an input by id.
204#[component]
205pub fn SpFieldLabel(for_: String, children: Element) -> Element {
206 rsx! {
207 sp-field-label {
208 "for": "{for_}",
209 {children}
210 }
211 }
212}
213
214/// Descriptive or validation text associated with a form control.
215///
216/// Maps to `<sp-help-text>`. Meaningless on its own — a form control like
217/// [`SpNumberfield`] renders it only when passed as that control's `children`, with
218/// `slot` set to one of the two slot names the control's own shadow DOM exposes:
219/// `"help-text"` for default/non-negative text, always rendered when present, or
220/// `"negative-help-text"` for text shown only while the control's own `invalid` prop is
221/// `true` (confirmed against the bundled SWC source's `HelpTextManager.render`, which
222/// switches which slot it exposes based on exactly that prop — the control ignores an
223/// `sp-help-text` slotted into `"negative-help-text"` whenever `invalid` is `false`).
224/// `variant` mirrors the element's own `variant` property (`"negative"` renders the
225/// error/red treatment); note that slotting into `"negative-help-text"` sets this to
226/// `"negative"` automatically on the control's own side, so passing it here is
227/// belt-and-suspenders, not load-bearing.
228#[component]
229pub fn SpHelpText(slot: String, variant: String, children: Element) -> Element {
230 rsx! {
231 sp-help-text {
232 "slot": "{slot}",
233 "variant": "{variant}",
234 {children}
235 }
236 }
237}
238
239/// Horizontal visual separator.
240///
241/// Maps to `<sp-divider>` with `size="s"` (small).
242#[component]
243pub fn SpDivider() -> Element {
244 rsx! {
245 sp-divider {
246 "size": "s",
247 }
248 }
249}
250
251/// Section heading.
252///
253/// Maps to `<sp-heading>`.
254#[component]
255pub fn SpHeading(children: Element) -> Element {
256 rsx! {
257 sp-heading {
258 {children}
259 }
260 }
261}
262
263/// Groups a row of `SpActionButton`s into a single visual cluster.
264///
265/// Maps to `<sp-action-group>`. Setting `compact` to `true` removes the gaps between
266/// buttons and rounds only the group's outermost corners — interior buttons (including
267/// a lone middle button) render square on both sides.
268#[component]
269pub fn SpActionGroup(compact: bool, children: Element) -> Element {
270 rsx! {
271 sp-action-group {
272 // Boolean attribute: omit entirely when false; presence = compact.
273 "compact": if compact { "true" },
274 {children}
275 }
276 }
277}
278
279/// A single button within an `SpActionGroup` (or standalone).
280///
281/// Maps to `<sp-action-button>`. `selected` renders it in its pressed/active
282/// visual state (e.g. to highlight the current choice in a group of buttons
283/// used as a picker).
284#[component]
285pub fn SpActionButton(
286 onclick: EventHandler<MouseEvent>,
287 #[props(default)] selected: bool,
288 children: Element,
289) -> Element {
290 rsx! {
291 sp-action-button {
292 onclick: move |e| onclick.call(e),
293 // Boolean attribute: omit entirely when false; presence = selected.
294 "selected": if selected { "true" },
295 {children}
296 }
297 }
298}
299
300/// A labeled on/off toggle.
301///
302/// Maps to `<sp-switch>`; slotted `children` render as its label. `checked`
303/// renders its current state; `onclick` fires on every toggle press,
304/// mirroring `SpActionButton`'s `selected`/`onclick` pattern (the caller owns
305/// the boolean state and re-renders `checked` from it) rather than reading
306/// the new state back off a native `change` event.
307#[component]
308pub fn SpSwitch(checked: bool, onclick: EventHandler<MouseEvent>, children: Element) -> Element {
309 rsx! {
310 sp-switch {
311 onclick: move |e| onclick.call(e),
312 // Boolean attribute: omit entirely when false; presence = checked.
313 "checked": if checked { "true" },
314 {children}
315 }
316 }
317}
318
319/// A togglable checkbox for a `bool`-typed value.
320///
321/// Maps to `<sp-checkbox>`. `children` is the checkbox's own label, rendered as its
322/// light-DOM content (`<sp-checkbox>label</sp-checkbox>`) — SWC gives `sp-checkbox` no
323/// separate label slot, so a sibling field label would render disconnected from the
324/// control. Setting `invalid` to `true` renders the SWC error state. Setting `disabled`
325/// to `true` renders the SWC disabled state. `onclick` fires on every toggle press,
326/// mirroring `SpSwitch`'s `checked`/`onclick` pattern — the caller owns the boolean
327/// state and re-renders `checked` from it rather than reading the new state off a
328/// native `change` event.
329#[component]
330pub fn SpCheckbox(
331 id: String,
332 checked: bool,
333 invalid: bool,
334 disabled: bool,
335 onclick: EventHandler<MouseEvent>,
336 children: Element,
337) -> Element {
338 rsx! {
339 sp-checkbox {
340 "id": "{id}",
341 onclick: move |e| onclick.call(e),
342 // Boolean attribute: omit entirely when false; presence = checked/invalid/disabled.
343 "checked": if checked { "true" },
344 "invalid": if invalid { "true" },
345 "disabled": if disabled { "true" },
346 {children}
347 }
348 }
349}
350
351/// A scrollable, selectable vertical list of items — used as the examples
352/// picker sidebar.
353///
354/// Maps to `<sp-sidenav>`. `value` must equal the `value` of whichever child
355/// `SpSideNavItem` should render selected. This is load-bearing, not
356/// cosmetic: `<sp-sidenav-item>` derives its own selected state by comparing
357/// its `value` against its parent's once per connect (SWC's
358/// `SidenavItem.startTrackingSelection`), silently clearing any `selected`
359/// attribute set directly on an item whose `value` doesn't match — confirmed
360/// by inspecting the bundled `assets/swc.js` source and the rendered DOM.
361#[component]
362pub fn SpSideNav(value: String, children: Element) -> Element {
363 rsx! {
364 sp-sidenav {
365 "value": "{value}",
366 {children}
367 }
368 }
369}
370
371/// A single item within an `SpSideNav`.
372///
373/// Maps to `<sp-sidenav-item>`. `label` sets the item's visible text (via the
374/// element's `label` attribute, not slotted content). `value` must be unique
375/// among sibling items; pass the parent `SpSideNav`'s `value` here exactly
376/// when this item should render selected — see `SpSideNav`'s doc comment for
377/// why a matching `value` (not just `selected`) is required. `selected`
378/// renders it in its highlighted/active state (e.g. to mark the current
379/// choice in a list used as a picker); pass the same condition used to
380/// decide `SpSideNav`'s `value`, so the attribute is already correct on the
381/// very first render rather than only after a later selection change.
382#[component]
383pub fn SpSideNavItem(
384 label: String,
385 value: String,
386 onclick: EventHandler<MouseEvent>,
387 #[props(default)] selected: bool,
388) -> Element {
389 rsx! {
390 sp-sidenav-item {
391 "label": "{label}",
392 "value": "{value}",
393 onclick: move |e| onclick.call(e),
394 // Boolean attribute: omit entirely when false; presence = selected.
395 "selected": if selected { "true" },
396 }
397 }
398}
399
400/// Zoom-in glyph, used as `SpActionButton` icon content.
401///
402/// Maps to `<sp-icon-zoom-in>`, assigned to the button's `icon` slot so
403/// `ActionButton` centers and sizes it like any other action-button icon
404/// instead of treating it as label text. Rendered via `dangerous_inner_html`
405/// on a wrapping `span` rather than the usual `sp-icon-zoom-in {}` RSX element
406/// syntax: dioxus-rsx 0.7.9 reconstructs hyphenated custom-element tag names
407/// by joining each `-`-separated segment's `Ident::to_string()`, and a segment
408/// matching a Rust keyword (`in`) parses as a raw identifier whose
409/// `to_string()` includes the `r#` prefix, corrupting the tag to
410/// `sp-icon-zoom-r#in`.
411#[component]
412pub fn SpIconZoomIn() -> Element {
413 rsx! {
414 span {
415 "slot": "icon",
416 dangerous_inner_html: "<sp-icon-zoom-in></sp-icon-zoom-in>",
417 }
418 }
419}
420
421/// Zoom-out glyph, used as `SpActionButton` icon content.
422///
423/// Maps to `<sp-icon-zoom-out>`, assigned to the button's `icon` slot so
424/// `ActionButton` centers and sizes it like any other action-button icon
425/// instead of treating it as label text.
426#[component]
427pub fn SpIconZoomOut() -> Element {
428 rsx! {
429 sp-icon-zoom-out {
430 "slot": "icon",
431 }
432 }
433}