# 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 + canonical field shapes ├── langs.ts ← idlangref constants for translations ├── 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` — public props + canonical field shapes for Opts - Langs file: `langs.ts` — idlangref constants (see A3) - Barrel: `exports.ts` + `index.ts` ## State Class Pattern ({name}-provider.svelte.ts) ```ts import { Provider, context, type ProviderOpts, type WithRefOpts } from '../../provider'; import { createAttrs } from '../../attrs'; import { COMPONENT_LANGS } from './langs'; const attrs = createAttrs({ component: '{name}', parts: ['root', '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 get() { return this.ctx.getOr(undefined) as {Name}Provider | undefined; } static require() { return this.ctx.get(); } static create(opts: {Name}Opts) { return new {Name}Provider(opts); } private constructor(opts: {Name}Opts) { super(opts, '{Name}', 'root', attrs.root, {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 create(opts) { return new {Name}TriggerProvider(opts); } readonly provider: {Name}Provider; private constructor(opts) { super(opts, '{Name}', 'trigger', attrs.trigger); this.provider = {Name}Provider.require(); } } ``` ### Static Method Convention All classes that use Svelte context follow the same 3-method pattern: | Method | Behavior | When to use | | -------------- | ------------------------------- | ------------------------------------------------------- | | `create(opts)` | Factory + context set | Root wrapper creates the provider | | `get()` | Returns instance or `undefined` | Optional parent (e.g., Checkbox inside optional Group) | | `require()` | Throws if not found | Required parent (e.g., Trigger must be inside Provider) | This applies consistently to: - **Provider classes**: `XProvider.create()`, `XProvider.get()`, `XProvider.require()` - **Soma**: `Soma.create()`, `Soma.get()`, `Soma.require()` - **App**: `App.create()`, `App.get()`, `App.require()` No standalone functions (`createApp`, `getApp`, `useApp`). No `from()`. The `ctx` field is `readonly` on the class but not exposed as public API — consumers use the static methods. ### 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, Gesture, 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). ### Types: define once, reference in Opts Canonical field shapes are defined once in `types.ts` and referenced by provider Opts via `StateProps<>` / `ActiveProps<>`: ```ts // types.ts export type DrawerStateFields = { open: boolean; activeSnapPoint: SnapPoint | null; }; export type DrawerActiveFields = { disabled: boolean; modal: boolean; direction: Direction; ... }; // provider interface DrawerOpts extends ProviderOpts, StateProps, ActiveProps {} ``` Do NOT redeclare field types in both `types.ts` and the Opts interface. ## Wrapper Pattern (components/{name}.svelte) ```svelte {#if child} {@render child({ props: mergedProps })} {:else}
{@render children?.()}
{/if} ``` ### Rules - Wrappers are thin: props → Active/State → Provider.create() → 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 `() => {}` inline — no `noop` import, soma does not depend on `$lib` - Direction: `soma?.presentation.getDir() ?? '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` - Drag: `data-dragging` (present during active gesture) ## 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 + canonical field shapes [ ] 7. Create langs.ts with idlangref constants — A3 [ ] 8. Create wrapper .svelte files (thin: props → Active → Provider → mergeProps → render — A11) [ ] 9. Verify: event handlers included in props (not just class methods) [ ] 10. Verify: context captured in constructor, not in handlers [ ] 11. Verify: ARIA relationships complete (aria-controls, aria-labelledby, aria-expanded — A4) [ ] 12. Verify: accessible name via idlangref constant, not hardcoded string — A3 [ ] 13. Verify: keyboard navigation respects RTL via getDirectionalKeys() — A12 [ ] 14. Verify: timers/listeners cleaned up in $effect return — A6 [ ] 15. Verify: gesture cleanup on unmount if using Gesture layer — A6, A15 [ ] 16. Verify: .get() for optional parents, .require() for required — A7 [ ] 17. Verify: no visual styles in provider (A8), static methods follow create/get/require pattern [ ] 18. Verify: roving tabindex has exactly one tabindex=0 item — A14 [ ] 19. Create exports.ts (Provider, not Root) [ ] 20. Add to components/index.ts barrel [ ] 21. Create demo page in /test/soma/{name} [ ] 22. Add link to /test/soma/+page.svelte index [ ] 23. svelte-check: 0 errors [ ] 24. Test in browser [ ] 25. 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()` 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. 8. **Redeclaring field types** — defining prop types in both `types.ts` and the provider Opts interface. Define canonical shapes once in `types.ts`, reference with `StateProps<>` / `ActiveProps<>`. 9. **Hardcoding aria strings** — use idlangref constants from `langs.ts`, never inline strings. 10. **Gesture capturing child clicks** — `setPointerCapture` must be deferred until moveBuffer is exceeded. Immediate capture on pointerdown steals click events from buttons inside the draggable area. ## 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 registerContract({ name: 'dialog', version: 1, parts: { root: [ { attr: 'data-state', values: ['open', 'closed'], description: 'Open state' } ], trigger: [ { attr: 'data-state', values: ['open', 'closed'], description: 'Open state' } ], content: [ { attr: 'data-state', values: ['open', 'closed'], description: 'Open state' } ] } }); ``` 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. Soma access, translations, and imports **Soma access:** Declare `readonly soma = Soma.get()` in the root provider only if the component needs translations or presentation services. Sub-parts access soma via `this.provider.soma`. **Translations:** Each component defines its idlangref constants in a `langs.ts` file: ```ts // drawer/langs.ts export const DRAWER_LANGS = { TRIGGER: '#?components.drawer.trigger|Open drawer', CLOSE: '#?common.buttons.close|Close', } as const; ``` Providers import and use the constants: ```ts import { DRAWER_LANGS } from './langs'; 'aria-label': this.provider.soma?.langs.ts(DRAWER_LANGS.CLOSE), ``` **Translation namespace structure:** ``` common.buttons.close ← project-wide, shared by soma + eidos + app common.buttons.open common.labels.* components.drawer.trigger ← component-specific components.dialog.trigger ``` - Common keys live under `common.*` at the lang root — not under soma - Component keys live under `components.{name}.*` - Soma does NOT inject translations — it exports `componentLangs` from `core/langs.ts` which the consumer imports and extends into langs - `langs.ts()` with idlangref for simple strings. `langs.t()` only for interpolated templates (e.g., `Page {{value}}`) Do NOT: - Create `translate()` helper methods in providers - Use `?? 'fallback'` — the fallback belongs inside the langref (`#?path|fallback`) - Hardcode aria strings — always use idlangref constants from the component's `langs.ts` - Put common keys (close, open, cancel) under component namespaces — they belong in `common.*` **Imports within soma:** Use relative paths, not `$soma/` aliases. Relative paths make the library portable without requiring alias configuration in the consumer's build. soma does not import from `$lib` — trivial utilities (like empty callbacks) are inline (`() => {}`). ```ts // Inside soma — relative import { DRAWER_LANGS } from './langs'; import type { DrawerSide } from './types'; import { Presence } from '../../layers/presence.svelte'; // Wrong — alias import { DRAWER_LANGS } from '$soma/components/drawer/langs'; // Wrong — external dependency import { noop } from '$lib/util/funcs'; ``` Consumer code (layouts, test pages, app) uses `$soma/` alias — that's their build config, not soma's concern. ### A4. ARIA relationships are mandatory Every component with trigger→content pattern MUST emit: - **Trigger**: `aria-controls={contentId}`, `aria-expanded` - **Content**: `aria-labelledby={triggerId}` (for dialogs, popovers, selects, drawers) - **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`) Per-item feature flags (Table columns, tree nodes) 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 Table: `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 `.get()` for optional parents, `.require()` for required | Method | Returns | Use when | | ---------------- | ---------------------------- | --------------------------------- | | `X.create(opts)` | instance | Creating + registering in context | | `X.get()` | instance or `undefined` | Parent is optional | | `X.require()` | instance (throws if missing) | Parent is required | ```ts // Tooltip can work without Group — use get() this.group = TooltipGroupProvider.get(); // Accordion Item MUST be inside Accordion — use require() this.provider = AccordionProvider.require(); ``` This convention applies to App, Soma, and all Provider classes. No standalone functions (`createApp`, `getSoma`). No `from()`. ### A8. No visual styles in headless providers Headless providers MUST NOT emit visual CSS properties (`border-radius`, `background`, `color`, `overflow: auto`). Only functional CSS is allowed: - `touch-action: none` — required for gesture drag - `pointer-events: auto` — required for overlays and fixed-position content - `transition: none` — required during active drag to disable CSS transitions - `transform: translate3d(...)` — required during active drag for visual feedback - CSS custom properties (`--drawer-progress`, `--drawer-offset-*`) — data for the visual layer The visual layer (air/eidos) owns appearance. The provider owns behavior. ### A9. Dismissal behavior for drawers vs dialogs A drawer is NOT a popover. `interactOutsideBehavior` should be `'ignore'` for drawers: - **Modal drawer**: overlay `onclick` handles close. Dismissal layer only handles Escape. - **Non-modal drawer**: background is interactive by definition. Dismissal layer disabled entirely. Escape handled via `onkeydown` on the content element. - **Non-dismissible**: Dismissal layer fully disabled. Close button uses `forceClose()` (bypasses the `dismissible` guard). ### 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, the query must re-run. For nested components (e.g., nested Accordion), filter results to only include elements whose closest root is the current root: ```ts getTriggers(): HTMLButtonElement[] { const root = this.opts.ref?.current; if (!root) return []; const all = Array.from(root.querySelectorAll(selector)); return all.filter((el) => el.closest(`[${attrs.root}]`) === root); } ``` ### 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)`. ```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). ### A15. Gesture layer integration Components with drag behavior (Drawer, Slider, Splitter, ScrollArea, Toast) use the Gesture layer from `layers/gesture/`. Three specializations: - `Gesture.base()` — pointer tracking + axis lock + velocity (Slider, ScrollArea) - `Gesture.drag()` — base + progress + snap points + dismiss (Drawer, Toast) - `Gesture.resize()` — base + delta + min/max constraints (Splitter) Rules: - `setPointerCapture` is deferred until moveBuffer is exceeded — immediate capture steals click events from child elements (buttons, links inside the draggable area) - Gesture `.props` (only `onpointerdown`) must be spread into the component's `props` - Gesture cleanup on unmount: `$effect(() => { return () => { this.gesture.cancel(); }; })` - CSS vars (`--drawer-progress`, `--drawer-offset-x/y`) are set by the provider for the visual layer to consume - During active drag: `transition: none` + inline `transform` for immediate visual feedback - Scroll-drag guard: disable gesture via `enabled` when user scrolled recently, not by suppressing the callback - The gesture layer measures — the component decides what the measurement means (dismiss, value change, resize) ### A16. Non-modal overlay components Components with `modal` prop (Dialog, Drawer) must adjust behavior when `modal=false`: - **FocusScope**: `trap=false`, `enabled=false` — background must stay interactive - **ScrollLock**: disabled — background scrolling must work - **Dismissal**: disabled — no interactOutside, no focusOutside - **Escape**: handled via `onkeydown` on the content element, not via Dismissal layer - **Auto-focus**: prevented (`e.preventDefault()` in `onOpenAutoFocus`) - **Content**: needs `tabindex=-1` to receive keyboard events without focus trap - **Overlay**: not rendered (consumer should not include `` for non-modal)