# Guides How-tos that go beyond the reference. Each builds on the [Concepts](./concepts.md) — especially the [control vs field split](./concepts.md#control-vs-field--the-key-distinction). **Using the library:** - [Composing a section body](#composing-a-section-body) - [Custom content and custom CSS](#custom-content-and-custom-css) - [Reactive values and change events](#reactive-values-and-change-events) - [Choosing a button](#choosing-a-button) - [A toolbar with a fluid search](#a-toolbar-with-a-fluid-search) - [Designing dock sections](#designing-dock-sections) - [App-wide shortcuts and saved state](#app-wide-shortcuts-and-saved-state) **Extending it:** - [Building a custom control](#building-a-custom-control) - [Building a custom field](#building-a-custom-field) - [Validation & tooltips](#validation--tooltips) - [Building a custom search or filter control](#building-a-custom-search-or-filter-control) - [Deep-linking to a field](#deep-linking-to-a-field) --- ## Composing a section body A `Section` body has padding and nothing else. It doesn't arrange what you put in it, which is what makes custom content possible. So the structure is yours to choose: | You're adding | Use | Why | |---|---|---| | One field | The field on its own | Nothing to stack | | Several fields | `FieldGroup` | Rows have no gap of their own; the group provides it | | A titled block of fields | `Fieldset` | A group with a heading | | A block that collapses | A nested `Section` | | | A value only the app changes | `ReadoutField` | A caption and selectable text, with no input to imply it's editable | | A sentence or two | `Label layout="expanded"` | A compact `Label` truncates to one line | | A warning or status message | `Label level="warn"` | Same row, colored by severity | | Richer prose, a table, a tree | Your own markup | See [Custom content and custom CSS](#custom-content-and-custom-css) | **Every `*Field` and `Label` is a row, and a row has no margin.** Rows placed directly in a section or modal body touch, with no error or warning, and it only looks slightly wrong. So wrap any stack of two or more: ```tsx
``` A group's direct children are its rows. A wrapper around one row is fine, but a wrapper around several takes them out of the group's gap. Bare content is deliberately supported, so a section holding a table or a canvas needs no group. The rule is about stacks of rows, not about sections. ### Read-only or readout? Both show a value a user can't change right now, and the difference is whether they ever could. A field that's editable in some states uses `readonly` on its normal field, so it keeps its shape when it locks. A value that only the app ever changes, like a resolved path or a frame count, uses `ReadoutField`, which can also be selected and copied. ### Spacing between blocks Spacing between two blocks in a body, like a `FieldGroup` and the buttons after it, is yours. The library adds none, so that bare content stays free of surprises. A margin on your own class is the intended answer, not a workaround: ```tsx …
``` ```css .my-actions { margin-top: 8px; } ``` ### Modal bodies and footers A modal is one dismissable `Section`, so all of the above applies to its body. For a footer with a primary action and a quieter one, set `variant` on the quieter option: ```tsx ``` Use `kind: 'static'` for non-button content such as a readout. To widen a content-heavy modal, pass a class: `stageModal.open(content, { class: 'my-wide-modal' })`. --- ## Custom content and custom CSS Everything inside `.stage-root` gets the tier's font size and the consumer's font family. Beyond that, custom markup gets no library styling, so read the tokens rather than repeating their values. A theme override then reaches your markup as well. ```css .my-tree-row { padding: 2px 6px; color: var(--stage-color-text); border-radius: var(--stage-border-radius); } .my-tree-row:hover { background: var(--stage-color-control-hover-bg); } .my-tree-row.selected { background: color-mix(in srgb, var(--stage-color-accent) 25%, var(--stage-color-panel-bg)); } ``` | You want | Token | |---|---| | Text | `--stage-color-text` | | Disabled text | `--stage-color-text-disabled` | | Hover fill | `--stage-color-control-hover-bg` | | Borders | `--stage-color-border`, `--stage-color-border-strong` | | Accent | `--stage-color-accent` | | Text size, row height | `--stage-font-size`, `--stage-control-height` | | Layering a custom popover | `--stage-z-popover` | - **Skip `var()` fallbacks.** Every token is always defined inside `.stage-root`, so a fallback does nothing except hide a misspelled or invented name. - **`--stage-color-text-muted` doesn't dim by default.** It equals `--stage-color-text` until a theme overrides it. Use `-text-disabled`, or mix your own, when you need a dimmer tone. - **Keyboard access is yours.** A `
` has no focus stop. Use a real ` ))}
); } ``` Key contract points: - **`data-stage-level={props.level}`** on the root gives you the error/warn border recolour the built-ins use. - **Spread the three handlers** (`onFocusIn` / `onFocusOut` / `onPointerEnter`) onto the focusable root so validation tooltips appear on focus/hover. - **Respect `readonly`** — drop out of the tab order (`tabindex={-1}`) and disable interaction, and dim with *colour* not opacity. That control is now usable on its own (a toolbar, a custom layout). To give it a captioned row, wrap it in a field. ## Building a custom field A **field** composes your control with the `Field` layout primitive and `createFieldValidation`. The pattern is identical for every built-in — here's the whole of `SliderField`, adapted: ```tsx import { Field, createFieldValidation, type FieldValidationProps, type FieldInfoProps, type FieldLayout, } from '@dolphinspired/solid-stage'; import { StarRatingControl } from './StarRatingControl'; interface StarRatingFieldProps extends FieldValidationProps, FieldInfoProps { name: string; id?: string; value: () => number; onChange: (next: number) => void; max?: number; layout?: FieldLayout; readonly?: () => boolean; description?: string; } export function StarRatingField(props: StarRatingFieldProps) { const v = createFieldValidation({ value: () => props.value(), name: () => props.name, validate: props.validate, }); return ( {(value) => ( )} ); } ``` What's happening: - **`createFieldValidation`** computes the reactive validation result and gives you `v.borderLevel()` (the `error`/`warn` cue) and `v.controlHandlers` (the focus/hover wiring). Thread both into the control. - **`Field`** renders the caption row, the optional `description` line, and the info bubble (`info`). Its `children` is a **render-prop receiving the value accessor** — pass *that* to your control (not `props.value`), the un-keyed `` convention so the control mounts once. - `extends FieldValidationProps, FieldInfoProps` pulls in `validate`, `info`, and `IconComponent` for free. Your field now drops into a `Section` like any built-in, with `bind`, validation, info, and theming all working. ## Validation & tooltips Every value field takes a `validate` callback. Return `null` for valid, or a `ValidationResult` `{ level, content }` to flag it: ```tsx import { type ValidationResult } from '@dolphinspired/solid-stage'; const validateLevel = (n: number): ValidationResult | null => n < 0 ? { level: 'error', content: 'must be ≥ 0' } : null; ``` - **`level`** is `'error' | 'warn' | 'success' | 'info'`. Only `error` and `warn` recolour the control's border; all four tint the tooltip bubble. - **`content`** is a string or JSX, shown in the tooltip on focus/hover. Text fields also have built-in helper validators that run *before* `validate`: `minLength`, `maxLength`, `pattern` (first failure wins). ```tsx ``` Validation never traps the user: a `validate` that throws is logged and treated as valid. The cue is the border recolour plus the tooltip — there's no inline error line. If you build a custom field, `createFieldValidation` is the same machinery the built-ins use; see [above](#building-a-custom-field). ## Building a custom search or filter control `SearchField` is the standard async combobox. If you need a different interaction — the classic **filter** model where the query stays in the input, the selection is shown separately, and a sibling category ` onQuery(e.currentTarget.value)} /> {source.loading() && Searching…}
    {source.results()?.items.map((item) =>
  • {item.label}
  • )}
); } ``` `createSearchSource(opts)` returns: | Member | Type | Notes | |---|---|---| | `results` | `() => SearchResult \| null` | The latest results (reactive). | | `loading` | `() => boolean` | | | `schedule(query, filter?)` | | Run a (debounced) search; `filter` reaches your `search` fn as `ctx.filter`. | | `reset()` / `cancel()` | | Clear / abort in-flight. | Your `SearchFn` receives `ctx.filter` (the sibling value) and `ctx.signal` (honour it to cancel). For ◀▶ stepping through results, `stepThrough(list, currentId, dir)` gives you the wrap math. This is exactly how the library's own `SearchControl` is built, and why a standalone `FilterControl` isn't shipped — it's fully composable from these pieces. ## Deep-linking to a field Give a field an `id`, then focus it from anywhere with `focusField(id)` — a toast action, a "jump to setting" button, another panel. It returns whether it found a target. ```tsx import { focusField } from '@dolphinspired/solid-stage'; // elsewhere — a button, a toast, a different panel: focusField('project-name')} /> ``` What it handles for you: - Focuses the field's **primary control** (skipping mouse-only chrome like a colour swatch or number steppers). - **Scrolls the field into view** — deep-linking to a field in another scrollable panel scrolls that panel to it. - **Expands collapsed sections and panels first** — a collapsed `Section` or `Panel` keeps its body mounted (just hidden), so `focusField` walks up expanding any collapsed ancestor sections, then focuses. It works even for a section never opened since load. Wire it into a toast for a "go fix this" affordance: ```tsx stageToast.push({ level: 'warn', message: () => Name is required. focusField('project-name')}>Fix, autoDismissMs: null, }); ```