soma: 12 components, architecture docs, naming fixes, form tier complete
Components (12):
- Accordion (keyboard nav, single/multiple, collapsible, loop)
- Checkbox (indeterminate, group, readonly)
- Collapsible
- Combobox (input + floating listbox, consumer-side filtering)
- Dialog (variant dialog/alertdialog, modal, nested, animations, forceMount)
- DropdownMenu (15 parts, submenus, typeahead, checkbox/radio items)
- Popover (floating, openOnHover, modal, custom anchor)
- RadioGroup (roving tabindex, orientation, readonly)
- Select (single/multiple, typeahead, scroll-into-view, hidden select)
- Slider (drag, keyboard, single/range, ticks, vertical)
- Switch (hidden input, data-checked)
- Tooltip (skip-delay group, 3-state, hover/focus)
Architecture:
- State class files renamed: {name}.svelte.ts → {name}-provider.svelte.ts
(avoids Vite module resolution ambiguity with .svelte wrappers)
- Export name standardized: always Provider, never Root
- COMPONENT_GUIDE.md: step-by-step implementation guide with checklist
- SOMA_ARCHITECTURE.md: updated structure, anti-patterns, improvements table
- README.md: updated naming, file structure, inventory
- context.ts: error message uses Provider not Root
- Demo index page at /test/soma/
Bug fixes:
- DropdownMenu trigger missing onclick/onkeydown in props
- DropdownMenu interactOutsideBehavior always 'close' (not modal-dependent)
- Checkbox getContext in event handler → captured in constructor
- Select context not found → was Vite cache issue (not naming)
- ScrollLock unlock path
- Floating wrapper z-index: auto override
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 months ago
# Component Implementation Guide
Step-by-step guide for building soma headless components.
## Before You Start
### 1. Compare with reference libraries
**This step is mandatory. Do not skip it.**
Search ark-ui, bits-ui, and radix-ui for the same component. Create a feature table:
| Feature | Radix | Ark | Bits | Soma | Decision |
|---------|-------|-----|------|------|----------|
| (each prop) | ... | ... | ... | ✓/✗ | justification |
Document what soma includes and what it skips (with reason).
### 2. Verify membership criteria
The component must have BOTH:
- Composition of parts (2+ sub-components communicating via context)
- Complex behavior (keyboard nav, focus management, floating, ARIA relationships, state machines, drag, or form integration)
If it only has one or neither → it's air-native, not soma.
## File Structure
```
components/{name}/
├── {name}-provider.svelte.ts ← ALL state classes (Provider subclasses)
├── types.ts ← ALL prop types with JSDoc
├── exports.ts ← barrel (Provider, Trigger, Content, etc.)
├── index.ts ← re-exports from exports.ts
└── components/
├── {name}.svelte ← root wrapper
├── {name}-trigger.svelte ← trigger wrapper
├── {name}-content.svelte ← content wrapper
└── ...
```
### File naming rules
- State class file: `{name}-provider.svelte.ts` (NOT `{name}.svelte.ts` )
- Why: avoids Vite module resolution ambiguity with `{name}.svelte` wrapper
- Contains ALL Provider subclasses for the component
- Wrapper files: `{name}.svelte` , `{name}-trigger.svelte` , etc.
- Types file: `types.ts`
- Barrel: `exports.ts` + `index.ts`
## State Class Pattern ({name}-provider.svelte.ts)
```ts
import { Provider, context, type ProviderOpts, type WithRefOpts } from '$soma/provider';
import { createAttrs } from '$soma/attrs';
const attrs = createAttrs({
component: '{name}',
parts: ['provider', 'trigger', 'content', ...] as const,
});
// Root provider (with or without DOM)
interface {Name}Opts extends ProviderOpts, ... {} // ProviderOpts if no DOM
interface {Name}Opts extends WithRefOpts, ... {} // WithRefOpts if renders element
export class {Name}Provider extends Provider< {Name}Opts> {
static readonly ctx = context< {Name}Provider>('{Name}');
static from(opts: {Name}Opts) {
return new {Name}Provider(opts);
}
private constructor(opts: {Name}Opts) {
super(opts, '{Name}', 'provider', attrs.provider, {Name}Provider.ctx);
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
// component-specific props
} as const),
);
}
// Sub-parts read parent context
export class {Name}TriggerProvider extends Provider< {Name}TriggerOpts> {
static from(opts) { return new {Name}TriggerProvider(opts); }
readonly provider: {Name}Provider;
soma: 12 components, architecture docs, naming fixes, form tier complete
Components (12):
- Accordion (keyboard nav, single/multiple, collapsible, loop)
- Checkbox (indeterminate, group, readonly)
- Collapsible
- Combobox (input + floating listbox, consumer-side filtering)
- Dialog (variant dialog/alertdialog, modal, nested, animations, forceMount)
- DropdownMenu (15 parts, submenus, typeahead, checkbox/radio items)
- Popover (floating, openOnHover, modal, custom anchor)
- RadioGroup (roving tabindex, orientation, readonly)
- Select (single/multiple, typeahead, scroll-into-view, hidden select)
- Slider (drag, keyboard, single/range, ticks, vertical)
- Switch (hidden input, data-checked)
- Tooltip (skip-delay group, 3-state, hover/focus)
Architecture:
- State class files renamed: {name}.svelte.ts → {name}-provider.svelte.ts
(avoids Vite module resolution ambiguity with .svelte wrappers)
- Export name standardized: always Provider, never Root
- COMPONENT_GUIDE.md: step-by-step implementation guide with checklist
- SOMA_ARCHITECTURE.md: updated structure, anti-patterns, improvements table
- README.md: updated naming, file structure, inventory
- context.ts: error message uses Provider not Root
- Demo index page at /test/soma/
Bug fixes:
- DropdownMenu trigger missing onclick/onkeydown in props
- DropdownMenu interactOutsideBehavior always 'close' (not modal-dependent)
- Checkbox getContext in event handler → captured in constructor
- Select context not found → was Vite cache issue (not naming)
- ScrollLock unlock path
- Floating wrapper z-index: auto override
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 months ago
private constructor(opts) {
super(opts, '{Name}', 'trigger', attrs.trigger);
this.provider = {Name}Provider.ctx.get(); // reads parent context
soma: 12 components, architecture docs, naming fixes, form tier complete
Components (12):
- Accordion (keyboard nav, single/multiple, collapsible, loop)
- Checkbox (indeterminate, group, readonly)
- Collapsible
- Combobox (input + floating listbox, consumer-side filtering)
- Dialog (variant dialog/alertdialog, modal, nested, animations, forceMount)
- DropdownMenu (15 parts, submenus, typeahead, checkbox/radio items)
- Popover (floating, openOnHover, modal, custom anchor)
- RadioGroup (roving tabindex, orientation, readonly)
- Select (single/multiple, typeahead, scroll-into-view, hidden select)
- Slider (drag, keyboard, single/range, ticks, vertical)
- Switch (hidden input, data-checked)
- Tooltip (skip-delay group, 3-state, hover/focus)
Architecture:
- State class files renamed: {name}.svelte.ts → {name}-provider.svelte.ts
(avoids Vite module resolution ambiguity with .svelte wrappers)
- Export name standardized: always Provider, never Root
- COMPONENT_GUIDE.md: step-by-step implementation guide with checklist
- SOMA_ARCHITECTURE.md: updated structure, anti-patterns, improvements table
- README.md: updated naming, file structure, inventory
- context.ts: error message uses Provider not Root
- Demo index page at /test/soma/
Bug fixes:
- DropdownMenu trigger missing onclick/onkeydown in props
- DropdownMenu interactOutsideBehavior always 'close' (not modal-dependent)
- Checkbox getContext in event handler → captured in constructor
- Select context not found → was Vite cache issue (not naming)
- ScrollLock unlock path
- Floating wrapper z-index: auto override
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 months ago
}
}
```
### Rules
- `getContext()` only works during component initialization (constructor called from script block). NEVER in event handlers, timeouts, or callbacks.
- If a handler needs a context reference, capture it in the constructor.
- Event handlers (onclick, onkeydown) must be included in `props` . Defining them as class methods without spreading them into props means they won't reach the DOM.
- Layers (Presence, FocusScope, Dismissal, etc.) are instantiated in the constructor and their `.props` are spread into the component's `props` .
- For DropdownMenu: `interactOutsideBehavior` defaults to `'close'` (not inherited from `modal` like Dialog).
## Wrapper Pattern (components/{name}.svelte)
```svelte
< script lang = "ts" >
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { {Name}Provider } from '../{name}-provider.svelte';
import type { {Name}Props } from '../types';
import { noop } from '$lib/util/funcs';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, '{name}'),
// ... props with defaults ...
children,
child,
...restProps
}: {Name}Props = $props();
const state = {Name}Provider.from({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
// ... wrap each prop ...
});
const mergedProps = $derived(mergeProps(restProps, state.props));
< / script >
{#if child}
{@render child({ props: mergedProps })}
{:else}
< div { . . . mergedProps } >
{@render children?.()}
< / div >
{/if}
```
### Rules
- Wrappers are thin: props → Active/State → Provider.from() → mergeProps → render
- No logic in wrappers. If you're writing more than prop conversion, the logic belongs in the Provider.
- IDs: `createId(uid, '{component}-{part}')` — descriptive and inspectable
- Callbacks default to `noop` from `$lib/util/funcs`
- Direction: `dir = soma?.dir.current ?? 'ltr'` where Soma is available
## Exports Pattern (exports.ts)
```ts
export { default as Provider } from './components/{name}.svelte'; // ALWAYS "Provider", never "Root"
export { default as Trigger } from './components/{name}-trigger.svelte';
export { default as Content } from './components/{name}-content.svelte';
export type {
{Name}Props as ProviderProps, // ALWAYS "ProviderProps", never "RootProps"
{Name}TriggerProps as TriggerProps,
{Name}ContentProps as ContentProps,
} from './types';
```
## Types Pattern (types.ts)
ALL props documented with JSDoc. No exceptions.
```ts
export type {Name}Props = WithChild< {
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Whether open. Bindable. @default false */
open?: boolean;
/** Callback on open change. */
onOpenChange?: OnChangeFn< boolean > ;
/**
* Multi-line for complex behavior.
* Inherits from X when not set.
* @default 'close'
*/
escapeKeydownBehavior?: DismissalBehavior;
}> & Without< PrimitiveDivAttributes , { } > ;
```
### Rules
- One-line JSDoc for simple props
- Multi-line when there's conditional behavior or prop relationships
- `@default` on every prop that has a default in the wrapper
- Callbacks: document what `e.preventDefault()` does if applicable
- `value: string` for required props (no `?` )
## ID Generation
```ts
createId(uid, 'dialog') // → "soma-dialog-c12"
createId(uid, 'dialog-trigger') // → "soma-dialog-trigger-c13"
createId(uid, 'dialog-content') // → "soma-dialog-content-c14"
```
Pattern: `soma-{component}-{part}-{uid}` . Always descriptive.
## Data Attributes
- Provider: `data-{component}` (no `-provider` suffix)
- Parts: `data-{component}-{part}`
- State: `data-state="open|closed"` , `data-state="checked|unchecked|indeterminate"`
- Flags: `data-disabled` , `data-readonly` , `data-checked`
- Floating: `data-side` , `data-align`
- Animation: `data-starting-style` , `data-ending-style`
- Nesting: `data-nested` , `data-nested-open`
## Checklist
```
[ ] 1. Compare with ark-ui, bits-ui, radix-ui — feature table with decisions
[ ] 2. Verify membership criteria (composition + complex behavior)
soma: audit fixes — 84 issues across 25 components, Table complete
Table component:
- Complete state manager: sorting, filtering, pagination, selection,
column pinning (sticky), column sizing, expandable rows (sub-rows +
detail panels), column visibility
- Provider renders <table> with translated aria-label
- Sorting opt-in per column, consumer places sort buttons
- filteredRowCount/totalRowCount for correct pagination count
- Sub-row IDs hierarchical (parentId.subIndex), no collisions
- Production-grade test page (Swiss Industrial design)
- Full README with all features documented
Critical fixes (10):
- Toast: memory leaks (timer + hotkey listener cleanup via $effect)
- Toast: restartTimer() method for promise-based toasts
- ScrollArea: type='always' now works (check order fix)
- ScrollArea: thumb click no longer triggers track jump
- Tooltip: no crash without <Tooltip.Group> (ctx.getOr)
- Combobox: click-outside-to-close fixed (isValidEvent)
- Popover: stray 'n' character removed from popover.svelte
- TreeView: ARIA ownership fixed (role=treeitem on Branch, not Control)
- Splitter: documented required array spread for reactivity
High fixes (14):
- Checkbox: group disabled/name propagation + checked sync via $effect
- Select: aria-labelledby on content, aria-disabled on items
- Stepper: aria-controls + aria-labelledby cross-linking
- TagsInput: highlightPrev returns to input, role on Control/Item
- Editable: Preview role=button + aria-label
- Pagination: translated aria-label on items
- RadioGroup: removed unused Soma import
Medium fixes (60):
- registerContract() added to all 25 components
- Dead config=getSoma() removed from 7 providers
- Dead imports removed (Tooltip, Popover, Slider, Combobox)
- Dialog: aria-modal conditional on modal prop
- Slider: pointer capture release + onpointercancel
- Popover: hover timeout cleanup, aria-modal support
- Tabs: inactive content hidden + aria-hidden + tabindex=-1
- ToggleGroup: aria-orientation + roving tabindex fallback
- Toolbar: data-disabled on Button
- Stepper: Content tabindex
- TreeView: RTL keyboard support (expand/collapse key swap)
- NumberField: Enter key commit, clampOnBlur, allowMouseWheel, scrubber a11y
- Toast: data-state on root, aria-live on viewport
- ScrollArea: scrollbar aria-label + tabindex
- Collapsible: part root (not provider), aria-controls, role=region
- Tooltip: removed dead provider part from attrs
Architecture:
- COMPONENT_GUIDE.md: 14 audit-derived rules (A1-A14)
- Checklist expanded from 15 to 23 steps
- 8 new HTML primitive types (Table, THead, TBody, TFoot, TR, TH, TD, Caption)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 months ago
[ ] 3. Define parts: Provider + sub-parts (root part uses 'root', not 'provider' — A2)
soma: 12 components, architecture docs, naming fixes, form tier complete
Components (12):
- Accordion (keyboard nav, single/multiple, collapsible, loop)
- Checkbox (indeterminate, group, readonly)
- Collapsible
- Combobox (input + floating listbox, consumer-side filtering)
- Dialog (variant dialog/alertdialog, modal, nested, animations, forceMount)
- DropdownMenu (15 parts, submenus, typeahead, checkbox/radio items)
- Popover (floating, openOnHover, modal, custom anchor)
- RadioGroup (roving tabindex, orientation, readonly)
- Select (single/multiple, typeahead, scroll-into-view, hidden select)
- Slider (drag, keyboard, single/range, ticks, vertical)
- Switch (hidden input, data-checked)
- Tooltip (skip-delay group, 3-state, hover/focus)
Architecture:
- State class files renamed: {name}.svelte.ts → {name}-provider.svelte.ts
(avoids Vite module resolution ambiguity with .svelte wrappers)
- Export name standardized: always Provider, never Root
- COMPONENT_GUIDE.md: step-by-step implementation guide with checklist
- SOMA_ARCHITECTURE.md: updated structure, anti-patterns, improvements table
- README.md: updated naming, file structure, inventory
- context.ts: error message uses Provider not Root
- Demo index page at /test/soma/
Bug fixes:
- DropdownMenu trigger missing onclick/onkeydown in props
- DropdownMenu interactOutsideBehavior always 'close' (not modal-dependent)
- Checkbox getContext in event handler → captured in constructor
- Select context not found → was Vite cache issue (not naming)
- ScrollLock unlock path
- Floating wrapper z-index: auto override
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 months ago
[ ] 4. Create {name}-provider.svelte.ts with all Provider subclasses
soma: audit fixes — 84 issues across 25 components, Table complete
Table component:
- Complete state manager: sorting, filtering, pagination, selection,
column pinning (sticky), column sizing, expandable rows (sub-rows +
detail panels), column visibility
- Provider renders <table> with translated aria-label
- Sorting opt-in per column, consumer places sort buttons
- filteredRowCount/totalRowCount for correct pagination count
- Sub-row IDs hierarchical (parentId.subIndex), no collisions
- Production-grade test page (Swiss Industrial design)
- Full README with all features documented
Critical fixes (10):
- Toast: memory leaks (timer + hotkey listener cleanup via $effect)
- Toast: restartTimer() method for promise-based toasts
- ScrollArea: type='always' now works (check order fix)
- ScrollArea: thumb click no longer triggers track jump
- Tooltip: no crash without <Tooltip.Group> (ctx.getOr)
- Combobox: click-outside-to-close fixed (isValidEvent)
- Popover: stray 'n' character removed from popover.svelte
- TreeView: ARIA ownership fixed (role=treeitem on Branch, not Control)
- Splitter: documented required array spread for reactivity
High fixes (14):
- Checkbox: group disabled/name propagation + checked sync via $effect
- Select: aria-labelledby on content, aria-disabled on items
- Stepper: aria-controls + aria-labelledby cross-linking
- TagsInput: highlightPrev returns to input, role on Control/Item
- Editable: Preview role=button + aria-label
- Pagination: translated aria-label on items
- RadioGroup: removed unused Soma import
Medium fixes (60):
- registerContract() added to all 25 components
- Dead config=getSoma() removed from 7 providers
- Dead imports removed (Tooltip, Popover, Slider, Combobox)
- Dialog: aria-modal conditional on modal prop
- Slider: pointer capture release + onpointercancel
- Popover: hover timeout cleanup, aria-modal support
- Tabs: inactive content hidden + aria-hidden + tabindex=-1
- ToggleGroup: aria-orientation + roving tabindex fallback
- Toolbar: data-disabled on Button
- Stepper: Content tabindex
- TreeView: RTL keyboard support (expand/collapse key swap)
- NumberField: Enter key commit, clampOnBlur, allowMouseWheel, scrubber a11y
- Toast: data-state on root, aria-live on viewport
- ScrollArea: scrollbar aria-label + tabindex
- Collapsible: part root (not provider), aria-controls, role=region
- Tooltip: removed dead provider part from attrs
Architecture:
- COMPONENT_GUIDE.md: 14 audit-derived rules (A1-A14)
- Checklist expanded from 15 to 23 steps
- 8 new HTML primitive types (Table, THead, TBody, TFoot, TR, TH, TD, Caption)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 months ago
[ ] 5. Register data-* contract via registerContract() — A1
[ ] 6. Create types.ts with JSDoc on ALL props
[ ] 7. Create wrapper .svelte files (thin: props → Active → Provider → mergeProps → render — A11)
[ ] 8. Verify: event handlers included in props (not just class methods)
[ ] 9. Verify: context captured in constructor, not in handlers
[ ] 10. Verify: ARIA relationships complete (aria-controls, aria-labelledby, aria-expanded — A4)
[ ] 11. Verify: accessible name on root (aria-label or aria-labelledby — A4)
[ ] 12. Verify: keyboard navigation respects RTL via getDirectionalKeys() — A12
[ ] 13. Verify: timers/listeners cleaned up in $effect return — A6
[ ] 14. Verify: ctx.getOr() for optional parents, ctx.get() for required — A7
[ ] 15. Verify: no visual styles in provider (A8), no dead getSoma() (A3)
[ ] 16. Verify: roving tabindex has exactly one tabindex=0 item — A14
[ ] 17. Create exports.ts (Provider, not Root)
[ ] 18. Add to components/index.ts barrel
[ ] 19. Create demo page in /test/soma/{name}
[ ] 20. Add link to /test/soma/+page.svelte index
[ ] 21. svelte-check: 0 errors
[ ] 22. Test in browser
[ ] 23. Document gaps vs reference libraries
soma: 12 components, architecture docs, naming fixes, form tier complete
Components (12):
- Accordion (keyboard nav, single/multiple, collapsible, loop)
- Checkbox (indeterminate, group, readonly)
- Collapsible
- Combobox (input + floating listbox, consumer-side filtering)
- Dialog (variant dialog/alertdialog, modal, nested, animations, forceMount)
- DropdownMenu (15 parts, submenus, typeahead, checkbox/radio items)
- Popover (floating, openOnHover, modal, custom anchor)
- RadioGroup (roving tabindex, orientation, readonly)
- Select (single/multiple, typeahead, scroll-into-view, hidden select)
- Slider (drag, keyboard, single/range, ticks, vertical)
- Switch (hidden input, data-checked)
- Tooltip (skip-delay group, 3-state, hover/focus)
Architecture:
- State class files renamed: {name}.svelte.ts → {name}-provider.svelte.ts
(avoids Vite module resolution ambiguity with .svelte wrappers)
- Export name standardized: always Provider, never Root
- COMPONENT_GUIDE.md: step-by-step implementation guide with checklist
- SOMA_ARCHITECTURE.md: updated structure, anti-patterns, improvements table
- README.md: updated naming, file structure, inventory
- context.ts: error message uses Provider not Root
- Demo index page at /test/soma/
Bug fixes:
- DropdownMenu trigger missing onclick/onkeydown in props
- DropdownMenu interactOutsideBehavior always 'close' (not modal-dependent)
- Checkbox getContext in event handler → captured in constructor
- Select context not found → was Vite cache issue (not naming)
- ScrollLock unlock path
- Floating wrapper z-index: auto override
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 months ago
```
## Common Mistakes
1. **Event handlers not in props** — defining `onclick` as a class method but forgetting to include it in the `props` derived object. The handler exists but never reaches the DOM.
2. **getContext in event handler** — calling `ctx.get()` or `ctx.getOr()` inside onclick/onkeydown. getContext only works during component initialization. Capture the reference in the constructor.
3. **Naming Root instead of Provider** — the export name is always `Provider` , never `Root` .
4. **State class file named same as wrapper** — `select.svelte.ts` + `components/select.svelte` can cause Vite module resolution issues. Always use `{name}-provider.svelte.ts` .
5. **Missing readonly prop on form components** — Switch, Checkbox, RadioGroup should have `readonly` alongside `disabled` . readonly prevents interaction but keeps the element focusable.
6. **Comments in Spanish** — all code comments must be in English.
7. **Skipping reference library comparison** — mandatory before implementation. No exceptions.
soma: audit fixes — 84 issues across 25 components, Table complete
Table component:
- Complete state manager: sorting, filtering, pagination, selection,
column pinning (sticky), column sizing, expandable rows (sub-rows +
detail panels), column visibility
- Provider renders <table> with translated aria-label
- Sorting opt-in per column, consumer places sort buttons
- filteredRowCount/totalRowCount for correct pagination count
- Sub-row IDs hierarchical (parentId.subIndex), no collisions
- Production-grade test page (Swiss Industrial design)
- Full README with all features documented
Critical fixes (10):
- Toast: memory leaks (timer + hotkey listener cleanup via $effect)
- Toast: restartTimer() method for promise-based toasts
- ScrollArea: type='always' now works (check order fix)
- ScrollArea: thumb click no longer triggers track jump
- Tooltip: no crash without <Tooltip.Group> (ctx.getOr)
- Combobox: click-outside-to-close fixed (isValidEvent)
- Popover: stray 'n' character removed from popover.svelte
- TreeView: ARIA ownership fixed (role=treeitem on Branch, not Control)
- Splitter: documented required array spread for reactivity
High fixes (14):
- Checkbox: group disabled/name propagation + checked sync via $effect
- Select: aria-labelledby on content, aria-disabled on items
- Stepper: aria-controls + aria-labelledby cross-linking
- TagsInput: highlightPrev returns to input, role on Control/Item
- Editable: Preview role=button + aria-label
- Pagination: translated aria-label on items
- RadioGroup: removed unused Soma import
Medium fixes (60):
- registerContract() added to all 25 components
- Dead config=getSoma() removed from 7 providers
- Dead imports removed (Tooltip, Popover, Slider, Combobox)
- Dialog: aria-modal conditional on modal prop
- Slider: pointer capture release + onpointercancel
- Popover: hover timeout cleanup, aria-modal support
- Tabs: inactive content hidden + aria-hidden + tabindex=-1
- ToggleGroup: aria-orientation + roving tabindex fallback
- Toolbar: data-disabled on Button
- Stepper: Content tabindex
- TreeView: RTL keyboard support (expand/collapse key swap)
- NumberField: Enter key commit, clampOnBlur, allowMouseWheel, scrubber a11y
- Toast: data-state on root, aria-live on viewport
- ScrollArea: scrollbar aria-label + tabindex
- Collapsible: part root (not provider), aria-controls, role=region
- Tooltip: removed dead provider part from attrs
Architecture:
- COMPONENT_GUIDE.md: 14 audit-derived rules (A1-A14)
- Checklist expanded from 15 to 23 steps
- 8 new HTML primitive types (Table, THead, TBody, TFoot, TR, TH, TD, Caption)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 months ago
## Audit-Derived Rules (mandatory for all components)
These rules were extracted from a full audit of all 25 soma components. Every issue below was found in multiple components. Follow these to avoid repeating them.
### A1. Register data-* contracts
Every component MUST call `registerContract()` in its provider file. Without it, `assertContract()` in `assertProps()` is a no-op — data-* values are never validated.
```ts
import { registerContract } from '$soma/attrs';
registerContract({
component: 'dialog',
parts: {
root: { 'data-state': ['open', 'closed'] },
trigger: { 'data-state': ['open', 'closed'] },
content: { 'data-state': ['open', 'closed'] },
},
});
```
Add to checklist step 4, after `createAttrs` .
### A2. Root part must use 'root', not 'provider'
`createAttrs` only strips the suffix for `'root'` → `data-{component}` . Using `'provider'` produces `data-{component}-provider` which violates the naming convention. If the root provider has no DOM, it still uses `'root'` in the parts array.
```ts
// Correct
const attrs = createAttrs({
component: 'collapsible',
parts: ['root', 'trigger', 'content'] as const,
});
// attrs.root → 'data-collapsible'
// Wrong
parts: ['provider', 'trigger', 'content']
// attrs.provider → 'data-collapsible-provider' ← violates convention
```
### A3. Only declare `config = getSoma()` if used
Do NOT add `readonly config = getSoma()` to a provider unless it reads `config.translator` , `config.dir` , or another service. Dead `config` fields add noise and create false dependencies. If only the wrapper needs soma context, use `getSoma()` in the wrapper.
### A4. ARIA relationships are mandatory
soma resolves ARIA by default (Architecture §3.6). Every component with trigger→content pattern MUST emit:
- **Trigger**: `aria-controls={contentId}` , `aria-expanded`
- **Content**: `aria-labelledby={triggerId}` (for dialogs, popovers, selects)
- **Form controls**: `aria-labelledby={labelId}` when a Label part exists
- **Groups**: `aria-label` or `aria-labelledby` on `role="group"` , `role="tablist"` , `role="toolbar"` , `role="radiogroup"` , `role="tree"`
Missing ARIA relationships = the component is broken for screen readers.
### A5. Feature flags are opt-in (`=== true`)
All per-column/per-item feature flags default to `false` . A feature only activates when the consumer explicitly sets it to `true` .
```ts
// Correct — opt-in
return col?.def.enableSorting === true;
// Wrong — opt-out (active by default)
return col?.def.enableSorting !== false;
```
Applies to: `enableSorting` , `enableFiltering` , `enableResizing` , `enablePinning` , `enableHiding` (exception: `enableHiding` defaults to `true` — documented in ColumnDef JSDoc).
### A6. Clean up timers, listeners, and observers
Every `setTimeout` , `setInterval` , `addEventListener` , `ResizeObserver` , or `MutationObserver` created in a provider MUST have cleanup in `$effect` return or explicit dispose. Uncleaned resources cause memory leaks.
```ts
// Correct
$effect(() => {
const timer = setTimeout(fn, delay);
return () => clearTimeout(timer);
});
// Wrong — leak
constructor() {
setTimeout(fn, delay); // never cleared
window.addEventListener('keydown', fn); // never removed
}
```
### A7. Use `ctx.getOr()` for optional parent context
If a component can render without a specific ancestor, use `ctx.getOr()` (returns `undefined` if missing). Use `ctx.get()` only when the ancestor is required and absence is a programming error.
```ts
// Tooltip can work without Group
this.group = TooltipGroupProvider.ctx.getOr();
// Accordion Item MUST be inside Accordion
this.provider = AccordionProvider.ctx.get();
```
Using `ctx.get()` for optional ancestors causes runtime crashes.
### A8. No visual styles in headless providers
Headless providers MUST NOT emit visual CSS properties (`border-radius`, `background` , `color` , `overflow: auto` , `pointer-events: auto` ). Only functional CSS is allowed: `position: sticky` , `width` /`min-width` for sizing, `display: none` for hidden. The visual layer owns appearance.
### A9. Dismissal `isValidEvent` pattern
When using the Dismissal layer, `isValidEvent` returning `false` means "use default validation". Returning `true` means "always valid". If dismissal click-outside must work, do NOT stub it as `() => false` without understanding the Dismissal layer's logic. Test click-outside behavior explicitly.
### A10. DOM queries in providers must handle dynamism
`getItems()` patterns using `querySelectorAll` are fragile — they capture a snapshot, not a live reference. If items are added/removed dynamically (conditional rendering, pagination), the query must re-run. Prefer reactive derivations over cached DOM queries. If a DOM query is necessary, re-query on each access, don't cache.
### A11. Wrapper must be thin
Complex logic (effects, DOM manipulation, state machines, event coordination) belongs in the Provider, not the wrapper. The wrapper's job is: destructure props → wrap in Active/State → create Provider → mergeProps → render. If a wrapper has `$effect` blocks, `onMount` , or branching logic, the code belongs in the Provider.
### A12. Keyboard navigation must respect RTL
Components with arrow-key navigation MUST check `dir` and swap left/right keys. Use `getDirectionalKeys(dir, orientation)` from `$soma/keyboard` .
```ts
const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);
```
Do NOT hardcode `KEYS.ARROW_LEFT` / `KEYS.ARROW_RIGHT` for directional navigation.
### A13. Form components need hidden inputs
Components that participate in forms (Checkbox, RadioGroup, Switch, Select, TagsInput, NumberField) should render a hidden `<input>` with the current value and `name` prop. The `name` from a parent Group must propagate to children.
### A14. Roving tabindex: exactly one item gets tabindex=0
In roving tabindex patterns (RadioGroup, Toolbar, Tabs, ToggleGroup), exactly one item must have `tabindex=0` at all times — either the focused/selected item, or the first item when nothing is selected. Never all `-1` (group unreachable) and never multiple `0` (breaks single-tab-stop pattern).