|
|
5 months ago | |
|---|---|---|
| .. | ||
| color | 5 months ago | |
| components | 5 months ago | |
| core | 5 months ago | |
| css | 6 months ago | |
| datetime | 5 months ago | |
| id | 6 months ago | |
| keyboard | 6 months ago | |
| layers | 5 months ago | |
| props | 6 months ago | |
| provider | 5 months ago | |
| types | 5 months ago | |
| COMPONENT_GUIDE.md | 5 months ago | |
| README.md | 5 months ago | |
| SOMA_ARCHITECTURE.md | 5 months ago | |
| errors.ts | 5 months ago | |
| index.ts | 5 months ago | |
| runtime.svelte.test.ts | 5 months ago | |
| runtime.svelte.ts | 5 months ago | |
| typeahead.ts | 5 months ago | |
README.md
soma
Librería headless de componentes compuestos para Svelte 5. Capa de
comportamiento dentro de UIX — emite los data-* y aria-* que el
contrato declara en morfo, gestiona estado y eventos, y delega lo
visual a eidos mediante el DOM.
Doctrina del API: soma mantiene la forma compound (
Toggle.Provider,Tabs.Provider + Tabs.Trigger + ...) por simetria con los multi-parte. Eidos no inventa una API flat paralela: aplica la capa visual sobre la anatomia declarada por morfo y materializada por soma.
Handoff 2026-05-14
Soma no debe crecer ahora con ActiveSoma/EngineSoma por simetria. El
contrato minimo de SomaRuntime con ActiveUix (dom, events, langs,
format, prefs) queda descrito en src/uix/contracts.ts. La regla de
ownership ya queda cerrada: Soma no crea servicios
compartidos. Recibe dom desde el scope Soma.runtime(...); si no hay una
superficie ActiveDom, falla en la raiz activa, no dentro de un componente.
Punto critico para manana: confirmar que todo atributo mutable sigue pasando
por el servicio DOM activo, y que ninguna ausencia de dom hace que Soma o
Sema caigan a escrituras directas.
1. Proposito
soma resuelve behavior, accesibilidad, composicion y estado para componentes compuestos. No resuelve presentacion visual — eso es responsabilidad de eidos.
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
- gesture tracking (drag, swipe, resize)
- 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/gesture behavior (slider, splitter, drawer, toast)
- Form integration via context (validation state, hidden inputs)
Si un componente cumple solo uno de los criterios o ninguno, no necesita pasar por soma — su lógica puede vivir directamente en el wrapper de eidos.
3. Independencia
soma solo depende de:
- svelte (runes: $state, $derived, $effect)
- runed (Context, watch)
- clsx (class merging)
- @floating-ui (positioning)
$libs/reactive,$libs/days, etc. — utilidades puras del repo (no façades)$uix/morfo— el contrato cross-layer (compileMorfo + SomaRuntime)$uix/sema— vocabulario semántico + EngineSemantic
soma NO depende de eidos. La capa visual lee del DOM y de los tipos públicos del soma; la dirección del acoplamiento es eidos → soma, no al revés.
Todo lo demas vive dentro de src/uix/soma/.
Imports
Dentro de soma (provider files, layer files, utility files): usar paths relativos. Hace la libreria portable sin forzar aliases en el build del consumidor.
// Inside soma — relative
import { DRAWER_LANGS } from './langs';
import type { DrawerSide } from './types';
import { Presence } from '../../layers/presence.svelte';
Consumidores (layouts, app code, test pages): usan el alias $soma/ que configuran en su build.
// Consumer code — alias
import { commonLangs } from '$soma/core/langs';
import * as Drawer from '$soma/components/drawer';
soma no importa de $lib — cualquier utilidad que necesite (e.g., funciones triviales) debe vivir dentro de soma.
soma sí importa del paquete hermano morfo ($uix/morfo), que es el contrato declarativo de la superficie DOM de cada componente (partes, data-attrs, ARIA, keyboard, focus). Ver §4b.
4. Estructura
src/uix/soma/
│
├── README.md
│
├── reactive/ ← sistema reactivo (propio, sin deps externas)
│ ├── reactive.svelte.ts ← state, readableActive, writableActive, autoReset
│ └── index.ts
│
├── provider/ ← context + opts bridge
│ ├── provider.svelte.ts ← ProviderOpts/WithRefOpts
│ ├── context.ts ← context<T>(name) — wraps runed Context
│ └── index.ts
│
├── props/ ← prop merging
│ ├── merge-props.ts ← mergeProps(...sources)
│ ├── compose-handlers.ts
│ └── index.ts
│
├── keyboard/ ← keyboard system
│ ├── keys.ts ← KEYS constant
│ ├── directional.ts ← getDirectionalKeys
│ ├── is-using-keyboard.svelte.ts
│ └── index.ts
│
├── dom/ ← DOM utilities
│ ├── core.ts ← isHTMLElement, contains, getDocument, etc.
│ ├── env.ts ← isBrowser, isIOS, isTouch
│ ├── context.svelte.ts ← DOMContext class
│ ├── focus/ ← focus utilities
│ │ ├── focus.ts ← focus(), focusFirst(), focusWithoutScroll()
│ │ ├── tabbable.ts ← getTabbableCandidates(), edges
│ │ ├── roving-focus.svelte.ts ← RovingFocusGroup class
│ │ ├── arrow-nav.ts ← useArrowNavigation()
│ │ └── guards.ts ← isElementHidden, isFocusVisible
│ └── index.ts
│
├── 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
│ ├── html.ts ← PrimitiveDivAttributes, PrimitiveButtonAttributes
│ ├── guards.ts ← isNull, isFunction, isNumberString
│ └── index.ts
│
├── layers/ ← behavior layers (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
│ │ ├── use-floating.svelte.ts
│ │ ├── safe-polygon.ts
│ │ ├── types.ts
│ │ ├── utils.ts
│ │ └── index.ts
│ ├── gesture/ ← drag/swipe/resize gesture tracking
│ │ ├── gesture.svelte.ts ← Gesture.base(), Gesture.drag(), Gesture.resize()
│ │ ├── velocity.ts ← ring buffer + velocity calculation (pure)
│ │ ├── types.ts
│ │ └── index.ts
│ └── index.ts
│
├── core/
│ ├── soma.svelte.ts ← Soma class (root instance, service accessors)
│ └── langs.ts ← shared commonLangs defaults
│
├── components/
│ ├── internal/ ← componentes Svelte internos de soma
│ │ ├── arrow.svelte
│ │ ├── visually-hidden.svelte
│ │ ├── portal.svelte
│ │ ├── portal-consumer.svelte
│ │ └── index.ts
│ │
│ └── [componente]/ ← each headless component
│ ├── [comp]-provider.svelte.ts ← state classes concretas
│ ├── types.ts ← public props + canonical field shapes
│ ├── langs.ts ← optional idlangref constants for imperative strings
│ ├── components/ ← svelte wrappers
│ ├── exports.ts ← barrel (Provider, not Root)
│ └── index.ts
│
├── exports.ts ← barrel principal
└── index.ts
4b. Morfo — contrato declarativo cross-layer
Cada componente tiene un archivo en src/uix/morfo/components/{kebab}.ts que declara, en un único objeto tipado, la superficie DOM pública del componente:
- parts — el árbol de partes (name, kebab, kind, defaultElement, role, states, supportsNesting).
- data — qué data-attrs emite cada parte, con valores enum cuando aplica y severity (
required/recommended/optional). - aria — qué atributos ARIA emite cada parte, con la fuente del valor tipada vía tagged union (
v.literal,v.stateRef,v.partRef,v.propRef,v.translationRef) y condición de emisión opcional. - keyboard — los atajos de teclado relevantes por parte.
- focus — política de foco para overlays (
initial,trap,return,restore). - translations — catálogo de traducciones propio del componente, registrado bajo
components.{kebab}. - apg — URL al patrón WAI-ARIA APG cuando aplica.
- scope — las capas que implementan el componente:
['soma'],['soma', 'eidos'], etc.
El morfo es la única fuente de verdad del contrato público. soma, air, eidos, sema y la docs auto-generada lo consumen todos.
¿Por qué morfo existe?
Antes de morfo, la información estructural de un componente vivía en seis sitios:
- mapas manuales de attrs — nombres de partes dentro del provider.
- registro manual de contrato — enums de data-attrs en el provider.
- ARIA hard-coded en cada
$derived.by(...)de props. - Keyboard handlers distribuidos por el provider.
- Prose en el README.
- Selectores en CSS de air/eidos,
.csemde sema y tablas de docs.
Renombrar una parte (content → panel) tocaba 6+ sitios sin verificación automática. Drift cross-layer (soma emite data-dialog-content, eidos estiliza data-dialog-panel) era silencioso.
Con morfo, todo se declara una sola vez. compileMorfo, registerMorfo,
SomaRuntime y el helper tipado createAttrs consumen el morfo directamente.
El script npm run morfo:check valida el DOM real contra la declaración en CI.
Cómo soma consume un morfo
Cada provider raíz crea un runtime con su morfo. Ese paso registra el contrato
data-*, compila la declaración y publica morfo.translations en los ActiveLangs
conectados por ActiveUix:
import { dialogMorfo } from '../../../morfo/components/dialog';
this.soma = Soma.require();
this.runtime = this.soma.runtime(dialogMorfo, sources);
Cuando un provider necesita un selector DOM, puede usar createAttrs(morfo)
desde $uix/morfo. Es solo un helper tipado de nombres, no registra contrato
ni escribe en el DOM:
import { createAttrs } from '$uix/morfo';
const attrs = createAttrs(dialogMorfo); // { provider: 'data-dialog', trigger: 'data-dialog-trigger', ... }
createAttrs es genérica con const type parameter (TS 5.0+): infiere el
tipo del objeto retornado a partir de la forma literal del morfo. Si un wrapper
o provider escribe attrs.trigerr, es error de compilación. El soporte de
autocomplete lista las partes válidas.
El registro ejecutable ocurre al crear el runtime: createSomaRuntime(morfo, sources) y soma.runtime(morfo, sources) llaman internamente a registerMorfo(morfo). Ese registro compila el morfo, registra su contrato data-* y publica morfo.translations en los ActiveLangs conectados por ActiveUix.
Requisito de autoría: cada morfo se declara como as const satisfies Morfo:
// ✅ Obligatorio
export const dialogMorfo = { ... } as const satisfies Morfo;
// ❌ Pierde literales, degrada `createAttrs` a `Record<string, string>`
export const dialogMorfo: Morfo = { ... };
Ver el dev guide completo en src/uix/morfo/README.md.
Validación
Tres capas atrapan tres clases de drift:
- Schema (build/dev):
validateMorfo(morfo)ensrc/uix/morfo/schema.tsvalida shape (sium) + invariantes cruzados (kebabs únicos,partRef.targetresuelve,stateRef.stateestá enstates[], etc.). - Strict mode en
assertContract(dev runtime): valida que los data-attrs emitidos por el provider tienen valores declarados en el morfo. Logs warnings en consola cuando algo se desvía. - CI:
npm run morfo:checknavega a cada demo y valida el DOM real contra el morfo;npm run morfo:vocabularydetecta divergencias de vocabulario canónico (open|closed,active|inactive, etc.).
Qué NO va en morfo
| No | Va en |
|---|---|
| Props del componente | {component}/types.ts con JSDoc |
| Summary, comparativa, ejemplos | {component}/README.md |
| Traducciones shared/common | app/langs catalog bajo common.* |
| Constantes idlangref imperativas | {component}/langs.ts, opcional |
| Event handlers, state machines | {component}-provider.svelte.ts |
| Recetas visuales | src/uix/eidos/ (futuro) |
5. Sistema reactivo
Los runes de Svelte 5 ($state, $derived) son compiler magic — solo funcionan en archivos .svelte y .svelte.ts, y no se pueden pasar como valores entre clases o funciones en TypeScript puro. soma necesita exactamente eso: pasar estado reactivo como argumento de constructor para componer Providers, layers y gestures. La capa reactive/ resuelve esto con contenedores (Active<T>, State<T>) que envuelven runes y exponen .current — el mismo patrón que React refs y Solid signals. Cuando Svelte ofrezca signals exportables de primera clase, esta capa se convierte en un alias thin migrable sin tocar los consumidores.
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 providers 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
Hay dos niveles: la funcion context() (interna) y los static methods de cada clase provider (API publica).
Nivel interno — context()
import { context } from '../provider';
const ctx = context<AccordionProvider>('Accordion');
ctx.set(instance); // registra en Svelte context
ctx.get(); // lee — throws si no existe
ctx.getOr(fallback); // lee con fallback
context() wraps runed's Context con mensajes de error descriptivos.
Nivel publico — static methods
Los consumidores (sub-parts, otros componentes) nunca tocan ctx directamente. Usan los static methods:
// Root provider registra via create()
static create(opts) {
return new AccordionProvider(opts);
}
private constructor(opts) {
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: AccordionProvider.ctx,
syncAttrs: true
});
}
// Sub-parts leen via require() o get()
this.provider = AccordionProvider.require(); // throws
this.group = TooltipGroupProvider.get(); // undefined si no existe
| Method | Returns | Use |
|---|---|---|
create() |
instance | Root crea + registra |
get() |
instance | undefined | Padre opcional |
require() |
instance (throws) | Padre obligatorio |
7. Runtime parts
Soma ya no usa una clase base Provider. Cada provider es una clase concreta
del componente, y registra sus partes mediante SomaRuntime. El runtime es la
autoridad de morfo en soma: registra la parte, crea el ref attachment, expone la
identidad renderizable (id, marker data-*, archetype) y valida los props
autorizados por el contrato.
import { context, type WithRefOpts } from '../provider';
import { Soma } from '../core/soma.svelte';
import type { SomaRuntime, SomaRuntimePart } from '../runtime.svelte';
import { accordionMorfo } from '$uix/morfo/components/accordion';
export class AccordionProvider {
static readonly ctx = context<AccordionProvider>('Accordion');
static get() {
return this.ctx.getOr(undefined) as AccordionProvider | undefined;
}
static require() {
return this.ctx.get();
}
readonly opts: AccordionOpts;
readonly soma: Soma;
readonly runtime: SomaRuntime;
readonly runtimePart: SomaRuntimePart;
static create(opts: AccordionOpts) {
return new AccordionProvider(opts);
}
private constructor(opts: AccordionOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(accordionMorfo, {});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: AccordionProvider.ctx,
syncAttrs: true
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'data-orientation': this.opts.orientation?.current,
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current)
} as const)
);
}
Lo que runtime.part() proporciona
interface SomaRuntimePart {
readonly attachment: RefAttachment;
readonly props: { id, [partAttr], 'data-archetype'?, ...attachment };
resolveProps(bindings?): Record<string, unknown>;
assert<P>(props: P): P; // validates data-* contract
}
runtime.part() es la única API pública para registrar partes. Cuando una parte
ya declara sus states/props como sources del runtime, activa
syncAttrs: true; en ese modo los attrs derivados por morfo se escriben
mediante uix.dom. Si el provider todavía compone attrs en sus props
renderizados, deja syncAttrs sin activar para evitar doble autoridad DOM.
Lo que cada componente define
static ctx— solo si usa contextstatic create()/get()/require()— static method convention- Constructor — asigna
optsy registraruntimePart readonly props = $derived.by(...)— ARIA, data-*, events- Event handlers — logica de interaccion
- Derived state — valores computados
8. Sistema de attrs
import { createAttrs } from '$uix/morfo';
import { accordionMorfo } from '$uix/morfo/components/accordion';
const attrs = createAttrs(accordionMorfo);
attrs.provider; // "data-accordion"
attrs.item; // "data-accordion-item"
attrs.trigger; // "data-accordion-trigger"
// Selector: `[${attrs.content}]`
createAttrs vive en morfo y solo expone nombres tipados para selectors o
tooling. La emisión de markers (data-accordion, data-accordion-item, etc.)
y la escritura de attrs dinámicos pertenecen a SomaRuntime.part(...).
Regla de naming
- La parte
providergeneradata-{component}(sin sufijo-provider) - Las demas partes generan
data-{component}-{part} - Estos attrs son API publica — cambiarlos es breaking change
- air/eidos los usa como selectores CSS
Contracts (via morfo)
El morfo declara cada parte y sus data-* attrs (required/recommended/optional,
con valores enumerados cuando aplica). runtimePart.assert() valida en desarrollo que los
atributos emitidos cumplen el morfo. scripts/morfo-check.ts valida contra el DOM real.
Boolean helpers
boolToStr(true); // 'true'
boolToEmptyStrOrUndef(true); // ''
boolToEmptyStrOrUndef(false); // undefined
boolToTrueOrUndef(true); // true
boolToTrueOrUndef(false); // undefined
9. mergeProps
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
10. Layers
Los layers son infraestructura compartida que los componentes compuestos usan para resolver problemas transversales.
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.
Behavior layers — consumo en Provider
Los behavior layers se instancian dentro del Provider y exponen .props para merge:
class DrawerContentProvider {
readonly focusScope = FocusScope.use({ ref, trap, ... });
readonly dismissal = Dismissal.use({ ref, onEscapeKeydown, ... });
readonly scrollLock = new ScrollLock();
readonly gesture = Gesture.drag({ ref, direction, threshold, ... });
readonly props = $derived.by(() => this.runtimePart.assert({
...this.runtimePart.props,
...this.focusScope.props,
...this.dismissal.props,
...this.gesture.props,
role: 'dialog',
}));
}
Convencion .use() vs new vs Gesture.x()
X.use(opts)— el layer gestiona su propio lifecycle viawatch/$effect. Constructor privado.new X(opts)— el consumidor controla el lifecycle.Gesture.base/drag/resize(opts)— factory functions, tres especializaciones.
| 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. |
Gesture.base |
Gesture.base(opts) |
{ onpointerdown } |
Pointer tracking + axis lock + velocity. |
Gesture.drag |
Gesture.drag(opts) |
{ onpointerdown } |
Base + progress + snap points + dismiss. |
Gesture.resize |
Gesture.resize(opts) |
{ onpointerdown } |
Base + delta + min/max constraints. |
SafePolygon |
new SafePolygon(opts) |
— | Hover-gap corridor between trigger↔content. |
Gesture layer
Three specializations, shared base. The layer measures — the component decides what it means.
Gesture.base() → pointer tracking + axis lock + velocity + cancel
Gesture.drag() → base + offset + progress + snap points + threshold + dismiss
Gesture.resize() → base + delta forwarding + min/max constraints
Key rules:
setPointerCapturedeferred until moveBuffer exceeded for containers with child buttons (Drawer, Slider). Pure drag handles (Splitter) capture immediately — the handle IS the drag target- Cleanup on unmount:
$effect(() => { return () => { this.gesture.cancel(); }; }) - CSS vars (
--drawer-progress,--drawer-offset-x/y) set by provider, consumed by visual layer - During drag:
transition: none+ inlinetransformfor immediate feedback - Design:
src/uix/soma/layers/GESTURES.md
SafePolygon
Handles pointer gap between trigger and floating content (DropdownMenu submenus, Tooltip). Calculates a corridor polygon — pointer can traverse the gap without closing. Used by any component where trigger and content have physical separation.
Patterns
Registry over DOM queries: Sub-parts register in a Map on mount, unregister on unmount. Keyboard nav iterates the registry instead of querySelectorAll. Used by Stepper triggers, Select labels.
Virtual focus (aria-activedescendant): For Select, Combobox — focus stays on trigger, items highlighted via data-highlighted. DOM focus (roving tabindex) for Menu, Toolbar, RadioGroup.
Exit animation via Presence: Toast, Drawer — dismiss marks element as dismissing, Presence animates exit, onComplete removes from array.
Naming
- Archivo = concepto:
dismissal.svelte.ts,presence.svelte.ts - Clase = concepto:
Dismissal,Presence,FocusScope,ScrollLock,Gesture - Opts type:
{Concepto}Opts:DismissalOpts,GestureBaseOpts - Sin sufijos redundantes: no
Layer, noState, noBody
11. Keyboard
KEYS.ENTER; // 'Enter'
KEYS.ESCAPE; // 'Escape'
KEYS.ARROW_DOWN; // 'ArrowDown'
KEYS.SPACE; // ' '
const { nextKey, prevKey } = getDirectionalKeys('ltr', 'horizontal');
// nextKey: 'ArrowRight', prevKey: 'ArrowLeft'
IsUsingKeyboard.current; // boolean — teclado vs pointer
12. DOM utilities
Focus
focusWithoutScroll(element)
focusFirst(candidates[])
getTabbableCandidates(container)
getTabbableEdges(container)
Roving Focus
const roving = new RovingFocusGroup({
candidateAttr: attrs.trigger,
rootNode: ref,
loop: true,
orientation: 'horizontal'
});
roving.handleKeydown(currentElement, event);
Scroll Lock
const lock = new ScrollLock();
lock.locked.current = true; // locks body scroll
lock.locked.current = false; // restores
13. Soma (root instance)
Soma es el scope runtime de componentes. Lee ActiveUix desde context y expone
solo la superficie que los providers necesitan: dom, events, langs,
formatters, prefs y logger. No crea servicios propios. Las traducciones de
componentes se declaran en morfo.translations y ActiveUix las registra en
ActiveLangs cuando el morfo entra en el registro.
Clase
class Soma {
static create(opts?: SomaOptions): Soma; // crea y registra en context
static get(): Soma | undefined; // lee del context (safe)
static require(): Soma; // throws si no existe
readonly uix: ActiveUix;
readonly portalTo: string | HTMLElement | undefined;
// Service accessors (delegan a ActiveUix)
get langs(): ActiveLangs;
get nums(): ActiveNumbers | undefined;
get money(): ActiveCurrency | undefined;
get dates(): ActiveDates | undefined;
get units(): ActiveUnits | undefined;
get prefs(): ActiveUixPrefsView; // adaptador historico respaldado por uix.prefs
get logger(): EngineLogger;
runtime(morfo, sources): SomaRuntime;
}
Traducciones
El catálogo nuevo de traducciones de componente vive en el morfo:
export const drawerMorfo = {
name: 'Drawer',
kebab: 'drawer',
translations: {
trigger: { es: 'Abrir cajon', en: 'Open drawer' }
}
// ...
} as const satisfies Morfo;
ActiveUix conecta el registro de morfos con ActiveLangs: cuando un provider
crea createSomaRuntime(morfo, sources) o llama soma.runtime(morfo, sources),
el morfo se registra y sus translations se extienden bajo components.{kebab}.
commonLangs en core/langs.ts aporta los defaults para v.commonRef(...)
(common.buttons.close, common.buttons.cancel, etc.). El integrador puede
predefinir sus propias claves en el schema de langs; ActiveUix solo rellena
las que falten.
No existe catálogo global por componente. ActiveUix conecta el registro de
morfos; cada componente publica sus textos cuando su morfo se registra.
Namespace structure:
common.buttons.close ← shared (soma + eidos + app)
common.buttons.open
components.dialog.trigger ← component-specific
components.drawer.trigger
Uso en Providers
// drawer/langs.ts — optional constants for imperative strings
export const DRAWER_LANGS = {
CLOSE: '#?common.buttons.close|Close'
} as const;
// drawer-provider.svelte.ts
import { DRAWER_LANGS } from './langs';
class DrawerCloseProvider {
readonly props = $derived.by(() => ({
'aria-label': this.provider.soma?.langs.ts(DRAWER_LANGS.CLOSE)
}));
}
Rules:
langs.ts()for simple strings,langs.t()only for interpolated templates- Fallback inline via idlangref (
#?path|fallback), never?? 'fallback' - No
translate()helpers in providers - Text owned by the component goes in
morfo.translationsand is referenced withv.translationRef - Common keys (
close,open,cancel) usecommon.*/v.commonRef, not per-component duplicates
Setup
<Soma>
<App />
</Soma>
14. Naming conventions
Clases
AccordionProvider— root provider del componenteAccordionItemProvider— sub-parteAccordionTriggerProvider— sub-parte
Files
accordion-provider.svelte.ts— state classes/providers (NOTaccordion.svelte.ts)accordion.svelte— root wrapper (incomponents/)accordion-item.svelte— sub-part wrappertypes.ts— public props + canonical field shapeslangs.ts— optional idlangref constants for imperative provider stringsexports.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.
Comparacion con librerias de referencia
Cada componente headless debe compararse con su equivalente en ark-ui, bits-ui y radix-ui. Documentar: 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-state— estado compartido (open/closed,on/off,checked/unchecked)data-disabled— flag de disableddata-orientation— orientaciondata-dragging— gesture activo
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,Gesture - Opts =
{Concepto}Opts:DismissalOpts,GestureBaseOpts - Sin sufijos redundantes
15. Patron de componente
Provider ({name}-provider.svelte.ts)
import { accordionMorfo } from '$uix/morfo/components/accordion';
// Canonical field shapes defined in types.ts, referenced here
interface AccordionOpts
extends WithRefOpts, StateProps<AccordionStateFields>, ActiveProps<AccordionActiveFields> {}
export class AccordionProvider {
static readonly ctx = context<AccordionProvider>('Accordion');
static get() {
return this.ctx.getOr(undefined) as AccordionProvider | undefined;
}
static require() {
return this.ctx.get();
}
readonly opts: AccordionOpts;
readonly soma: Soma;
readonly runtime: SomaRuntime;
readonly runtimePart: SomaRuntimePart;
static create(opts: AccordionOpts) {
return new AccordionProvider(opts);
}
private constructor(opts: AccordionOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(accordionMorfo, {});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: AccordionProvider.ctx,
syncAttrs: true
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'data-orientation': this.opts.orientation?.current,
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current)
} as const)
);
}
Wrapper svelte ({name}.svelte)
<script lang="ts">
import { readableActive, writableActive } from '../../reactive';
import { mergeProps } from '../../props';
import { createId } from '../../id';
import { AccordionProvider } from '../accordion-provider.svelte';
import type { AccordionProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'accordion'),
value = $bindable([]),
disabled = false,
children,
child,
...restProps
}: AccordionProps = $props();
const state = AccordionProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: writableActive(
() => value,
(v) => (value = v)
),
disabled: readableActive(() => disabled)
});
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 Provider → Parts con contexto:
<Accordion.Provider bind:value>
<Accordion.Item value="one">
<Accordion.Header>
<Accordion.Trigger>Click me</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>Content here</Accordion.Content>
</Accordion.Item>
</Accordion.Provider>
Flujo de datos
Provider
├── crea AccordionProvider
├── registra en context via runtime.part(..., { context, owner })
└── children
├── Item
│ ├── crea AccordionItemProvider
│ ├── lee AccordionProvider via AccordionProvider.require()
│ └── children
│ ├── Trigger → lee AccordionItemProvider.require()
│ └── Content → lee AccordionItemProvider.require()
└── Item
└── ...
Regla de context
- Root siempre registra en context al crear su
runtimePart(runtime.part(..., { owner: this, context: XProvider.ctx })) - Sub-parts leen con
XProvider.require()(obligatorio) oXProvider.get()(opcional) - 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/eidos
soma → headless behavior, accesibilidad, data-* contracts, context
air → visual layer: tokens, CSS recipes, sizes, variants
eidos → enhanced visual layer: motion, sound, advanced interactions
air/eidos consume soma:
- Importa componentes:
import { Accordion } from '$uix/soma' - Responde a data-*:
[data-accordion][data-state='open'] { ... } - Añade props visuales:
size,variant,color - Usa mismas translations:
#?common.buttons.close|Close,#?components.dialog.trigger|Open dialog
air/eidos NUNCA:
- Importa Provider classes internas de soma
- Depende de estructura DOM incidental
- Accede a propiedades privadas
- Duplica behavior que soma ya resuelve
18. Checklist de componente nuevo
Referencia completa con todos los pasos en COMPONENT_GUIDE.md. Resumen:
[ ] 1. Comparar con ark-ui, bits-ui, radix-ui — feature table
[ ] 2. Verificar criterio de pertenencia
[ ] 3. Definir partes + attrs + contract
[ ] 4. Crear types.ts (props + canonical field shapes)
[ ] 5. Añadir `morfo.translations` para texto propio; `langs.ts` solo si hacen falta constantes imperativas
[ ] 6. Crear {name}-provider.svelte.ts (state classes concretas, sin heredar de Provider)
[ ] 7. Crear wrappers .svelte (thin)
[ ] 8. Crear exports.ts + index.ts
[ ] 9. Crear test page + link en index
[ ] 10. svelte-check + test in browser
19. Inventory
Implemented
Accordion, Checkbox (Group), Collapsible, Combobox, ContextMenu, Dialog (AlertDialog variant), Drawer, DropdownMenu, Editable, LinkPreview, NumberField, Pagination, Popover, RadioGroup, ScrollArea, Select, Slider, Splitter, Stepper, Switch, Tabs, Table, TagsInput, Toast, Toggle (Group), Toolbar, Tooltip, TreeView.
Planned
Tier 2 (menu + form): Menubar, Command, Field/Form, FileUpload Tier 3 (dates): Calendar, RangeCalendar, DateField, DatePicker, DateRangeField, DateRangePicker Tier 4 (time): TimeField, TimePicker, TimeRangeField Tier 5 (color): ColorPicker, ColorField, ColorRangePicker, ColorRangeField Tier 6 (sound): SoundPicker, SoundRangePicker
Visual-native (no soma)
Avatar (eidos-native), Badge, Button, Label, Meter, Progress, Separator, Spinner, PinInput, RatingGroup, ColorPicker, AspectRatio, Typography, Layout, Icon.