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
dev 8a88ad577a
soma: audit fixes — 84 issues across 25 components, Table complete
6 months ago
..
attrs Add soma foundation + air components (Pagination, Editable, PinInput, RatingGroup, Stepper, FileUpload) 6 months ago
components soma: audit fixes — 84 issues across 25 components, Table complete 6 months ago
core soma: refactor providers to follow terra patterns, fix translations, add table/number-field 6 months ago
css Add soma foundation + air components (Pagination, Editable, PinInput, RatingGroup, Stepper, FileUpload) 6 months ago
dom Add soma foundation + air components (Pagination, Editable, PinInput, RatingGroup, Stepper, FileUpload) 6 months ago
events Add soma foundation + air components (Pagination, Editable, PinInput, RatingGroup, Stepper, FileUpload) 6 months ago
external/dates soma: refactor providers to follow terra patterns, fix translations, add table/number-field 6 months ago
id Refactor soma: consolidate layers, add Soma class, Dialog component, animation system 6 months ago
keyboard Add soma foundation + air components (Pagination, Editable, PinInput, RatingGroup, Stepper, FileUpload) 6 months ago
layers soma: refactor providers to follow terra patterns, fix translations, add table/number-field 6 months ago
props Add soma foundation + air components (Pagination, Editable, PinInput, RatingGroup, Stepper, FileUpload) 6 months ago
provider soma: refactor providers to follow terra patterns, fix translations, add table/number-field 6 months ago
reactive Add soma foundation + air components (Pagination, Editable, PinInput, RatingGroup, Stepper, FileUpload) 6 months ago
types soma: audit fixes — 84 issues across 25 components, Table complete 6 months ago
AUDIT.md soma: refactor providers to follow terra patterns, fix translations, add table/number-field 6 months ago
COMPONENT_GUIDE.md soma: audit fixes — 84 issues across 25 components, Table complete 6 months ago
README.md soma: 12 components, architecture docs, naming fixes, form tier complete 6 months ago
SOMA_ARCHITECTURE.md soma: refactor providers to follow terra patterns, fix translations, add table/number-field 6 months ago
index.ts soma: refactor providers to follow terra patterns, fix translations, add table/number-field 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 en core.ts
  • is-browser.ts (cajon de sastre con 14 exports) → split en core.ts (DOM guards), env.ts (entorno), focus/guards.ts (focus-specific), types/guards.ts (genericos)
  • isHTMLElement duplicado → una sola version canonica (instanceof)
  • body-scroll-lock y resize-observer → movidos a layers/
  • locale.ts → movido a config/direction.ts
  • handleCalendarInitialFocus → movido al componente calendar

Layers reorganizados:

  • dismissible/ + escape/ → merged en dismissal.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 .props en 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 registration
  • static 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 root genera data-{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 con composeHandlers
  • class → merged con clsx
  • style → 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 via watch/$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, no dismissal-layer.svelte.ts)
  • Clase: concepto (Dismissal, no DismissalLayerState)
  • Opts type: {Concepto}Opts (DismissalOpts, no DismissalLayerProps)
  • Sin sufijos redundantes: no Layer (ya estan en layers/), no State (son todas clases de estado), no Body (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 componente
  • AccordionItemState — state de una sub-parte
  • AccordionTriggerState — state de otra sub-parte
  • HeadlessState — base class (no SomaState, no SomaPartState)

Files

  • accordion-provider.svelte.ts — state classes (NOT accordion.svelte.ts)
  • accordion.svelte — root wrapper (in components/)
  • accordion-item.svelte — sub-part wrapper
  • types.ts — public props with JSDoc
  • exports.ts — barrel (exports Provider, not Root)

Props

  • AccordionProps not AccordionProviderProps (root doesn't need "Provider" suffix in type name)
  • AccordionItemProps — sub-part
  • AccordionTriggerProps — 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
  • @default siempre 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-parte
  • data-accordion-trigger — sub-parte
  • data-state — estado compartido (open/closed, on/off, checked/unchecked)
  • data-disabled — flag de disabled
  • data-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, no State, no Body
  • Behavior layers → .svelte.ts directo en layers/
  • DOM layers → .svelte directo en layers/ (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) en static 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.

Powered by TurnKey Linux.