You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/COMPONENT_GUIDE.md

395 lines
16 KiB

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).

Powered by TurnKey Linux.