# 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; private constructor(opts) { super(opts, '{Name}', 'trigger', attrs.trigger); this.provider = {Name}Provider.ctx.get(); // reads parent context } } ``` ### 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 {#if child} {@render child({ props: mergedProps })} {:else}
{@render children?.()}
{/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; /** * Multi-line for complex behavior. * Inherits from X when not set. * @default 'close' */ escapeKeydownBehavior?: DismissalBehavior; }> & Without; ``` ### 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) [ ] 3. Define parts: Provider + sub-parts (root part uses 'root', not 'provider' — A2) [ ] 4. Create {name}-provider.svelte.ts with all Provider subclasses [ ] 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 ``` ## 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. ## 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 `` 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).