Skip to main content

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}