|
|
6 months ago | |
|---|---|---|
| .. | ||
| attrs | 6 months ago | |
| components | 6 months ago | |
| core | 6 months ago | |
| css | 6 months ago | |
| dom | 6 months ago | |
| events | 6 months ago | |
| external/dates | 6 months ago | |
| id | 6 months ago | |
| keyboard | 6 months ago | |
| layers | 6 months ago | |
| props | 6 months ago | |
| provider | 6 months ago | |
| reactive | 6 months ago | |
| types | 6 months ago | |
| AUDIT.md | 6 months ago | |
| COMPONENT_GUIDE.md | 6 months ago | |
| README.md | 6 months ago | |
| SOMA_ARCHITECTURE.md | 6 months ago | |
| index.ts | 6 months ago | |
README.md
soma
Libreria headless de componentes compuestos para Svelte 5.
soma es la evolucion de terra — un rediseño desde cero que elimina el boilerplate, organiza las utilidades por responsabilidad, y define reglas claras de pertenencia.
1. Proposito
soma resuelve behavior, accesibilidad, composicion y estado para componentes compuestos. No resuelve presentacion visual — eso es responsabilidad de air.
soma existe para:
- keyboard navigation entre partes de un componente
- focus management (trap, scope, roving)
- ARIA relationships entre partes (trigger↔content, tab↔panel)
- floating/positioning de overlays
- portal rendering
- presence management (enter/exit animations)
- dismiss on outside click / Escape
- state machines para componentes con multiples estados
- form integration (hidden inputs, validation context)
soma NO existe para:
- colores, tipografia, espaciado, animaciones visuales
- tokens de tema
- responsive design
- iconografia
- componentes de una sola parte sin behavior complejo
2. Criterio de pertenencia
Un componente pertenece a soma si cumple ambos criterios:
Composicion de partes
El componente tiene 2 o mas subcomponentes que se comunican via context. Ejemplo: Accordion tiene Root, Item, Trigger, Content — cada parte lee estado del padre.
Behavior complejo
El componente implementa al menos uno de:
- Keyboard navigation no trivial (roving focus, arrow keys, typeahead)
- Focus management (trap, scope, restore)
- Floating positioning (popover, tooltip, dropdown)
- ARIA relationships que requieren IDs cruzados (aria-controls, aria-labelledby)
- State machine con transiciones (open/closed, editing/preview)
- Drag behavior (slider, splitter, resize)
- Form integration via context (validation state, hidden inputs)
Si un componente cumple solo uno de los criterios o ninguno, es air-native — vive directamente en air con su propia state class.
3. Independencia
soma no importa de terra
terra no importa de soma
soma no importa de air
soma no importa de $lib/util/reactive
soma solo depende de:
- svelte (runes: $state, $derived, $effect)
- runed (Context, watch)
- clsx (class merging)
- @floating-ui (positioning)
Todo lo demas vive dentro de src/uix/soma/.
4. Estructura
src/uix/soma/
│
├── README.md
│
├── reactive/ ← sistema reactivo (propio, sin deps externas)
│ ├── state.svelte.ts ← state<T>(initial): State<T>
│ ├── active.svelte.ts ← readableActive, writableActive
│ ├── guards.ts ← isActive, isState
│ ├── symbols.ts ← ActiveSymbol, WritableSymbol
│ ├── types.ts ← Active<T>, State<T>, Getter<T>, ActiveProps<T>, StateProps<T>
│ └── index.ts
│
│ Eliminado vs terra: toValue, normalize, flatten, readonly (no usados o triviales)
│ Extraido a su propio modulo: autoReset → reactive/auto-reset.svelte.ts
│
├── context/ ← context system
│ ├── context.ts ← createContext<T>(name) — wraps runed Context
│ └── index.ts
│
├── state/ ← HeadlessState base class
│ ├── headless-state.svelte.ts
│ └── index.ts
│
├── props/ ← prop merging
│ ├── merge-props.ts ← mergeProps(...sources)
│ ├── compose-handlers.ts
│ └── index.ts
│
│ Simplificado vs terra: event detection por prefijo 'on' en vez de lista hardcodeada
│ Simplificado vs terra: style merging extraido a helper mergeStyles()
│
├── attrs/ ← data-* system
│ ├── create-attrs.ts ← createAttrs({ component, parts })
│ ├── contracts.ts ← assertContract, ComponentContract
│ ├── helpers.ts ← boolToStr, boolToEmptyStrOrUndef, etc.
│ └── index.ts
│
├── keyboard/ ← keyboard system
│ ├── keys.ts ← KEYS constant
│ ├── directional.ts ← getNextKey, getPrevKey, getDirectionalKeys
│ ├── is-using-keyboard.svelte.ts
│ └── index.ts
│
├── dom/ ← DOM utilities (reorganizado, sin cajones de sastre)
│ ├── core.ts ← isHTMLElement (version unica, instanceof),
│ │ isDocument, isWindow, isNode, isShadowRoot,
│ │ contains, getDocument, getWindow, getActiveElement,
│ │ getParentNode, getNodeName
│ ├── env.ts ← isBrowser, isIOS, isTouch
│ ├── context.svelte.ts ← DOMContext class
│ ├── focus/ ← todo lo de focus junto
│ │ ├── focus.ts ← focus(), focusFirst(), focusWithoutScroll()
│ │ ├── tabbable.ts ← getTabbableCandidates(), getTabbableFrom(), edges
│ │ ├── roving-focus.svelte.ts ← RovingFocusGroup class
│ │ ├── arrow-nav.ts ← useArrowNavigation()
│ │ └── guards.ts ← isElementHidden, isSelectableInput, isFocusVisible
│ └── index.ts
│
│ Eliminado vs terra: dom-internal.ts, elements.ts (merged en core.ts)
│ Eliminado vs terra: is-browser.ts como cajon de sastre (split en core.ts + env.ts + focus/guards.ts)
│ Eliminado vs terra: isHTMLElement duplicado (una sola version canonica)
│ Movido: body-scroll-lock → layers/scroll/
│ Movido: resize-observer → layers/observers/
│ Movido: locale.ts → config/ (direction es config, no DOM)
│ Movido: isNull, isFunction, isNumberString → types/guards.ts (no son DOM)
│ Movido: handleCalendarInitialFocus → componente calendar (no es generico)
│
├── events/ ← event system
│ ├── types.ts ← EventCallback, typed helpers
│ ├── add-event-listener.ts
│ └── index.ts
│
│ Eliminado vs terra: event-list.ts hardcodeado (detection por prefijo 'on')
│
├── css/ ← style utilities
│ ├── parse.ts ← cssToStyleObj, styleToString
│ ├── sr-only.ts ← srOnlyStyles
│ └── index.ts
│
├── id/ ← ID generation
│ ├── create-id.ts
│ └── index.ts
│
├── types/ ← shared types
│ ├── component.ts ← WithChild, WithRefOpts, Orientation, Direction
│ ├── events.ts ← SomaEvent, SomaKeyboardEvent, SomaMouseEvent, etc.
│ ├── html.ts ← PrimitiveDivAttributes, PrimitiveButtonAttributes, etc.
│ ├── guards.ts ← isNull, isFunction, isNumberString (genericos)
│ └── index.ts
│
├── layers/ ← behavior layers — solo clases .svelte.ts (ver §10)
│ ├── presence.svelte.ts ← animation-aware mount/unmount
│ ├── focus-scope.svelte.ts ← focus trap, auto-focus, restore
│ ├── dismissal.svelte.ts ← escape + interact-outside (merged)
│ ├── text-selection.svelte.ts ← prevenir selection overflow
│ ├── scroll-lock.svelte.ts ← body scroll lock con refcount
│ ├── resize-observer.svelte.ts ← ResizeObserver con lifecycle Svelte
│ ├── floating/ ← posicionamiento relativo a anchor (@floating-ui)
│ │ ├── floating.svelte.ts ← FloatingProvider, FloatingContent, FloatingArrow, FloatingAnchor
│ │ ├── use-floating.svelte.ts ← core positioning engine (computePosition wrapper)
│ │ ├── safe-polygon.ts ← safe hover zone for popovers/tooltips
│ │ ├── types.ts ← shared types (Measurable, options, return types)
│ │ ├── utils.ts ← DPR, CSS vars, visibility helpers
│ │ └── index.ts
│ └── index.ts
│
├── soma.svelte.ts ← Soma class (root instance, services, resolve)
│
├── components/
│ ├── internal/ ← componentes Svelte internos de soma
│ │ ├── arrow.svelte ← flecha visual para floating
│ │ ├── visually-hidden.svelte ← sr-only content
│ │ ├── portal.svelte ← render en otro nodo DOM
│ │ ├── portal-consumer.svelte
│ │ └── index.ts
│ │
│ └── [componente]/ ← each headless component
│ ├── [comp]-provider.svelte.ts ← state classes (NOT [comp].svelte.ts)
│ ├── types.ts ← public props with JSDoc
│ ├── components/ ← svelte wrappers
│ ├── exports.ts ← barrel (Provider, not Root)
│ └── index.ts
│
├── exports.ts ← barrel principal
└── index.ts
Decisiones de estructura vs terra
Reactive: eliminados toValue, normalize, flatten, readonly (sin uso real o triviales). autoReset extraido a su propio archivo.
Props: event detection por prefijo on en vez de lista hardcodeada de 104 eventos. Style merging extraido a mergeStyles() helper.
DOM reorganizado:
dom-internal.ts+elements.ts→ merged encore.tsis-browser.ts(cajon de sastre con 14 exports) → split encore.ts(DOM guards),env.ts(entorno),focus/guards.ts(focus-specific),types/guards.ts(genericos)isHTMLElementduplicado → una sola version canonica (instanceof)body-scroll-lockyresize-observer→ movidos alayers/locale.ts→ movido aconfig/direction.tshandleCalendarInitialFocus→ movido al componente calendar
Layers reorganizados:
dismissible/+escape/→ merged endismissal.svelte.ts(siempre van juntos, mismos types, mismo patron de registry)- Layers divididos en dos categorias: behavior (clases consumidas por Providers) y DOM (componentes usados en templates)
- Behavior layers eliminan el nesting de 5 niveles de terra — se integran via
.propsen el Provider - Archivos atomicos (1 archivo = 1 layer) salvo Portal (necesita consumer) y Floating (complejo)
- Naming normalizado: archivo = concepto, clase = concepto, sin sufijos redundantes (Layer, State, Body)
Framework hooks eliminados: onMountEffect, onDestroyEffect, useOnChange, watch custom → usar Svelte 5 nativo ($effect) o runed directamente.
Otros eliminados: afterSleep (0 usos), event-list.ts hardcodeado.
5. Sistema reactivo
soma tiene su propio sistema reactivo, independiente de terra. Es una capa fina sobre los runes de Svelte 5 que permite pasar estado reactivo por referencia entre clases y funciones.
Primitivas
// Mutable container — backed by $state
const count = state(0);
count.current++;
// Readonly derived — backed by $derived.by
const double = readableActive(() => count.current * 2);
double.current; // 2
// Writable derived — getter + setter (para two-way binding)
const bound = writableActive(
() => pressed,
(v) => (pressed = v),
);
Tipos
// Readonly reactive container
type Active<T> = { readonly current: T };
// Mutable reactive container
type State<T> = { current: T };
// Mapping helpers — wraps all properties
type ActiveProps<T> = { [K in keyof T]: Active<T[K]> };
type StateProps<T> = { [K in keyof T]: State<T[K]> };
Regla
Los state classes de soma reciben sus opciones como Active<T> o State<T>. Los wrappers svelte convierten props normales a Active/State con readableActive/writableActive. Esta conversion es la frontera entre el mundo de props de Svelte y el mundo de clases reactivas de soma.
6. Sistema de contexto
import { createContext } from 'soma/context';
const AccordionCtx = createContext<AccordionState>('Accordion');
// En el state class:
AccordionCtx.set(instance); // registra
AccordionCtx.get(); // lee (throws si no existe)
AccordionCtx.getOr(fallback); // lee con fallback
createContext wraps runed's Context con mensajes de error descriptivos.
7. HeadlessState
Base class abstracta que elimina el boilerplate comun a todos los state classes.
import { HeadlessState } from 'soma/state';
export class ToggleState extends HeadlessState<Opts> {
// --- Manual: static create + context (3 lineas, semantica importante) ---
static create(opts: Opts) {
return new ToggleState(opts);
}
// --- Constructor: solo super() + component-specific init ---
private constructor(opts: Opts) {
super(opts, 'Toggle', 'root', attrs.root);
}
// --- Event handlers (component-specific) ---
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.opts.disabled.current) return;
const next = !this.opts.pressed.current;
this.opts.pressed.current = next;
this.opts.onPressedChange.current(next);
};
// --- Props derivation (component-specific body, base handles boilerplate) ---
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' as const,
disabled: this.opts.disabled.current,
'aria-pressed': this.opts.pressed.current,
'data-state': this.opts.pressed.current ? 'on' : 'off',
onclick: this.onclick,
} as const)
);
}
Lo que HeadlessState proporciona
abstract class HeadlessState<TOpts extends WithRefOpts> {
readonly opts: TOpts;
readonly attachment: RefAttachment;
constructor(opts, component, part, partAttr, onRefChange?);
// Spread en cada props derivation
protected get baseProps(): { id, [partAttr], ...attachment };
// Assert contract + return
protected assertProps<P>(props: P): P;
}
Lo que cada componente define
static create()— con o sin context registrationstatic ctx— solo si usa context- Constructor — llama
super()con component/part info readonly props = $derived.by(...)— ARIA, data-*, events, estados- Event handlers — logica de interaccion
- Derived state — valores computados del componente
8. Sistema de attrs
import { createAttrs } from 'soma/attrs';
const attrs = createAttrs({
component: 'accordion',
parts: ['root', 'item', 'trigger', 'content'] as const,
});
attrs.root; // "data-accordion"
attrs.item; // "data-accordion-item"
attrs.trigger; // "data-accordion-trigger"
attrs.selector('content'); // "[data-accordion-content]"
Regla de naming
- La parte
rootgeneradata-{component}(sin sufijo-root) - Las demas partes generan
data-{component}-{part} - Estos attrs son API publica — cambiarlos es breaking change
- air los usa como selectores CSS
Contracts
import { assertContract } from 'soma/attrs';
// Dentro de $derived.by():
return this.assertProps({
...this.baseProps,
'data-state': open ? 'open' : 'closed',
'data-disabled': boolToEmptyStrOrUndef(disabled),
});
// assertProps valida que los data-* attrs cumplen el contrato registrado
Boolean helpers
boolToStr(true) // 'true'
boolToEmptyStrOrUndef(true) // ''
boolToEmptyStrOrUndef(false) // undefined
boolToTrueOrUndef(true) // true
boolToTrueOrUndef(false) // undefined
9. mergeProps
import { mergeProps } from 'soma/props';
const merged = mergeProps(restProps, state.props);
Comportamiento:
- Event handlers (
onclick,onfocus, etc.) → compuestos concomposeHandlers class→ merged con clsxstyle→ merged (object + string)hidden: false/disabled: false→ eliminados (fix Svelte)- Resto → last wins
Es la misma logica que terra pero con imports propios de soma.
10. Layers
Los layers son infraestructura compartida que los componentes compuestos usan para resolver problemas transversales. Se dividen en dos categorias segun como se consumen.
Principio
layers/ contiene solo clases de comportamiento (.svelte.ts). Son infraestructura consumida por Providers.
Los componentes Svelte internos de soma (Portal, Arrow, VisuallyHidden) viven en components/internal/ — no son layers, son componentes utilitarios que generan DOM propio.
Behavior layers — consumo en Provider
Los behavior layers se instancian dentro del Provider y exponen .props para merge. Esto elimina el nesting de 5 niveles que existia en terra.
class DialogContentProvider extends Provider<ContentOpts> {
readonly presence = new Presence({ ref: opts.ref, open: opts.open });
readonly focusScope = FocusScope.use({ ref: opts.ref, trap: opts.trapFocus, ... });
readonly dismissal = Dismissal.use({ ref: opts.ref, onEscapeKeydown: opts.onEscapeKeydown, ... });
readonly scrollLock = new ScrollLock();
readonly textSelection = TextSelection.use({ ref: opts.ref, ... });
readonly props = $derived.by(() => this.assertProps({
...this.baseProps,
...this.focusScope.props,
...this.dismissal.props,
role: 'dialog',
}));
}
Convencion .use() vs new
X.use(opts)— el layer gestiona su propio lifecycle viawatch/$effect. Constructor privado.new X(opts)— el consumidor controla el lifecycle (e.g..locked.current = true).
| Layer | API | Props | Descripcion |
|---|---|---|---|
Presence |
new Presence(opts) |
— | Animation-aware mount/unmount. Expone isPresent. |
FocusScope |
FocusScope.use(opts) |
{ tabindex: -1 } |
Focus trap, loop, auto-focus, restore. |
Dismissal |
Dismissal.use(opts) |
{ onfocuscapture, onblurcapture } |
Escape + interact-outside. Global registry. |
TextSelection |
TextSelection.use(opts) |
— | Previene selection overflow durante drag. |
ScrollLock |
new ScrollLock() |
— | Body scroll lock con refcount. |
ResizeObserver$ |
new ResizeObserver$(getter, cb) |
— | ResizeObserver con lifecycle Svelte. |
Componentes internos (components/internal/)
Portal, Arrow y VisuallyHidden no son layers — son componentes Svelte que generan DOM propio. Se usan en templates de wrappers:
<Portal to="body">
<div {...provider.props}>
<Arrow />
{@render children?.()}
</div>
</Portal>
Naming
- Archivo: concepto (
dismissal.svelte.ts, nodismissal-layer.svelte.ts) - Clase: concepto (
Dismissal, noDismissalLayerState) - Opts type:
{Concepto}Opts(DismissalOpts, noDismissalLayerProps) - Sin sufijos redundantes: no
Layer(ya estan enlayers/), noState(son todas clases de estado), noBody(contexto implicito)
11. Keyboard
import { KEYS } from 'soma/keyboard';
KEYS.ENTER // 'Enter'
KEYS.ESCAPE // 'Escape'
KEYS.ARROW_DOWN // 'ArrowDown'
KEYS.SPACE // ' '
KEYS.TAB // 'Tab'
KEYS.HOME // 'Home'
KEYS.END // 'End'
import { getDirectionalKeys } from 'soma/keyboard';
const { nextKey, prevKey } = getDirectionalKeys('ltr', 'horizontal');
// nextKey: 'ArrowRight', prevKey: 'ArrowLeft'
import { IsUsingKeyboard } from 'soma/keyboard';
// Singleton global — detecta si el usuario navega con teclado o pointer
IsUsingKeyboard.current // boolean
12. DOM utilities
Focus
focusWithoutScroll(element) // Focus sin scroll
focusFirst(candidates[]) // Focus primer focusable
getTabbableCandidates(container) // TreeWalker para tabbables
getTabbableEdges(container) // Primer y ultimo tabbable
Roving Focus
const roving = new RovingFocusGroup({
candidateAttr: attrs.trigger, // selector de candidatos
rootNode: ref, // nodo contenedor
loop: true, // wrap at ends
orientation: 'horizontal', // arrow key direction
});
roving.handleKeydown(currentElement, event);
Arrow Navigation
useArrowNavigation(event, currentElement, parentElement, {
loop: true,
dir: 'ltr',
orientation: 'horizontal',
});
Scroll Lock
const lock = new ScrollLock();
lock.locked.current = true; // locks body scroll
lock.locked.current = false; // restores
13. Soma (root instance)
Soma es la identidad runtime del framework. No es "configuracion" — es el objeto raiz que provee servicios a todo el arbol via context.
Clase
class Soma {
static create(props: SomaProps): Soma; // crea y registra en context
static get(): Soma; // lee del context (throws)
static getOr(): Soma | undefined; // lee del context (safe)
static resolve<T>(prop, fromConfig, fallback): Active<T>;
readonly dir: Active<Direction>;
readonly translator: Active<SomaTranslator | undefined>;
readonly logger: Active<SomaLogger | undefined>;
readonly portalTo: Active<PortalTarget | undefined>;
readonly format: Active<SomaFormat | undefined>;
translate(path: string, fallback: string): string;
}
SomaFormat (formatters namespace)
interface SomaFormat {
locale: string;
date?: SomaDateFormat; // getDateOrder(), getHourCycle()
number?: SomaNumberFormat; // format(value, options?)
currency?: SomaCurrencyFormat; // format(value, currency, options?)
unit?: SomaUnitFormat; // format(value, unit, options?)
}
resolve() — cadena prop → config → fallback
// En un Provider:
readonly soma = Soma.getOr();
readonly dateOrder = Soma.resolve(
opts.dateOrder, // prop explicita
() => this.soma?.format.current?.date?.getDateOrder(), // soma
'DMY', // fallback
);
1 generico reemplaza los 6 resolvers especificos de terra.
Uso en Providers
class DialogProvider {
readonly soma = Soma.getOr();
translate(path: string, fallback: string): string {
return this.soma?.translate(`dialog.${path}`, fallback) ?? fallback;
}
}
Setup
<Soma dir="ltr" {translator} {format}>
<App />
</Soma>
14. Naming conventions
Clases
AccordionState— state principal del componenteAccordionItemState— state de una sub-parteAccordionTriggerState— state de otra sub-parteHeadlessState— base class (noSomaState, noSomaPartState)
Files
accordion-provider.svelte.ts— state classes (NOTaccordion.svelte.ts)accordion.svelte— root wrapper (incomponents/)accordion-item.svelte— sub-part wrappertypes.ts— public props with JSDocexports.ts— barrel (exportsProvider, notRoot)
Props
AccordionPropsnotAccordionProviderProps(root doesn't need "Provider" suffix in type name)AccordionItemProps— sub-partAccordionTriggerProps— sub-part
Documentacion de props (norma obligatoria)
Todas las props de todos los componentes deben documentarse con JSDoc en types.ts.
Cada prop lleva: descripcion, @default si tiene valor por defecto, notas de comportamiento si aplica.
export type DialogProps = {
/** Whether the dialog is open. Bindable. @default false */
open?: boolean;
/**
* When true, click outside does NOT close. When false, click outside closes.
* @default true
*/
modal?: boolean;
/** Callback fired when open state changes. */
onOpenChange?: OnChangeFn<boolean>;
};
Reglas:
- Una linea para props simples:
/** Unique identifier. Auto-generated if omitted. */ - Bloque multilinea cuando hay logica condicional o interacciones entre props
@defaultsiempre que haya un valor por defecto en el wrapper.svelte- Documentar relaciones entre props: "Overrides X from Y when set"
- Callbacks: documentar que
e.preventDefault()hace, si aplica
Comparacion con librerias de referencia
Cada componente headless debe compararse con su equivalente en ark-ui, bits-ui y radix-ui. Documentar en el commit o PR: que props tienen ellos que soma no, y justificar si se omiten o se incluyen.
Attrs
data-accordion— root (sin-root)data-accordion-item— sub-partedata-accordion-trigger— sub-partedata-state— estado compartido (open/closed,on/off,checked/unchecked)data-disabled— flag de disableddata-orientation— orientacion
Events
- DOM handlers:
onclick,onkeydown,onfocus(lowercase, Svelte convention) - User callbacks:
onOpenChange,onValueChange(camelCase)
Layers
- Archivo = concepto:
dismissal.svelte.ts,presence.svelte.ts - Clase = concepto:
Dismissal,Presence,FocusScope,ScrollLock - Opts =
{Concepto}Opts:DismissalOpts,TextSelectionOpts - Sin sufijos redundantes: no
Layer, noState, noBody - Behavior layers →
.svelte.tsdirecto enlayers/ - DOM layers →
.sveltedirecto enlayers/(o subdir si necesita multiples archivos)
15. Patron de componente
State class (accordion.svelte.ts)
const attrs = createAttrs({ component: 'accordion', parts: ['root', 'item', 'trigger', 'content'] as const });
interface AccordionOpts extends WithRefOpts,
StateProps<{ value: string[] }>,
ActiveProps<{ disabled: boolean; onValueChange: OnChangeFn<string[]> }> {}
export class AccordionState extends HeadlessState<AccordionOpts> {
static readonly ctx = createContext<AccordionState>('Accordion');
static create(opts: AccordionOpts) {
return AccordionState.ctx.set(new AccordionState(opts));
}
private constructor(opts: AccordionOpts) {
super(opts, 'Accordion', 'root', attrs.root);
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-orientation': this.opts.orientation?.current,
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
} as const)
);
}
Wrapper svelte (accordion.svelte)
<script lang="ts">
import { readableActive, writableActive } from 'soma/reactive';
import { mergeProps, createId } from 'soma';
import { AccordionState } from './accordion.svelte.ts';
import type { AccordionProps } from './types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
value = $bindable([]),
disabled = false,
onValueChange,
children,
child,
...restProps
}: AccordionProps = $props();
const state = AccordionState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
value: writableActive(() => value, (v) => (value = v)),
disabled: readableActive(() => disabled),
onValueChange: readableActive(() => onValueChange),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}
16. Patron de composicion
Los componentes compuestos siguen el patron Root → Parts con contexto:
<Accordion.Root bind:value>
<Accordion.Item value="one">
<Accordion.Trigger>Click me</Accordion.Trigger>
<Accordion.Content>Content here</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
Flujo de datos
Root
├── crea AccordionState
├── registra en context via AccordionState.ctx.set()
└── children
├── Item
│ ├── crea AccordionItemState
│ ├── lee AccordionState via AccordionState.ctx.get()
│ └── children
│ ├── Trigger → lee AccordionItemState
│ └── Content → lee AccordionItemState
└── Item
└── ...
Regla de context
- Root siempre hace
.ctx.set(instance)enstatic create() - Sub-parts siempre hacen
.ctx.get()para leer el padre - Si una sub-part tiene hijos que necesitan su estado, crea su propio context (Item tiene ctx, Trigger lo lee)
- El context es por componente instance — multiples Accordion en la misma pagina funcionan independientemente
17. Relacion con air
soma → headless behavior, accesibilidad, data-* contracts, context
air → visual layer: tokens, CSS recipes, sizes, variants, motion, sound
air consume soma de la misma forma que consume terra hoy:
- Importa componentes:
import { Accordion } from '$uix/soma' - Wrappea con clases CSS:
class="air-accordion" - Responde a data-*:
[data-accordion][data-state='open'] { ... } - Añade props visuales:
size,variant,color - Añade semanticas:
air.interaction.play('expansion', 'enter')
air NUNCA:
- Importa state classes internas de soma
- Depende de estructura DOM incidental
- Accede a propiedades privadas de state classes
- Duplica behavior que soma ya resuelve
18. Checklist de componente nuevo
[ ] 1. Verificar criterio de pertenencia (composicion + behavior complejo)
[ ] 2. Definir partes: Root + sub-parts
[ ] 3. Definir attrs: createAttrs({ component, parts })
[ ] 4. Definir contract: data-* attrs por parte
[ ] 5. Definir types.ts: props publicas con WithChild/ActiveProps/StateProps
[ ] 6. Implementar state classes extendiendo HeadlessState
[ ] 7. Implementar wrappers svelte (thin: props → state → mergeProps → render)
[ ] 8. Implementar exports.ts barrel
[ ] 9. Añadir al barrel principal de soma
[ ] 10. Crear test page en /test/soma/[componente]
[ ] 11. Crear air wrapper en air/components/[componente]
[ ] 12. Verificar: npm run check + dev server
19. Inventory
Implemented (12)
Accordion, Checkbox (Group), Collapsible, Combobox, Dialog (AlertDialog variant), DropdownMenu, Popover, RadioGroup, Select, Slider, Switch, Tooltip.
Planned soma components
Calendar, ContextMenu, Command, DateField, DatePicker, DateRangeField, DateRangePicker, Drawer, Editable, Field/Form, FileUpload, LinkPreview, Menubar, NumberField, Pagination, RangeCalendar, ScrollArea, Splitter, Stepper, Tabs, TagsInput, TimeField, TimeRangeField, Toast, Toggle (Group), Toolbar.
Air-native (no soma)
Avatar, Badge, Button, Label, Meter, Progress, Separator, Spinner, PinInput, RatingGroup, ColorPicker, AspectRatio, Typography, Layout, Icon.