soma: refactor providers to follow terra patterns, fix translations, add table/number-field

- Rename root→provider in all 25 component providers (terra convention)
- Fix translations: use translatePath (ling.t) instead of translate (ling.ts)
  for dot-path resolution with soma. prefix
- Move onChange callbacks from provider Opts to writableActive setters
  in svelte wrappers (terra pattern, reduces prop duplication)
- Add shared test layout with ling translator for all soma test pages
- Add Table component with sorting, selection, pagination, filtering,
  column visibility, ARIA grid pattern
- Add NumberField component with glob numr integration
- Fix table headers reactivity (missing getter)
- Fix table column filters (was passing row instead of cellValue)
- Add AUDIT.md with F1-F15 findings (11 fixed, 4 pending)
- Revert accidental terra modifications

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
semantuix
dev 6 months ago
parent dad3d26139
commit 1fd0b46b2e

@ -114,7 +114,29 @@
"Bash(curl -s \"https://next.bits-ui.com/docs/components/select\")",
"WebFetch(domain:next.bits-ui.com)",
"Bash(curl -s \"https://ark-ui.com/docs/components/combobox\")",
"Bash(curl -s \"https://next.bits-ui.com/docs/components/combobox\")"
"Bash(curl -s \"https://next.bits-ui.com/docs/components/combobox\")",
"Bash(curl -s http://localhost:5173/test/soma/tabs)",
"WebFetch(domain:sonner.emilkowal.ski)",
"WebFetch(domain:www.radix-vue.com)",
"WebFetch(domain:react-resizable-panels.vercel.app)",
"WebFetch(domain:mantine.dev)",
"WebFetch(domain:react-aria.adobe.com)",
"WebFetch(domain:reka-ui.com)",
"WebFetch(domain:www.w3.org)",
"WebFetch(domain:zagjs.com)",
"WebFetch(domain:react-spectrum.adobe.com)",
"WebFetch(domain:headless-tree.lukasbach.com)",
"WebFetch(domain:mui.com)",
"WebFetch(domain:primereact.org)",
"WebFetch(domain:a11ymatters.com)",
"WebFetch(domain:base-ui.com)",
"WebFetch(domain:tanstack.com)",
"WebFetch(domain:icflorescu.github.io)",
"WebFetch(domain:www.ag-grid.com)",
"Bash(sort -t'\"' -k2)",
"Bash(uniq -f1)",
"Bash(netstat -tlnp)",
"Bash(xargs -I {} basename {})"
]
}
}

@ -33,7 +33,7 @@
description: {
es: 'Banco de pruebas para superficies, bordes, alturas visuales y contraste.',
en: 'Test bench for surfaces, borders, visual elevation and contrast.',
ar: 'منصة اختبار للأسطح والحدود والارتفاع البصري والتباين.',
ar: 'منصة اختبار للأسطح والحدود والارتفاع البصري والتباين.'
}
},
{
@ -71,13 +71,15 @@
name: 'Layout · Float',
path: '/test/air/layout/float',
status: 'wip',
description: 'Posicionamiento absoluto por placement para badges, affordances y overlays menores.'
description:
'Posicionamiento absoluto por placement para badges, affordances y overlays menores.'
},
{
name: 'Layout · Box',
path: '/test/air/layout/box',
status: 'wip',
description: 'Base espacial para spacing, sizing, overflow y positioning con props responsivas.'
description:
'Base espacial para spacing, sizing, overflow y positioning con props responsivas.'
},
{
name: 'Layout · Container',
@ -89,7 +91,8 @@
name: 'Layout · Section',
path: '/test/air/layout/section',
status: 'wip',
description: 'Bloque vertical de página con sizes normalizados para ritmo entre secciones.'
description:
'Bloque vertical de página con sizes normalizados para ritmo entre secciones.'
},
{
name: 'Layout · Flex',
@ -137,7 +140,8 @@
name: 'Layout · ScrollArea',
path: '/test/air/layout/scroll-area',
status: 'wip',
description: 'Área scrollable con viewport, scrollbar, thumb y corner sobre terra.ScrollArea.'
description:
'Área scrollable con viewport, scrollbar, thumb y corner sobre terra.ScrollArea.'
},
{
name: 'Layout · Splitter',
@ -157,6 +161,13 @@
status: 'wip',
description: 'Primitivo visual standalone. size, variant, estados nativos. Base de Field.'
},
{
name: 'Style Editor',
path: '/test/air/style-editor',
status: 'wip',
description:
'Editor visual de estilos de componentes. Modifica tokens y genera variantes CSS.'
},
{
name: 'Sounds',
path: '/test/air/sounds',
@ -176,7 +187,7 @@
description: {
es: 'Puente real entre air, terra y ling con el catálogo oficial ya registrado.',
en: 'Real bridge between air, terra and ling with the official catalog already registered.',
ar: 'جسر فعلي بين air و terra و ling مع تسجيل الكتالوج الرسمي بالفعل.',
ar: 'جسر فعلي بين air و terra و ling مع تسجيل الكتالوج الرسمي بالفعل.'
}
}
]
@ -189,13 +200,15 @@
name: 'Accordion',
path: '/test/air/accordion',
status: 'wip',
description: 'Expansión inline sobre terra.Accordion con indicador, tamaños y variants del sistema.'
description:
'Expansión inline sobre terra.Accordion con indicador, tamaños y variants del sistema.'
},
{
name: 'Field/Input',
path: '/test/air/field',
status: 'wip',
description: 'Bloque composable sobre terra.Field: label, control, input, helper, error y adornments.'
description:
'Bloque composable sobre terra.Field: label, control, input, helper, error y adornments.'
},
{
name: 'Form',
@ -215,7 +228,8 @@
name: 'Select',
path: '/test/air/select',
status: 'wip',
description: 'Select dropdown sobre terra.Select con trigger, groups, viewport e items del sistema.'
description:
'Select dropdown sobre terra.Select con trigger, groups, viewport e items del sistema.'
},
{
name: 'Combobox',
@ -325,7 +339,8 @@
name: 'Drawer',
path: '/test/air/drawer',
status: 'wip',
description: 'Panel lateral o inferior sobre terra.Drawer con side, tamaño y superficie propia.'
description:
'Panel lateral o inferior sobre terra.Drawer con side, tamaño y superficie propia.'
},
{
name: 'Dialog',
@ -343,7 +358,8 @@
name: 'Toast',
path: '/test/air/toast',
status: 'wip',
description: 'Notificaciones transitorias con store global, variantes semánticas y acción opcional.'
description:
'Notificaciones transitorias con store global, variantes semánticas y acción opcional.'
},
{
name: 'ContextMenu',

@ -0,0 +1,160 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Soma } from '$soma/components/internal';
import { createLing, type LingNode } from '$lib/ling/lib';
import { createSomaTranslator, extendSomaTranslationModule } from '$soma/core/translator.svelte';
import type { SomaPresentationConfig } from '$soma/types';
let { children }: { children: Snippet } = $props();
// ── Ling ─────────────────────────────────────────────────────────────────
const ling = createLing(
{
app: {
title: { es: 'Test Soma', en: 'Test Soma' },
},
} satisfies LingNode,
'es',
);
// ── Soma translations ────────────────────────────────────────────────────
const somaModule = {
dialog: {
trigger: { es: 'Abrir diálogo', en: 'Open dialog' },
close: { es: 'Cerrar', en: 'Close' },
content: {
roledescription: { es: 'ventana de diálogo', en: 'dialog window' },
},
},
popover: {
close: { es: 'Cerrar', en: 'Close' },
},
toast: {
close: { es: 'Cerrar', en: 'Close' },
},
editable: {
edit: { es: 'Editar', en: 'Edit' },
submit: { es: 'Guardar', en: 'Save' },
cancel: { es: 'Cancelar', en: 'Cancel' },
},
pagination: {
label: { es: 'Paginación', en: 'Pagination' },
prev: { es: 'Página anterior', en: 'Previous page' },
next: { es: 'Página siguiente', en: 'Next page' },
},
table: {
label: { es: 'Tabla de datos', en: 'Data table' },
sort: { es: 'Ordenar', en: 'Sort' },
sortAsc: { es: 'Ordenado ascendente', en: 'Sorted ascending' },
sortDesc: { es: 'Ordenado descendente', en: 'Sorted descending' },
perPage: { es: 'por página', en: 'per page' },
noResults: { es: 'Sin resultados', en: 'No results' },
globalFilter: { es: 'Buscar en todas las columnas...', en: 'Search all columns...' },
clear: { es: 'Limpiar', en: 'Clear' },
allRoles: { es: 'Todos los roles', en: 'All roles' },
columns: { es: 'Columnas', en: 'Columns' },
},
'number-field': {
role: { es: 'Campo numérico', en: 'Number field' },
increment: { es: 'Incrementar', en: 'Increment' },
decrement: { es: 'Decrementar', en: 'Decrement' },
},
'tags-input': {
remove: { es: 'Eliminar', en: 'Remove' },
clear: { es: 'Limpiar todo', en: 'Clear all' },
},
'tree-view': {
label: { es: 'Árbol', en: 'Tree' },
},
} satisfies LingNode;
extendSomaTranslationModule(ling, somaModule);
// ── Translator adapter ───────────────────────────────────────────────────
const translator = createSomaTranslator({
locale: () => activeLocale,
translate: () => ling.ts.bind(ling),
translatePath: () => ling.t.bind(ling) as any,
runtime: () => ling,
});
// ── Presentation ─────────────────────────────────────────────────────────
let activeDir = $state<'ltr' | 'rtl'>('ltr');
let activeLocale = $state<'es' | 'en'>('es');
const presentation: SomaPresentationConfig = {
get dir() { return activeDir; },
};
// ── Locale switcher ──────────────────────────────────────────────────────
function setLocale(locale: 'es' | 'en') {
ling.setLocale(locale);
activeLocale = locale;
}
function setDir(dir: 'ltr' | 'rtl') {
activeDir = dir;
}
</script>
<Soma {translator} {presentation}>
<div class="soma-test-controls">
<span class="label">Locale:</span>
<button class:active={activeLocale === 'es'} onclick={() => setLocale('es')}>ES</button>
<button class:active={activeLocale === 'en'} onclick={() => setLocale('en')}>EN</button>
<span class="sep">|</span>
<span class="label">Dir:</span>
<button class:active={activeDir === 'ltr'} onclick={() => setDir('ltr')}>LTR</button>
<button class:active={activeDir === 'rtl'} onclick={() => setDir('rtl')}>RTL</button>
<span class="sep">|</span>
<span class="info">locale: {activeLocale} · dir: {activeDir}</span>
</div>
{@render children()}
</Soma>
<style>
.soma-test-controls {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
background: #1e293b;
color: #e2e8f0;
font-size: 0.8rem;
font-family: system-ui, sans-serif;
}
.soma-test-controls .label {
font-weight: 600;
color: #94a3b8;
}
.soma-test-controls .sep {
color: #475569;
margin: 0 0.2rem;
}
.soma-test-controls .info {
color: #64748b;
font-family: monospace;
font-size: 0.75rem;
}
.soma-test-controls button {
padding: 0.2rem 0.5rem;
border: 1px solid #475569;
border-radius: 3px;
background: transparent;
color: #e2e8f0;
cursor: pointer;
font-size: 0.75rem;
}
.soma-test-controls button:hover {
background: #334155;
}
.soma-test-controls button.active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
</style>

@ -31,6 +31,7 @@
<h2>Data Display</h2>
<ul>
<li><a href="/test/soma/tree-view">TreeView</a></li>
<li><a href="/test/soma/table">Table</a></li>
</ul>
<h2>Controls</h2>
@ -58,6 +59,7 @@
<li><a href="/test/soma/select">Select</a></li>
<li><a href="/test/soma/combobox">Combobox</a></li>
<li><a href="/test/soma/slider">Slider</a></li>
<li><a href="/test/soma/number-field">NumberField</a></li>
</ul>
</nav>
</div>

@ -1,102 +1,35 @@
<script lang="ts">
import * as Dialog from '$soma/components/dialog';
import Portal from '$soma/components/internal/portal.svelte';
import { Soma as SomaRoot } from '$soma/components/internal';
import FormatConsumer from './format-consumer.svelte';
import type { SomaTranslator, SomaFormat, SomaLogger, SomaNumberFormat, SomaCurrencyFormat } from '$soma/types';
let open1 = $state(false);
let open2 = $state(false);
let open3 = $state(false);
let open4 = $state(false);
let open5alert = $state(false);
let open5scroll = $state(false);
let open6 = $state(false);
let open6nested = $state(false);
let open7 = $state(false);
let animEvent = $state('');
let logMessages = $state<string[]>([]);
let activeLang = $state<'none' | 'es' | 'en'>('none');
// ── Test translator ──────────────────────────────────────────────────────
const translations: Record<string, Record<string, string>> = {
es: {
'dialog.trigger': 'Abrir diálogo',
'dialog.close': 'Cerrar',
'dialog.content.roledescription': 'ventana de diálogo',
},
en: {
'dialog.trigger': 'Open dialog',
'dialog.close': 'Close',
'dialog.content.roledescription': 'dialog window',
},
};
function createTranslator(lang: string): SomaTranslator {
const dict = translations[lang] ?? {};
return {
getLocale: () => lang,
translate: (value) => (typeof value === 'string' ? dict[value] ?? value : value.fallback ?? ''),
translatePath: (path) => dict[path] ?? '',
};
}
// ── Test logger ──────────────────────────────────────────────────────────
const testLogger: SomaLogger = {
debug: (msg, ctx) => logMessages = [...logMessages, `[debug] ${msg} ${ctx ? JSON.stringify(ctx) : ''}`],
info: (msg, ctx) => logMessages = [...logMessages, `[info] ${msg} ${ctx ? JSON.stringify(ctx) : ''}`],
warn: (msg, ctx) => logMessages = [...logMessages, `[warn] ${msg} ${ctx ? JSON.stringify(ctx) : ''}`],
error: (msg, ctx) => logMessages = [...logMessages, `[error] ${msg} ${ctx ? JSON.stringify(ctx) : ''}`],
};
// ── Test format (implementations of SomaNumberFormat / SomaCurrencyFormat) ─
const esNumberFormat: SomaNumberFormat = {
format: (value, opts) => value.toLocaleString('es-ES', opts),
};
const esCurrencyFormat: SomaCurrencyFormat = {
format: (value, currency, opts) =>
value.toLocaleString('es-ES', { style: 'currency', currency, ...opts }),
};
const testFormat: SomaFormat = {
locale: 'es-ES',
number: esNumberFormat,
currency: esCurrencyFormat,
};
// ── Reactive config ──────────────────────────────────────────────────────
let translator = $derived(activeLang !== 'none' ? createTranslator(activeLang) : undefined);
let open3nested = $state(false);
</script>
<svelte:head>
<title>Dialog · Services · Soma</title>
<title>Dialog · Soma</title>
</svelte:head>
<div class="page">
<h1>Soma Dialog — Services Test</h1>
<p>Validates translator, logger, and format integration via SomaRoot.</p>
<h1>Soma Dialog</h1>
<p>Translator and dir come from the layout. Switch language/dir in the top bar.</p>
<!-- ── 1. Sin SomaRoot (fallbacks) ─────────────────────────────────── -->
<!-- ── 1. Basic ────────────────────────────────────────────────────────── -->
<section>
<h2>1. Without SomaRoot (fallbacks)</h2>
<p>aria-label uses hardcoded fallbacks. Inspect the trigger and close buttons.</p>
<h2>1. Basic dialog</h2>
<p class="desc">Inspect aria-label on trigger and close — should translate with locale.</p>
<Dialog.Provider bind:open={open1}>
<Dialog.Trigger>Open (no config)</Dialog.Trigger>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>Fallback Dialog</Dialog.Title>
<Dialog.Title>Basic Dialog</Dialog.Title>
<Dialog.Description>
No SomaRoot ancestor — aria-labels use English fallbacks.
The aria-label on the close button should change when you switch locale in the top bar.
</Dialog.Description>
<div class="inspect">
<code>Trigger aria-label: "Open dialog"</code>
<code>Close aria-label: "Close"</code>
<code>Content aria-roledescription: (none)</code>
</div>
<div class="actions">
<Dialog.Close>Close</Dialog.Close>
</div>
@ -105,240 +38,56 @@
</Dialog.Provider>
</section>
<!-- ── 2. Con SomaRoot + translator ─────────────────────────────────── -->
<!-- ── 2. Alert dialog ─────────────────────────────────────────────────── -->
<section>
<h2>2. With SomaRoot + Translator</h2>
<p>Switch language and open the dialog — aria-labels update reactively.</p>
<h2>2. Alert dialog</h2>
<div class="controls">
<button class:active={activeLang === 'none'} onclick={() => (activeLang = 'none')}>
No translator
</button>
<button class:active={activeLang === 'es'} onclick={() => (activeLang = 'es')}>
Español
</button>
<button class:active={activeLang === 'en'} onclick={() => (activeLang = 'en')}>
English
</button>
</div>
<SomaRoot {translator} dir={activeLang === 'es' ? 'ltr' : 'ltr'}>
<Dialog.Provider bind:open={open2}>
<Dialog.Trigger>
Open ({activeLang === 'none' ? 'fallback' : activeLang})
</Dialog.Trigger>
<Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>Translated Dialog</Dialog.Title>
<Dialog.Description>
{#if activeLang === 'none'}
No translator — fallbacks active.
{:else if activeLang === 'es'}
Traductor español activo. Inspecciona aria-label del trigger y close.
{:else}
English translator active. Inspect trigger and close aria-labels.
{/if}
</Dialog.Description>
<div class="inspect">
<code>Trigger → {activeLang !== 'none' ? translations[activeLang]?.['dialog.trigger'] : 'Open dialog (fallback)'}</code>
<code>Close → {activeLang !== 'none' ? translations[activeLang]?.['dialog.close'] : 'Close (fallback)'}</code>
<code>Roledescription → {activeLang !== 'none' ? translations[activeLang]?.['dialog.content.roledescription'] : '(none)'}</code>
</div>
<div class="actions">
<Dialog.Close>
{activeLang === 'es' ? 'Cerrar' : 'Close'}
</Dialog.Close>
</div>
</Dialog.Content>
</Portal>
</Dialog.Provider>
</SomaRoot>
</section>
<!-- ── 3. Con SomaRoot + format ─────────────────────────────────────── -->
<section>
<h2>3. With SomaRoot + Format</h2>
<p>FormatConsumer reads <code>getSoma().format</code> from context — no direct Intl access.</p>
<SomaRoot format={testFormat}>
<FormatConsumer />
<Dialog.Provider bind:open={open3}>
<Dialog.Trigger>Open (formatted)</Dialog.Trigger>
<Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>Format in Dialog</Dialog.Title>
<Dialog.Description>
This component also reads format from SomaRoot context.
</Dialog.Description>
<FormatConsumer />
<div class="actions">
<Dialog.Close>Close</Dialog.Close>
</div>
</Dialog.Content>
</Portal>
</Dialog.Provider>
</SomaRoot>
</section>
<!-- ── 4. Con SomaRoot + logger ─────────────────────────────────────── -->
<section>
<h2>4. With SomaRoot + Logger</h2>
<p>Logger captures events. Open/close the dialog and watch the log.</p>
<SomaRoot logger={testLogger}>
<Dialog.Provider
bind:open={open4}
onOpenChange={(v) => testLogger.info('dialog.openChange', { open: v })}
>
<Dialog.Trigger
onclick={() => testLogger.debug('dialog.trigger.click')}
>
Open (logged)
</Dialog.Trigger>
<Portal>
<Dialog.Overlay />
<Dialog.Content
onEscapeKeydown={() => testLogger.warn('dialog.escapeKeydown')}
onInteractOutside={() => testLogger.warn('dialog.interactOutside')}
>
<Dialog.Title>Logger Demo</Dialog.Title>
<Dialog.Description>
Close via Escape or click outside — events appear in the log below.
</Dialog.Description>
<div class="actions">
<Dialog.Close onclick={() => testLogger.debug('dialog.close.click')}>
Close
</Dialog.Close>
</div>
</Dialog.Content>
</Portal>
</Dialog.Provider>
</SomaRoot>
{#if logMessages.length > 0}
<div class="log">
<div class="log-header">
<strong>Log ({logMessages.length})</strong>
<button onclick={() => (logMessages = [])}>Clear</button>
</div>
<pre>{logMessages.join('\n')}</pre>
</div>
{/if}
</section>
<!-- ── 5. AlertDialog variant ─────────────────────────────────────────── -->
<section>
<h2>5. AlertDialog variant</h2>
<p>
<code>variant="alertdialog"</code> — Escape does NOT close, click outside does NOT close.
Only the explicit buttons can dismiss. Inspect: <code>role="alertdialog"</code>.
</p>
<Dialog.Provider variant="alertdialog" bind:open={open5alert}>
<Dialog.Trigger>Delete account</Dialog.Trigger>
<Dialog.Provider bind:open={open2} variant="alertdialog">
<Dialog.Trigger>Open alert</Dialog.Trigger>
<Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>Are you sure?</Dialog.Title>
<Dialog.Title>Confirm Action</Dialog.Title>
<Dialog.Description>
This action cannot be undone. Press Escape or click outside — nothing happens.
You must use the buttons below.
This uses role="alertdialog". Cannot dismiss by clicking outside.
</Dialog.Description>
<div class="actions">
<Dialog.Close>Cancel</Dialog.Close>
<button
class="danger"
onclick={() => { open5alert = false; }}
onclick={() => {
open2 = false;
}}>Confirm</button
>
Delete
</button>
</div>
</Dialog.Content>
</Portal>
</Dialog.Provider>
</section>
<!-- ── 6. restoreScrollDelay ──────────────────────────────────────────── -->
<section>
<h2>6. restoreScrollDelay</h2>
<p>
Scroll down first to get a scroll position, then open the dialog.
On close, <code>restoreScrollDelay={200}</code> waits 200ms before restoring scroll —
the exit animation finishes without a scroll jump.
</p>
<div class="scroll-spacer">
{#each Array(20) as _, i}
<p class="filler">Line {i + 1} — scroll content to create a scroll position before opening the dialog.</p>
{/each}
</div>
<Dialog.Provider bind:open={open5scroll}>
<Dialog.Trigger>Open (with 200ms scroll delay)</Dialog.Trigger>
<Portal>
<Dialog.Overlay forceMount />
<Dialog.Content forceMount restoreScrollDelay={200}>
<Dialog.Title>Scroll Delay Demo</Dialog.Title>
<Dialog.Description>
Close this dialog. The body scroll position restores after 200ms,
not immediately. The exit animation plays without the page jumping.
</Dialog.Description>
<div class="actions">
<Dialog.Close>Close</Dialog.Close>
</div>
</Dialog.Content>
</Portal>
</Dialog.Provider>
</section>
<!-- ── 7. Nested dialogs ──────────────────────────────────────────────── -->
<!-- ── 3. Nested ───────────────────────────────────────────────────────── -->
<section>
<h2>7. Nested Dialogs</h2>
<p>
Open the outer dialog, then open the inner one. Inspect DOM to see
<code>data-nested</code>, <code>data-nested-open</code>,
<code>--soma-dialog-depth</code>, <code>--soma-dialog-nested-count</code>.
</p>
<h2>3. Nested dialogs</h2>
<Dialog.Provider bind:open={open6}>
<Dialog.Trigger>Open outer (depth 0)</Dialog.Trigger>
<Dialog.Provider bind:open={open3}>
<Dialog.Trigger>Open outer</Dialog.Trigger>
<Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>Outer Dialog (depth 0)</Dialog.Title>
<Dialog.Description>
This is the outer dialog. Open the nested one to see nesting attrs.
</Dialog.Description>
<div class="nested-info">
<code>open6: {open6}</code>
<code>open6nested: {open6nested}</code>
</div>
<Dialog.Title>Outer Dialog</Dialog.Title>
<Dialog.Description>Open a nested dialog inside.</Dialog.Description>
<!-- Nested dialog -->
<Dialog.Provider bind:open={open6nested}>
<Dialog.Trigger>Open inner (depth 1)</Dialog.Trigger>
<Dialog.Provider bind:open={open3nested}>
<Dialog.Trigger>Open nested</Dialog.Trigger>
<Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>Inner Dialog (depth 1)</Dialog.Title>
<Dialog.Description>
This is nested. Inspect the outer's Content — it should have
<code>data-nested-open</code> and
<code>--soma-dialog-nested-count: 1</code>.
</Dialog.Description>
<Dialog.Title>Nested Dialog</Dialog.Title>
<Dialog.Description>This is inside the outer dialog.</Dialog.Description>
<div class="actions">
<Dialog.Close>Close inner</Dialog.Close>
<Dialog.Close>Close nested</Dialog.Close>
</div>
</Dialog.Content>
</Portal>
@ -351,293 +100,77 @@
</Portal>
</Dialog.Provider>
</section>
<!-- ── 8. Animations (CSS transitions) ────────────────────────────────── -->
<section>
<h2>8. Animations (CSS transitions)</h2>
<p>
Uses <code>data-starting-style</code> / <code>data-ending-style</code> for CSS transitions.
<code>forceMount</code> keeps elements in DOM. <code>onOpenChangeComplete</code> fires after
animation finishes.
</p>
<p class="anim-event">Last animation event: <strong>{animEvent || '(none)'}</strong></p>
<Dialog.Provider
bind:open={open7}
onOpenChangeComplete={(v) => (animEvent = `onOpenChangeComplete(${v}) at ${new Date().toLocaleTimeString()}`)}
>
<Dialog.Trigger>Open animated dialog</Dialog.Trigger>
<Portal>
<Dialog.Overlay forceMount />
<Dialog.Content forceMount>
<Dialog.Title>Animated Dialog</Dialog.Title>
<Dialog.Description>
This dialog uses CSS transitions via data attrs. Inspect the element to see
<code>data-starting-style</code> flash on open and <code>data-ending-style</code>
persist during close.
</Dialog.Description>
<div class="actions">
<Dialog.Close>Close</Dialog.Close>
</div>
</Dialog.Content>
</Portal>
</Dialog.Provider>
</section>
<!-- ── 9. Data attributes reference ───────────────────────────────────── -->
<section>
<h2>9. Data attributes (inspect DOM)</h2>
<pre>Trigger: [data-dialog-trigger] [aria-label] [aria-haspopup=dialog]
Content: [data-dialog-content] [role=dialog] [data-state]
[data-starting-style] [data-ending-style]
[data-nested] [data-nested-open]
--soma-dialog-depth --soma-dialog-nested-count
Overlay: [data-dialog-overlay] [data-state]
[data-starting-style] [data-ending-style]
[data-nested] [data-nested-open]
--soma-dialog-depth --soma-dialog-nested-count
Title: [data-dialog-title] [role=heading] [aria-level]
Description: [data-dialog-description]
Close: [data-dialog-close] [aria-label]</pre>
</section>
</div>
<style>
.page {
max-width: 700px;
max-width: 640px;
margin: 2rem auto;
font-family: system-ui, sans-serif;
}
h1 { margin-bottom: 0.5rem; }
h2 { margin-top: 2rem; margin-bottom: 0.5rem; }
p { color: #555; line-height: 1.6; margin-bottom: 1rem; }
section {
padding: 1.5rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 1rem;
}
pre {
font-size: 0.8rem;
background: #f8fafc;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 0;
}
code {
display: block;
font-size: 0.8rem;
color: #475569;
padding: 0.25rem 0;
}
.controls {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
h1 {
margin-bottom: 0.25rem;
}
.controls button {
padding: 0.4rem 0.8rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: white;
font: inherit;
font-size: 0.85rem;
cursor: pointer;
h2 {
margin-top: 2rem;
margin-bottom: 0.5rem;
}
.controls button.active {
background: #1e293b;
color: white;
border-color: #1e293b;
p {
color: #555;
}
.inspect {
background: #f8fafc;
padding: 0.75rem 1rem;
border-radius: 6px;
margin: 1rem 0;
border: 1px dashed #cbd5e1;
.desc {
font-size: 0.9rem;
}
.log {
margin-top: 1rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
overflow: hidden;
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.log-header button {
padding: 0.2rem 0.5rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: white;
font: inherit;
font-size: 0.75rem;
cursor: pointer;
}
.log pre {
max-height: 200px;
overflow-y: auto;
border-radius: 0;
section {
margin-bottom: 2rem;
}
.actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
justify-content: flex-end;
}
/* Dialog styles (via data-* selectors) */
:global([data-dialog-trigger]) {
padding: 0.75rem 1.5rem;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #f8fafc;
font: inherit;
font-weight: 600;
cursor: pointer;
}
:global([data-dialog-trigger]:hover) {
background: #f1f5f9;
border-color: #94a3b8;
}
.scroll-spacer {
max-height: 150px;
overflow-y: auto;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 0.5rem 1rem;
margin-bottom: 1rem;
background: #f8fafc;
}
.filler {
margin: 0;
padding: 0.25rem 0;
color: #94a3b8;
font-size: 0.8rem;
border-bottom: 1px solid #f1f5f9;
}
.nested-info {
background: #f0fdf4;
padding: 0.75rem 1rem;
border-radius: 6px;
margin: 1rem 0;
border: 1px dashed #86efac;
}
.anim-event {
font-size: 0.85rem;
color: #1e293b;
}
/* ── Overlay ─────────────────────────────────────────────────────── */
:global([data-dialog-overlay]) {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 50;
transition: opacity 200ms ease;
}
:global([data-dialog-overlay][data-state='closed']:not([data-starting-style]):not([data-ending-style])) {
display: none;
}
:global([data-dialog-overlay][data-starting-style]) {
opacity: 0;
}
:global([data-dialog-overlay][data-ending-style]) {
opacity: 0;
}
/* Dim outer overlay when nested dialog is open */
:global([data-dialog-overlay][data-nested-open]) {
opacity: 0.3;
}
/* ── Content ─────────────────────────────────────────────────────── */
:global([data-dialog-content]) {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 12px;
padding: 1.5rem;
min-width: 420px;
max-width: 90vw;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
max-width: 420px;
width: 90vw;
z-index: 51;
transition: opacity 200ms ease, transform 200ms ease;
}
:global([data-dialog-content][data-state='closed']:not([data-starting-style]):not([data-ending-style])) {
display: none;
}
:global([data-dialog-content][data-starting-style]) {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
}
:global([data-dialog-content][data-ending-style]) {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
}
/* Scale down outer content when nested dialog is open */
:global([data-dialog-content][data-nested-open]) {
transform: translate(-50%, -50%) scale(0.97);
filter: brightness(0.95);
}
:global([data-dialog-title]) {
font-size: 1.25rem;
font-weight: 700;
margin: 0 0 0.5rem;
}
:global([data-dialog-description]) {
color: #64748b;
line-height: 1.5;
margin: 0;
}
:global([data-dialog-trigger]),
:global([data-dialog-close]) {
padding: 0.5rem 1rem;
border: 1px solid #cbd5e1;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: white;
font: inherit;
cursor: pointer;
}
:global([data-dialog-trigger]:hover),
:global([data-dialog-close]:hover) {
background: #f1f5f9;
background: #f8fafc;
border-color: #94a3b8;
}
/* AlertDialog danger button */
.danger {
padding: 0.5rem 1rem;
border: 1px solid #ef4444;
border-radius: 6px;
background: #ef4444;
color: white;
font: inherit;
:global([data-dialog-title]) {
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
margin-bottom: 0.5rem;
}
.danger:hover {
background: #dc2626;
:global([data-dialog-description]) {
color: #64748b;
font-size: 0.9rem;
}
</style>

@ -1,21 +1,21 @@
<script lang="ts">
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
const soma = Soma.get();
const config = getSoma();
const numberFormatted = $derived(
soma.format.current?.number?.format(1234567.89) ?? '(no number formatter)',
config.presentation.current?.numberFormatter?.format(1234567.89) ?? '(no number formatter)',
);
const currencyEUR = $derived(
soma.format.current?.currency?.format(1234.5, 'EUR') ?? '(no currency formatter)',
config.presentation.current?.currencyFormatter?.format(1234.5, 'EUR') ?? '(no currency formatter)',
);
const currencyUSD = $derived(
soma.format.current?.currency?.format(999.99, 'USD') ?? '(no currency formatter)',
config.presentation.current?.currencyFormatter?.format(999.99, 'USD') ?? '(no currency formatter)',
);
const locale = $derived(soma.format.current?.locale ?? '(no format config)');
const locale = $derived(config.translator.current.getLocale());
</script>
<p class="locale">Locale from config: <strong>{locale}</strong></p>
<p class="locale">Locale from translator: <strong>{locale}</strong></p>
<div class="format-demo">
<div class="format-row">
<span class="label">Number (1234567.89):</span>

@ -0,0 +1,162 @@
<script lang="ts">
import * as NumberField from '$soma/components/number-field';
let quantity = $state<number | undefined>(5);
let price = $state<number | undefined>(29.99);
let percent = $state<number | undefined>(0.75);
let scrubValue = $state<number | undefined>(50);
</script>
<svelte:head>
<title>NumberField · Soma</title>
</svelte:head>
<div class="page">
<h1>Soma NumberField</h1>
<p>Numeric input with increment/decrement, keyboard, scrubber, locale-aware formatting via createNumr.</p>
<!-- ── 1. Basic ───────────────────────────────────────────────────────────── -->
<section>
<h2>1. Basic (min 0, max 99, step 1)</h2>
<p class="state">value: {quantity}</p>
<NumberField.Provider bind:value={quantity} min={0} max={99}>
<div class="field">
<NumberField.DecrementTrigger>-</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</div>
</NumberField.Provider>
</section>
<!-- ── 2. Currency ────────────────────────────────────────────────────────── -->
<section>
<h2>2. Currency (EUR, step 0.01)</h2>
<p class="state">value: {price}</p>
<NumberField.Provider
bind:value={price}
min={0}
step={0.01}
largeStep={1}
formatOptions={{ style: 'currency', currency: 'EUR', minDecimals: 2 }}
>
<div class="field">
<NumberField.DecrementTrigger>-</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</div>
</NumberField.Provider>
</section>
<!-- ── 3. Percentage ──────────────────────────────────────────────────────── -->
<section>
<h2>3. Percentage (0-1 internal, displays as 0%-100%)</h2>
<p class="desc">Internal value 0.75 is edited as 75 and displayed as 75%.</p>
<p class="state">value: {percent}</p>
<NumberField.Provider
bind:value={percent}
min={0}
max={1}
step={0.01}
formatOptions={{ style: 'percent' }}
>
<div class="field">
<NumberField.DecrementTrigger>-</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</div>
</NumberField.Provider>
</section>
<!-- ── 4. Scrubber ────────────────────────────────────────────────────────── -->
<section>
<h2>4. With scrubber (drag to adjust)</h2>
<p class="desc">Drag the label left/right to change the value.</p>
<p class="state">value: {scrubValue}</p>
<NumberField.Provider bind:value={scrubValue} min={0} max={100} allowMouseWheel>
<div class="field-with-scrubber">
<NumberField.Scrubber>Opacity</NumberField.Scrubber>
<div class="field">
<NumberField.DecrementTrigger>-</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</div>
</div>
</NumberField.Provider>
</section>
</div>
<style>
.page {
max-width: 640px;
margin: 2rem auto;
font-family: system-ui, sans-serif;
}
h1 { margin-bottom: 0.25rem; }
h2 { margin-top: 2rem; margin-bottom: 0.5rem; }
p { color: #555; }
.state { font-family: monospace; font-size: 0.85rem; color: #64748b; }
.desc { font-size: 0.9rem; margin-bottom: 0.25rem; }
section { margin-bottom: 2rem; }
.field {
display: inline-flex;
align-items: center;
border: 1px solid #e2e8f0;
border-radius: 6px;
overflow: hidden;
}
:global([data-number-field-input]) {
width: 120px;
padding: 0.5rem;
border: none;
text-align: center;
font: inherit;
font-size: 0.95rem;
outline: none;
}
:global([data-number-field-input]:focus) {
background: #f8fafc;
}
:global([data-number-field-increment-trigger]),
:global([data-number-field-decrement-trigger]) {
padding: 0.5rem 0.75rem;
border: none;
background: #f1f5f9;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
color: #475569;
line-height: 1;
}
:global([data-number-field-increment-trigger]:hover),
:global([data-number-field-decrement-trigger]:hover) {
background: #e2e8f0;
}
:global([data-number-field-increment-trigger][disabled]),
:global([data-number-field-decrement-trigger][disabled]) {
opacity: 0.3;
cursor: not-allowed;
}
.field-with-scrubber {
display: flex;
align-items: center;
gap: 0.75rem;
}
:global([data-number-field-scrubber]) {
font-weight: 500;
font-size: 0.9rem;
color: #475569;
padding: 0.3rem 0;
}
:global([data-number-field-scrubber][data-scrubbing]) {
color: #3b82f6;
}
</style>

@ -0,0 +1,288 @@
<script lang="ts">
import * as Table from '$soma/components/table';
import * as Pagination from '$soma/components/pagination';
import { createTable, type ColumnDef } from '$soma/components/table';
import { getSoma } from '$soma/core/soma.svelte';
const soma = getSoma();
function tt(path: string, fallback: string): string {
return soma.translator.current.translatePath(`soma.table.${path}`) || fallback;
}
interface Person {
name: string;
age: number;
email: string;
role: string;
}
const data: Person[] = [
{ name: 'Alice Johnson', age: 32, email: 'alice@example.com', role: 'Engineer' },
{ name: 'Bob Smith', age: 45, email: 'bob@example.com', role: 'Manager' },
{ name: 'Carol White', age: 28, email: 'carol@example.com', role: 'Designer' },
{ name: 'David Brown', age: 38, email: 'david@example.com', role: 'Engineer' },
{ name: 'Eve Davis', age: 29, email: 'eve@example.com', role: 'Designer' },
{ name: 'Frank Miller', age: 52, email: 'frank@example.com', role: 'Director' },
{ name: 'Grace Lee', age: 35, email: 'grace@example.com', role: 'Engineer' },
{ name: 'Henry Wilson', age: 41, email: 'henry@example.com', role: 'Manager' },
{ name: 'Ivy Chen', age: 26, email: 'ivy@example.com', role: 'Designer' },
{ name: 'Jack Taylor', age: 33, email: 'jack@example.com', role: 'Engineer' },
{ name: 'Kate Moore', age: 47, email: 'kate@example.com', role: 'Director' },
{ name: 'Leo Garcia', age: 30, email: 'leo@example.com', role: 'Engineer' },
];
const columns: ColumnDef<Person>[] = [
{ accessorKey: 'name', header: 'Name', enableSorting: true, enableFiltering: true },
{ accessorKey: 'age', header: 'Age', enableSorting: true, enableFiltering: true },
{ accessorKey: 'email', header: 'Email', enableFiltering: true },
{ accessorKey: 'role', header: 'Role', enableSorting: true, enableFiltering: true },
];
const table1 = createTable({
data: () => data,
columns,
sorting: { enabled: true, multi: true },
selection: { mode: 'multiple' },
pagination: { enabled: true, pageSize: 5 },
filtering: { enabled: true },
});
// Sync pagination: table state ↔ Pagination component
let currentPage = $state(1);
$effect(() => {
currentPage = table1.pagination.pageIndex + 1;
});
function onPageChange(page: number) {
table1.setPageIndex(page - 1);
}
</script>
<svelte:head>
<title>Table · Soma</title>
</svelte:head>
<div class="page">
<h1>Soma Table</h1>
<p>Headless table with sorting, selection, pagination, filtering, column visibility, ARIA grid, Soma i18n/dir.</p>
<section>
<h2>Table</h2>
<!-- Toolbar -->
<div class="toolbar">
<div class="filter-group">
<label for="global-filter">Global filter:</label>
<input
id="global-filter"
type="text"
placeholder={tt('globalFilter', 'Search all columns...')}
value={table1.globalFilter}
oninput={(e) => table1.setGlobalFilter((e.currentTarget as HTMLInputElement).value)}
/>
{#if table1.globalFilter}
<button onclick={() => table1.setGlobalFilter('')}>{tt('clear', 'Clear')}</button>
{/if}
</div>
<div class="filter-group">
<label for="role-filter">Role filter:</label>
<select id="role-filter" onchange={(e) => {
const v = (e.currentTarget as HTMLSelectElement).value;
if (v) table1.setColumnFilter('role', v);
else table1.removeColumnFilter('role');
}}>
<option value="">{tt('allRoles', 'All roles')}</option>
<option value="Engineer">Engineer</option>
<option value="Manager">Manager</option>
<option value="Designer">Designer</option>
<option value="Director">Director</option>
</select>
</div>
<div class="filter-group">
<span>{tt('columns', 'Columns')}:</span>
{#each table1.columns as col}
<label class="col-toggle">
<input
type="checkbox"
checked={table1.getIsColumnVisible(col.id)}
onchange={() => table1.toggleColumnVisibility(col.id)}
/>
{col.def.header ?? col.id}
</label>
{/each}
</div>
</div>
<!-- State -->
<p class="state">
sorting: {JSON.stringify(table1.sorting)} |
selected: {Object.keys(table1.selection).filter(k => table1.selection[k]).length} rows |
page: {currentPage}/{table1.pageCount} |
filters: {JSON.stringify(table1.columnFilters)} |
global: "{table1.globalFilter}"
</p>
<!-- Table -->
<Table.Provider table={table1}>
<table class="demo-table" role="grid" aria-label="Team members">
<thead>
<tr>
<th class="checkbox-col">
<input
type="checkbox"
checked={table1.getIsAllSelected()}
indeterminate={table1.getIsSomeSelected()}
onchange={() => table1.toggleAllSelection()}
/>
</th>
{#each table1.headers as header}
<Table.ColumnHeader {header}>
{header.label}
{#if table1.getIsSorted(header.id) === 'asc'}
▲
{:else if table1.getIsSorted(header.id) === 'desc'}
▼
{/if}
</Table.ColumnHeader>
{/each}
</tr>
</thead>
<tbody>
{#each table1.rows as row (row.id)}
<Table.Row {row}>
<td class="checkbox-col" onclick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={table1.getIsRowSelected(row.id)}
onchange={() => table1.toggleRowSelection(row.id)}
/>
</td>
{#each row.cells as cell, colIdx}
<Table.Cell {cell} colIndex={colIdx}>
{cell.value}
</Table.Cell>
{/each}
</Table.Row>
{/each}
{#if table1.rows.length === 0}
<tr><td colspan={table1.headers.length + 1} class="empty">{tt('noResults', 'No results')}</td></tr>
{/if}
</tbody>
</table>
</Table.Provider>
<!-- Pagination -->
<Pagination.Provider
count={table1.allRows.length}
bind:page={currentPage}
pageSize={table1.pagination.pageSize}
siblingCount={1}
onPageChange={onPageChange}
>
{#snippet child({ pages, totalPages })}
<nav class="pagination-controls">
<Pagination.PrevTrigger>
<span aria-hidden="true">←</span>
</Pagination.PrevTrigger>
{#each pages as pageItem}
{#if pageItem.type === 'page'}
<Pagination.Item value={pageItem.value}>
{pageItem.value}
</Pagination.Item>
{:else}
<Pagination.Ellipsis position={pageItem.position}>
…
</Pagination.Ellipsis>
{/if}
{/each}
<Pagination.NextTrigger>
<span aria-hidden="true">→</span>
</Pagination.NextTrigger>
<select class="page-size" onchange={(e) => table1.setPageSize(Number((e.currentTarget as HTMLSelectElement).value))}>
<option value="5">5 {tt('perPage', 'per page')}</option>
<option value="10">10 {tt('perPage', 'per page')}</option>
<option value="25">25 {tt('perPage', 'per page')}</option>
</select>
</nav>
{/snippet}
</Pagination.Provider>
</section>
</div>
<style>
.page {
max-width: 860px;
margin: 2rem auto;
font-family: system-ui, sans-serif;
}
h1 { margin-bottom: 0.25rem; }
h2 { margin-top: 1.5rem; margin-bottom: 0.5rem; }
p { color: #555; }
.state { font-family: monospace; font-size: 0.75rem; color: #64748b; word-break: break-all; margin-bottom: 0.75rem; }
section { margin-bottom: 2rem; }
/* Toolbar */
.toolbar {
display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: 0.75rem;
padding: 0.75rem; border: 1px solid #e2e8f0; border-radius: 8px; background: #fafafa;
}
.filter-group { display: flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; }
.filter-group label, .filter-group span { font-weight: 500; color: #475569; }
.filter-group input[type="text"] {
padding: 0.3rem 0.5rem; border: 1px solid #e2e8f0; border-radius: 4px; font-size: 0.85rem; width: 160px;
}
.filter-group select {
padding: 0.3rem; border: 1px solid #e2e8f0; border-radius: 4px; font-size: 0.85rem;
}
.filter-group button {
padding: 0.2rem 0.5rem; border: 1px solid #e2e8f0; border-radius: 4px;
background: white; cursor: pointer; font-size: 0.8rem;
}
.col-toggle { display: flex; align-items: center; gap: 2px; font-weight: 400; cursor: pointer; }
/* Table */
.demo-table {
width: 100%; border-collapse: collapse; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden;
}
.demo-table :global(th),
.demo-table :global(td) {
padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid #e2e8f0; font-size: 0.9rem;
}
.demo-table :global(th) { background: #f8fafc; font-weight: 600; color: #475569; }
.demo-table :global([data-sortable]) { cursor: pointer; user-select: none; }
.demo-table :global([data-sortable]:hover) { background: #e2e8f0; }
.demo-table :global([data-sorted]) { color: #1e293b; }
.demo-table :global([data-selected]) { background: #eff6ff; }
.demo-table :global(tr:hover td) { background: #f8fafc; }
.demo-table :global([data-selected]:hover td) { background: #dbeafe; }
.checkbox-col { width: 2rem; text-align: center; }
.empty { text-align: center; color: #94a3b8; padding: 2rem !important; }
/* Pagination */
.pagination-controls {
display: flex; align-items: center; gap: 0.25rem; margin-top: 0.75rem; font-size: 0.85rem;
}
.pagination-controls :global([data-pagination-prev-trigger]),
.pagination-controls :global([data-pagination-next-trigger]) {
padding: 0.3rem 0.6rem; border: 1px solid #e2e8f0; border-radius: 4px; background: white; cursor: pointer;
}
.pagination-controls :global([data-pagination-item]) {
padding: 0.3rem 0.6rem; border: 1px solid #e2e8f0; border-radius: 4px; background: white; cursor: pointer;
min-width: 2rem; text-align: center;
}
.pagination-controls :global([data-pagination-item][data-selected]) {
background: #3b82f6; color: white; border-color: #3b82f6;
}
.pagination-controls :global([data-pagination-ellipsis]) {
padding: 0.3rem 0.4rem; color: #94a3b8;
}
.pagination-controls :global(button:hover:not(:disabled)) { background: #f8fafc; border-color: #94a3b8; }
.pagination-controls :global(button:disabled) { opacity: 0.4; cursor: not-allowed; }
.page-size { padding: 0.3rem; border: 1px solid #e2e8f0; border-radius: 4px; margin-left: auto; }
</style>

@ -7,6 +7,24 @@ export type {
StyleVariantOverrides
} from './types.js';
export type {
StylePropDef,
StyleSlotProps,
StyleAnatomy,
ComponentStyleDef,
AnatomySlots,
SlotProps,
SlotPropToken,
SlotPropType,
DefVariants,
DefColors,
DefSizes,
AnatomyTokenMap
} from './ComponentStyleDef.js';
export type { AnatomySlotProp, AnatomySlot, AnatomyData } from './anatomy.js';
export { inferAnatomy } from './anatomy.js';
import type { ComponentStyleSchema } from './types.js';
import { buttonStyles } from '$uix/air/components/button/styles.js';
import { badgeStyles } from '$uix/air/components/badge/styles.js';

@ -0,0 +1,531 @@
# Soma — Auditoria F1–F15
**Fecha:** 2026-04-14
**Ambito:** `src/uix/soma/` — 283 archivos, 26 componentes
**Tambien afecta:** `src/uix/terra/system/translator.svelte.ts`, `src/uix/terra/system/prop-resolvers.ts`
---
## Resumen
| # | Problema | Severidad | Estado |
| --- | --------------------------------------------------------------------------------------------------- | --------- | --------- |
| F1 | Typo `*tu ha` en JSDoc de Provider | baja | **Fixed** |
| F2 | Debug code en `soma.svelte.ts` (`debugSetSoma`, `debugGetSoma`, `console.log`) | media | **Fixed** |
| F3 | `SomaFormatter.format` es `(...args: unknown[]) => string` — sin type-safety | media | Pending |
| F4 | Translator duplica `interpolateTemplate` / `resolveRecordFallback` que ya existen en `lang/helpers` | alta | **Fixed** |
| F5 | `fallbackTranslator` hardcodea locale fijo (`'en'` en soma, `'es'` en terra) | alta | **Fixed** |
| F6 | `resolveRecordFallback` tiene fallback hardcodeado a un locale fijo (`record.en` / `record.es`) | alta | **Fixed** |
| F7 | `FloatingProvider` usa `document.querySelector` sin guard SSR | alta | **Fixed** |
| F8 | `resolveLocaleProp` hardcodea `'en'` como fallback final | alta | **Fixed** |
| F9 | `ScrollLock` tiene estado global sin mecanismo de reset para HMR/tests | media | **Fixed** |
| F10 | `resolveSomaTranslation` / `resolveTerraTranslation` hardcodean locale en fallback path | alta | **Fixed** |
| F11 | `normalizeLocale` duplica logica trivial, nombre poco descriptivo | media | **Fixed** |
| F12 | `getSoma()` crea instancias ocultas cuando no hay context | alta | Pending |
| F13 | `mergeProps` no maneja Symbol keys de `args[0]` explicitamente | alta | Pending |
| F14 | `Provider` no tiene `dispose()` para cleanup de efectos | media | Pending |
| F15 | `console.log` / `console.warn` en produccion | media | **Fixed** |
**Totales:** 11 fixed, 4 pending
---
## Detalle
---
### F1 — Typo en JSDoc
**Archivo:** `provider/provider.svelte.ts:21` (original)
Linea suelta `*tu ha` dentro del bloque JSDoc de la clase `Provider`.
```diff
* no aporta seguridad real — el tipado del context vive en su declaracion, no en la clase base.
- *tu ha
+ *
* ## Que resuelve
```
**Estado:** Fixed. Linea eliminada.
---
### F2 — Debug code en produccion
**Archivos:** `core/soma.svelte.ts`, `components/internal/soma.svelte`, `routes/test/soma/dialog/+page.svelte`
Funciones `debugSetSoma()` y `debugGetSoma()` usaban `setContext`/`getContext` de Svelte directamente (saltandose `runed` Context) para depurar si el context era visible. Ademas habia `console.log` en `Soma.create()` y `getSoma()`.
```ts
// Eliminado de soma.svelte.ts:
export function debugSetSoma(value: any) { ... }
export function debugGetSoma() { ... }
// Eliminado de soma.svelte.ts (Soma.create):
console.log('[Soma.create] set context, instance:', instance, 'result:', result);
// Eliminado de soma.svelte.ts (getSoma):
console.log('[getSoma] instance:', instance, 'found:', !!instance);
console.warn('[getSoma] FALLBACK — no Soma context found');
// Eliminado de soma.svelte:
debugSetSoma(instance);
// Eliminado de routes/test/soma/dialog/+page.svelte:
const rawCtx = debugGetSoma();
console.log('[dialog page] Soma via runed Context:', somaCtx);
console.log('[dialog page] Soma via raw Svelte getContext:', rawCtx);
```
**Estado:** Fixed.
---
### F3 — SomaFormatter sin type-safety
**Archivo:** `types/services.ts:33-35`
```ts
export type SomaFormatter = {
format: (...args: unknown[]) => string;
};
```
`SomaNumberFormatter`, `SomaCurrencyFormatter` y `SomaUnitFormatter` son aliases de este tipo. No hay forma de validar los argumentos en compile time.
```ts
currencyFormatter.format(42); // OK — pero que args acepta?
currencyFormatter.format('nonsense'); // Tambien compila — runtime error
```
**Propuesta:**
```ts
export type SomaFormatter<T extends unknown[] = unknown[]> = {
format: (...args: T) => string;
};
export type SomaNumberFormatter = SomaFormatter<
[value: number, options?: Intl.NumberFormatOptions]
>;
export type SomaCurrencyFormatter = SomaFormatter<
[value: number, currency: string, options?: Intl.NumberFormatOptions]
>;
export type SomaUnitFormatter = SomaFormatter<
[value: number, unit: string, options?: Intl.NumberFormatOptions]
>;
```
**Estado:** Pending. Requiere decision sobre la firma exacta de cada formatter.
---
### F4 — Duplicacion de helpers de traduccion
**Archivos:** `core/translator.svelte.ts`, `terra/system/translator.svelte.ts`, `lib/ext/lang/lib/helpers.ts`
`interpolateTemplate` era identica en tres sitios:
```ts
// lang/helpers.ts, soma/translator.svelte.ts, terra/translator.svelte.ts
function interpolateTemplate(text: string, params?: Record<string, any>): string {
if (!params) return text;
let result = text;
for (const [key, val] of Object.entries(params)) {
result = result.replaceAll(`{{${key}}}`, String(val));
}
return result;
}
```
`resolveRecordFallback` tambien se duplicaba, pero con firmas distintas (soma acepta locale opcional, lang usa `SupportedLocale[]` chain). Se mantiene en cada modulo.
**Fix:** Ambos translators ahora importan `interpolateTemplate` de `$lib/ext/lang/lib/helpers`.
```ts
// Antes (en ambos translators):
function interpolateTemplate(text: string, params?: ...) { ... }
// Despues:
import { interpolateTemplate } from '$lib/ext/lang/lib/helpers';
```
**Nota:** Esto rompe la regla de independencia declarada en `README.md` ("soma no importa de `$lib`"). Se decidio que importar un helper puro sin side-effects es aceptable — `interpolateTemplate` es una funcion pura sin dependencias de framework. Si se quiere mantener la independencia estricta, se puede mover el helper a un paquete compartido.
**Estado:** Fixed.
---
### F5 — fallbackTranslator hardcodea locale
**Archivos:** `core/translator.svelte.ts`, `terra/system/translator.svelte.ts`
```ts
// soma — antes:
export const fallbackTranslator: SomaTranslator = {
getLocale() { return 'en'; },
translate(value) {
return resolveTranslationDescriptor(value, 'en');
return resolveTranslationFallback(value, 'en') ?? ...
}
};
// terra — antes:
export const fallbackTranslator: TerraTranslator = {
getLocale() { return 'es'; },
translate(value) {
return resolveTranslationDescriptor(value, 'es');
return resolveTranslationFallback(value, 'es') ?? ...
}
};
```
Soma fallback a ingles, Terra fallback a espanol — inconsistencia. Ninguno deberia preferir un locale.
**Fix:** `getLocale()` retorna `''` (empty string). Las funciones internas ahora aceptan `locale?: string` — cuando es `undefined`, se salta la busqueda por locale y retorna el primer valor disponible del record.
```ts
// Despues (ambos):
export const fallbackTranslator = {
getLocale() {
return '';
},
translate(value) {
if (isTranslationDescriptor(value)) return resolveTranslationDescriptor(value);
return resolveTranslationFallback(value) ?? String(value ?? '');
},
translatePath(path) {
return path;
}
};
```
**Estado:** Fixed.
---
### F6 — resolveRecordFallback preferencia hardcodeada
**Archivos:** `core/translator.svelte.ts`, `terra/system/translator.svelte.ts`
```ts
// soma — antes:
const value = record[locale] ?? record[shortLocale] ?? record.en ?? Object.values(record)[0];
// terra — antes:
const value = record[locale] ?? record[shortLocale] ?? record.es ?? Object.values(record)[0];
```
Cuando el locale del usuario no esta en el record, se fallback a `record.en` o `record.es` respectivamente. Esto contradice que no debe haber locale hardcodeado.
**Fix:** La nueva implementacion busca primero por locale exacto, luego short locale, y finalmente toma el primer valor disponible:
```ts
function resolveRecordFallback(
record: Record<string, string>,
locale: string | undefined,
params?: SomaTranslationParams
): string | undefined {
if (locale) {
const short = toShortLocale(locale);
const value = record[locale] ?? record[short];
if (typeof value === 'string') return interpolateTemplate(value, params);
}
const first = Object.values(record)[0];
return typeof first === 'string' ? interpolateTemplate(first, params) : undefined;
}
```
**Estado:** Fixed.
---
### F7 — FloatingProvider sin guard SSR
**Archivo:** `layers/floating/floating.svelte.ts:58` (original)
```ts
// Antes:
this.anchorNode.current = document.querySelector(this.customAnchorNode.current);
```
En SSR, `document` no existe. Crashea durante server-side rendering.
```ts
// Despues:
const el =
typeof document !== 'undefined' ? document.querySelector(this.customAnchorNode.current) : null;
this.anchorNode.current = el;
```
**Estado:** Fixed.
---
### F8 — resolveLocaleProp hardcodea 'en'
**Archivos:** `core/prop-resolvers.ts`, `terra/system/prop-resolvers.ts`
```ts
// soma — antes:
export const resolveLocaleProp = createPropResolver(
(config) => config.translator.current.getLocale(),
'en' // ← hardcodeado
);
// terra — antes:
export const resolveLocaleProp = createPropResolver(
(config) => config.translator.current.getLocale(),
'en' // ← hardcodeado
);
```
Si no hay translator configurado, todos los formatters usan ingles.
```ts
// Despues (ambos):
export const resolveLocaleProp = createPropResolver((config) => {
const locale = config.translator.current.getLocale();
return locale || undefined;
}, '');
```
Usar `''` como fallback es correcto: los Intl APIs de JavaScript tratan `''` como "system default locale", que es mejor que forzar un locale especifico.
**Estado:** Fixed.
---
### F9 — ScrollLock sin reset global
**Archivo:** `layers/scroll-lock.svelte.ts`
El modulo tiene estado global mutable:
- `lockMap` — SvelteMap de locks activos
- `initialBodyStyle` — estilo original del body
- `stopTouchMoveListener` — cleanup de touch event
- `cleanupTimeoutId` — timeout de restauracion
- `isInCleanupTransition` — flag de transicion
- `cleanupScheduledAt` — timestamp del cleanup pendiente
- `watchInitialized` — flag para evitar doble inicializacion del watcher
Ninguno tenia mecanismo de reset. En tests, los locks de un test podian filtrar al siguiente. En HMR, los estilos del body podian quedarse en estado lockeado.
**Fix:** Agregado metodo estatico `ScrollLock.reset()`:
```ts
static reset() {
lockMap.clear();
cancelPendingCleanup();
resetBodyStyle();
initialBodyStyle = null;
isInCleanupTransition = false;
cleanupScheduledAt = null;
watchInitialized = false;
}
```
**Estado:** Fixed.
---
### F10 — resolveSomaTranslation / resolveTerraTranslation hardcodean locale
**Archivos:** `core/translator.svelte.ts`, `terra/system/translator.svelte.ts`
```ts
// soma — antes:
export function resolveSomaTranslation(value, translator, fallback) {
if (isTranslationDescriptor(value)) {
return resolveTranslationFallback(value.fallback, 'en', value.params) ?? ...;
}
return resolveTranslationFallback(value, 'en') ?? fallback;
}
// terra — antes:
export function resolveTerraTranslation(value, translator, fallback) {
if (isTranslationDescriptor(value)) {
return resolveTranslationFallback(value.fallback, 'es', value.params) ?? ...;
}
return resolveTranslationFallback(value, 'es') ?? fallback;
}
```
Cuando no hay translator disponible, se forzaba `'en'` o `'es'` como locale de fallback.
**Fix:** Se pasa `undefined` como locale, lo que activa la logica de "primer valor disponible" en `resolveRecordFallback`:
```ts
// Despues:
return resolveTranslationFallback(value.fallback, undefined, value.params) ?? descriptorFallback;
return resolveTranslationFallback(value) ?? fallback;
```
**Estado:** Fixed.
---
### F11 — normalizeLocale duplicado y mal nombrado
**Archivos:** `core/translator.svelte.ts`, `terra/system/translator.svelte.ts`
```ts
function normalizeLocale(locale: string): string {
return locale.toLowerCase().split('-')[0];
}
```
Misma funcion en ambos translators. El nombre `normalizeLocale` es ambiguo — no normaliza, extrae el codigo base del locale.
**Fix:** Renombrada a `toShortLocale` en ambos:
```ts
function toShortLocale(locale: string): string {
return locale.toLowerCase().split('-')[0];
}
```
**Estado:** Fixed.
---
### F12 — getSoma() crea instancias ocultas
**Archivo:** `core/soma.svelte.ts:51-55`
```ts
export function getSoma(): SomaResolvedStateProps {
const instance = Soma.getOr();
if (instance) return instance.opts;
return new Soma(null, {}).opts; // ← instancia fantasma
}
```
Cuando se llama fuera de un context de `<Soma>`, crea una instancia nueva que:
- No se registra en ningun context
- No se destruye nunca (sin `$effect` cleanup)
- Crea reactividad (`readableActive`) que el GC no puede limpiar
- Se llama desde `createPropResolver` → cada resolver invoca `getSoma()` en su creacion
**Opciones propuestas:**
1. **Retornar fallback frozen** — objeto estatico con `fallbackTranslator`, sin reactividad. Sin ruido.
2. **Warn + fallback** — `console.warn` una sola vez, luego fallback. Ayuda a detectar `<Soma>` faltante en dev.
3. **Throw en dev** — estricto, rompe si falta `<Soma>`. Ruidoso pero seguro.
**Estado:** Pending. Requiere decision del arquitecto.
---
### F13 — mergeProps pierde Symbol keys de args[0]
**Archivo:** `props/props.ts:60-97`
```ts
export function mergeProps<T extends Props[]>(...args: T): Props & { style?: string } {
const result: Props = { ...args[0] }; // spread copia Symbols ✓
for (let i = 1; i < args.length; i++) {
const props = args[i];
// ...merge string keys...
for (const key of Object.getOwnPropertySymbols(props)) {
(result as Record<symbol, unknown>)[key] = ...; // solo args[1+]
}
}
}
```
El spread `{ ...args[0] }` copia Symbol keys al resultado. El loop de symbols solo procesa `args[1+]`. Si un Symbol de `args[1]` colisiona con uno de `args[0]`, el de `args[1]` sobreescribe — correcto.
El bug sutil: el codigo funciona porque JS spread copia Symbols, pero la intencion no es obvia. Alguien podria "optimizar" eliminando el spread sin darse cuenta que pierde los Symbols de `args[0]`.
**Fix propuesto:** Hacer explicita la copia de Symbols de `args[0]`:
```ts
const result: Props = {};
// Copy string + symbol keys from first arg
for (const key of Reflect.ownKeys(args[0])) {
result[key as string] = args[0][key as string];
}
```
O mas simple: iterar `Reflect.ownKeys` en vez de separar `Object.keys` + `Object.getOwnPropertySymbols`.
**Estado:** Pending.
---
### F14 — Provider sin dispose()
**Archivo:** `provider/provider.svelte.ts`
La clase `Provider` no tiene metodo `dispose()`. Los `$effect` de subclases (scroll lock, presence, dismissal) se limpian via Svelte effect cleanup automatico, pero no hay punto de extension para cleanup manual.
**Impacto actual:** Bajo. Svelte limpia effects cuando se destruye el componente.
**Impacto futuro:**
- Tests que necesitan cleanup entre runs
- Providers con recursos externos (ResizeObserver, IntersectionObserver)
- Destruccion programatica de componentes
**Propuesta:**
```ts
export abstract class Provider<S extends ProviderOpts> {
// ...existing code...
private _disposers: (() => void)[] = [];
protected addDisposer(fn: () => void) {
this._disposers.push(fn);
}
dispose() {
for (const fn of this._disposers) fn();
this._disposers.length = 0;
}
}
```
**Estado:** Pending. Requiere decision sobre si vale la pena el overhead ahora.
---
### F15 — console.log / console.warn en produccion
**Archivo:** `core/soma.svelte.ts`
Cubierto por F2. Los `console.log` en `Soma.create()` y `console.warn` en `getSoma()` eran debug residual.
**Estado:** Fixed (junto con F2).
---
## Archivos modificados
| Archivo | Issues |
| ----------------------------------------- | -------------------- |
| `soma/provider/provider.svelte.ts` | F1 |
| `soma/core/soma.svelte.ts` | F2, F15 |
| `soma/components/internal/soma.svelte` | F2 |
| `routes/test/soma/dialog/+page.svelte` | F2 |
| `soma/core/translator.svelte.ts` | F4, F5, F6, F10, F11 |
| `terra/system/translator.svelte.ts` | F4, F5, F6, F10, F11 |
| `soma/core/prop-resolvers.ts` | F8 |
| `terra/system/prop-resolvers.ts` | F8 |
| `soma/layers/floating/floating.svelte.ts` | F7 |
| `soma/layers/scroll-lock.svelte.ts` | F9 |
## Archivos pendientes
| Archivo | Issues |
| ---------------------------------- | ------ |
| `soma/types/services.ts` | F3 |
| `soma/props/props.ts` | F13 |
| `soma/provider/provider.svelte.ts` | F14 |
| `soma/core/soma.svelte.ts` | F12 |

@ -86,11 +86,11 @@ export class {Name}Provider extends Provider<{Name}Opts> {
export class {Name}TriggerProvider extends Provider<{Name}TriggerOpts> {
static from(opts) { return new {Name}TriggerProvider(opts); }
readonly root: {Name}Provider;
readonly provider: {Name}Provider;
private constructor(opts) {
super(opts, '{Name}', 'trigger', attrs.trigger);
this.root = {Name}Provider.ctx.get(); // reads parent context
this.provider = {Name}Provider.ctx.get(); // reads parent context
}
}
```

@ -1,4 +1,4 @@
# Soma Architecture
el # Soma Architecture
Documento de referencia arquitectonica para `src/uix/soma`.

@ -1,10 +1,10 @@
import { Provider, context, type WithRefOpts } from '$soma/provider';
import { createAttrs, boolToEmptyStrOrUndef, getDataOpenClosed } from '$soma/attrs';
import { readableActive, state, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, Orientation, Direction, SomaMouseEvent, SomaKeyboardEvent } from '$soma/types';
import type { Orientation, Direction, SomaMouseEvent, SomaKeyboardEvent } from '$soma/types';
import { KEYS, getDirectionalKeys } from '$soma/keyboard';
import { useId } from '$soma/id';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { Presence } from '$soma/layers/presence.svelte';
import { ResizeObserver$ } from '$soma/layers/resize-observer.svelte';
@ -28,7 +28,6 @@ interface AccordionOpts
orientation: Orientation;
loop: boolean;
dir: Direction;
onValueChange: OnChangeFn<string[]>;
}> {}
export class AccordionProvider extends Provider<AccordionOpts> {
@ -38,11 +37,11 @@ export class AccordionProvider extends Provider<AccordionOpts> {
return new AccordionProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
private constructor(opts: AccordionOpts) {
super(opts, 'Accordion', 'root', attrs.root, AccordionProvider.ctx);
this.soma = Soma.getOr();
}
isItemOpen(value: string): boolean {
@ -64,7 +63,6 @@ export class AccordionProvider extends Provider<AccordionOpts> {
}
this.opts.value.current = next;
this.opts.onValueChange.current(next);
}
/** Get all trigger elements for keyboard navigation. */
@ -100,7 +98,7 @@ export class AccordionItemProvider extends Provider<AccordionItemOpts> {
return new AccordionItemProvider(opts);
}
readonly root: AccordionProvider;
readonly provider: AccordionProvider;
readonly contentPresence: Presence;
// tracked IDs
@ -114,7 +112,7 @@ export class AccordionItemProvider extends Provider<AccordionItemOpts> {
private constructor(opts: AccordionItemOpts) {
super(opts, 'Accordion', 'item', attrs.item, AccordionItemProvider.ctx);
this.root = AccordionProvider.ctx.get();
this.provider = AccordionProvider.ctx.get();
this.contentPresence = new Presence({
open: readableActive(() => this.isOpen),
@ -134,11 +132,11 @@ export class AccordionItemProvider extends Provider<AccordionItemOpts> {
}
readonly isOpen = $derived.by(() =>
this.root.isItemOpen(this.opts.value.current),
this.provider.isItemOpen(this.opts.value.current),
);
readonly isDisabled = $derived.by(() =>
this.opts.disabled.current || this.root.opts.disabled.current,
this.opts.disabled.current || this.provider.opts.disabled.current,
);
setContentRef(el: HTMLElement | null) {
@ -150,7 +148,7 @@ export class AccordionItemProvider extends Provider<AccordionItemOpts> {
...this.baseProps,
'data-state': getDataOpenClosed(this.isOpen),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-orientation': this.root.opts.orientation.current,
'data-orientation': this.provider.opts.orientation.current,
} as const),
);
}
@ -178,7 +176,7 @@ export class AccordionHeaderProvider extends Provider<AccordionHeaderOpts> {
'aria-level': this.opts.level.current,
'data-state': getDataOpenClosed(this.item.isOpen),
'data-disabled': boolToEmptyStrOrUndef(this.item.isDisabled),
'data-orientation': this.item.root.opts.orientation.current,
'data-orientation': this.item.provider.opts.orientation.current,
} as const),
);
}
@ -202,13 +200,13 @@ export class AccordionTriggerProvider extends Provider<AccordionTriggerOpts> {
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.item.isDisabled) return;
this.item.root.toggleItem(this.item.opts.value.current);
this.item.provider.toggleItem(this.item.opts.value.current);
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
if (this.item.isDisabled) return;
const root = this.item.root;
const root = this.item.provider;
const orientation = root.opts.orientation.current;
const dir = root.opts.dir.current;
const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);
@ -248,7 +246,7 @@ export class AccordionTriggerProvider extends Provider<AccordionTriggerOpts> {
'aria-controls': this.item.contentId.current || undefined,
'data-state': getDataOpenClosed(this.item.isOpen),
'data-disabled': boolToEmptyStrOrUndef(this.item.isDisabled),
'data-orientation': this.item.root.opts.orientation.current,
'data-orientation': this.item.provider.opts.orientation.current,
disabled: this.item.isDisabled || undefined,
onclick: this.onclick,
onkeydown: this.onkeydown,
@ -284,7 +282,7 @@ export class AccordionContentProvider extends Provider<AccordionContentOpts> {
'aria-labelledby': this.item.triggerId.current || undefined,
'data-state': getDataOpenClosed(this.item.isOpen),
'data-disabled': boolToEmptyStrOrUndef(this.item.isDisabled),
'data-orientation': this.item.root.opts.orientation.current,
'data-orientation': this.item.provider.opts.orientation.current,
style: {
'--soma-accordion-content-height': `${this.item.contentHeight}px`,
'--soma-accordion-content-width': `${this.item.contentWidth}px`,

@ -2,13 +2,13 @@
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { AccordionProvider } from '../accordion-provider.svelte';
import type { AccordionProps } from '../types';
import { noop } from '$lib/util/funcs';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
ref = $bindable(null),
@ -20,7 +20,7 @@
disabled = false,
orientation = 'vertical',
loop = false,
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
children,
child,
...restProps
@ -35,9 +35,11 @@
type: readableActive(() => type),
value: writableActive(
() => value,
(v) => (value = v),
(v) => {
value = v;
onValueChange(v);
},
),
onValueChange: readableActive(() => onValueChange),
collapsible: readableActive(() => collapsible),
disabled: readableActive(() => disabled),
orientation: readableActive(() => orientation),

@ -1,7 +1,7 @@
import { Provider, context, type WithRefOpts } from '$soma/provider';
import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
import { readableActive, state, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, SomaMouseEvent, SomaKeyboardEvent } from '$soma/types';
import type { SomaMouseEvent, SomaKeyboardEvent } from '$soma/types';
import { KEYS } from '$soma/keyboard';
const attrs = createAttrs({
@ -20,8 +20,6 @@ interface CheckboxOpts
readonly: boolean;
name: string;
value: string;
onCheckedChange: OnChangeFn<boolean>;
onIndeterminateChange: OnChangeFn<boolean>;
}> {}
export class CheckboxProvider extends Provider<CheckboxOpts> {
@ -43,11 +41,9 @@ export class CheckboxProvider extends Provider<CheckboxOpts> {
if (this.opts.disabled.current || this.opts.readonly.current) return;
if (this.opts.indeterminate.current) {
this.opts.indeterminate.current = false;
this.opts.onIndeterminateChange.current(false);
}
const next = !this.opts.checked.current;
this.opts.checked.current = next;
this.opts.onCheckedChange.current(next);
if (this.group) this.group.updateItemChecked(this.opts.value.current, next);
}
@ -107,7 +103,6 @@ interface CheckboxGroupOpts
disabled: boolean;
required: boolean;
name: string;
onValueChange: OnChangeFn<string[]>;
}> {}
export class CheckboxGroupProvider extends Provider<CheckboxGroupOpts> {
@ -133,7 +128,6 @@ export class CheckboxGroupProvider extends Provider<CheckboxGroupOpts> {
? [...current, value]
: current.filter((v) => v !== value);
this.opts.value.current = next;
this.opts.onValueChange.current(next);
}
readonly props = $derived.by(() =>

@ -24,8 +24,13 @@
const state = CheckboxGroupProvider.from({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
value: writableActive(() => value, (v) => (value = v)),
onValueChange: readableActive(() => onValueChange),
value: writableActive(
() => value,
(v) => {
value = v;
onValueChange(v);
},
),
disabled: readableActive(() => disabled),
required: readableActive(() => required),
name: readableActive(() => name),

@ -28,10 +28,20 @@
const state = CheckboxProvider.from({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
checked: writableActive(() => checked, (v) => (checked = v)),
indeterminate: writableActive(() => indeterminate, (v) => (indeterminate = v)),
onCheckedChange: readableActive(() => onCheckedChange),
onIndeterminateChange: readableActive(() => onIndeterminateChange),
checked: writableActive(
() => checked,
(v) => {
checked = v;
onCheckedChange(v);
},
),
indeterminate: writableActive(
() => indeterminate,
(v) => {
indeterminate = v;
onIndeterminateChange(v);
},
),
disabled: readableActive(() => disabled),
required: readableActive(() => required),
readonly: readableActive(() => readonly),

@ -1,7 +1,7 @@
import { Provider, context, type WithRefOpts } from '$soma/provider';
import { createAttrs, boolToEmptyStrOrUndef, getDataOpenClosed } from '$soma/attrs';
import type { ActiveProps, StateProps } from '$soma/reactive';
import type { SomaMouseEvent, OnChangeFn } from '$soma/types';
import type { SomaMouseEvent } from '$soma/types';
const attrs = createAttrs({
component: 'collapsible',
@ -12,7 +12,7 @@ const attrs = createAttrs({
interface CollapsibleOpts extends WithRefOpts,
StateProps<{ open: boolean }>,
ActiveProps<{ disabled: boolean; onOpenChange: OnChangeFn<boolean> }> {}
ActiveProps<{ disabled: boolean }> {}
export class CollapsibleProvider extends Provider<CollapsibleOpts> {
static readonly ctx = context<CollapsibleProvider>('Collapsible');
@ -24,9 +24,7 @@ export class CollapsibleProvider extends Provider<CollapsibleOpts> {
toggle() {
if (this.opts.disabled.current) return;
const next = !this.opts.open.current;
this.opts.open.current = next;
this.opts.onOpenChange.current(next);
this.opts.open.current = !this.opts.open.current;
}
readonly props = $derived.by(() =>
@ -45,25 +43,25 @@ interface CollapsibleTriggerOpts extends WithRefOpts {}
export class CollapsibleTriggerProvider extends Provider<CollapsibleTriggerOpts> {
static from(opts: CollapsibleTriggerOpts) { return new CollapsibleTriggerProvider(opts); }
readonly root: CollapsibleProvider;
readonly provider: CollapsibleProvider;
private constructor(opts: CollapsibleTriggerOpts) {
super(opts, 'Collapsible', 'trigger', attrs.trigger);
this.root = CollapsibleProvider.ctx.get();
this.provider = CollapsibleProvider.ctx.get();
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.root.toggle();
this.provider.toggle();
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' as const,
'aria-expanded': this.root.opts.open.current,
'data-state': getDataOpenClosed(this.root.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.root.opts.disabled.current),
disabled: this.root.opts.disabled.current || undefined,
'aria-expanded': this.provider.opts.open.current,
'data-state': getDataOpenClosed(this.provider.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
disabled: this.provider.opts.disabled.current || undefined,
onclick: this.onclick,
} as const)
);
@ -76,21 +74,21 @@ interface CollapsibleContentOpts extends WithRefOpts {}
export class CollapsibleContentProvider extends Provider<CollapsibleContentOpts> {
static from(opts: CollapsibleContentOpts) { return new CollapsibleContentProvider(opts); }
readonly root: CollapsibleProvider;
readonly provider: CollapsibleProvider;
private constructor(opts: CollapsibleContentOpts) {
super(opts, 'Collapsible', 'content', attrs.content);
this.root = CollapsibleProvider.ctx.get();
this.provider = CollapsibleProvider.ctx.get();
}
readonly isOpen = $derived.by(() => this.root.opts.open.current);
readonly isOpen = $derived.by(() => this.provider.opts.open.current);
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
hidden: this.isOpen ? undefined : true,
'data-state': getDataOpenClosed(this.isOpen),
'data-disabled': boolToEmptyStrOrUndef(this.root.opts.disabled.current),
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
} as const)
);
}

@ -23,8 +23,13 @@
const state = CollapsibleProvider.from({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
open: writableActive(() => open, (v) => (open = v)),
onOpenChange: readableActive(() => onOpenChange),
open: writableActive(
() => open,
(v) => {
open = v;
onOpenChange(v);
},
),
disabled: readableActive(() => disabled),
});

@ -3,7 +3,7 @@ import { createAttrs, boolToEmptyStrOrUndef, getDataOpenClosed } from '$soma/att
import { readableActive, state, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, SomaKeyboardEvent, Direction, Arrayable, StyleProperties } from '$soma/types';
import { KEYS } from '$soma/keyboard';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { Presence } from '$soma/layers/presence.svelte';
import { Dismissal, type DismissalBehavior } from '$soma/layers/dismissal.svelte';
@ -37,10 +37,7 @@ interface ComboboxOpts
loop: boolean;
allowDeselect: boolean;
dir: Direction;
onValueChange: OnChangeFn<string[]>;
onOpenChange: OnChangeFn<boolean>;
onOpenChangeComplete: OnChangeFn<boolean>;
onInputValueChange: OnChangeFn<string>;
}> {}
export class ComboboxProvider extends Provider<ComboboxOpts> {
@ -50,7 +47,7 @@ export class ComboboxProvider extends Provider<ComboboxOpts> {
return new ComboboxProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
readonly floatingProvider: FloatingProvider;
readonly contentPresence: Presence;
@ -66,7 +63,7 @@ export class ComboboxProvider extends Provider<ComboboxOpts> {
private constructor(opts: ComboboxOpts) {
super(opts, 'Combobox', 'root', attrs.root, ComboboxProvider.ctx);
this.soma = Soma.getOr();
this.floatingProvider = FloatingProvider.create();
this.contentPresence = new Presence({
@ -102,19 +99,16 @@ export class ComboboxProvider extends Provider<ComboboxOpts> {
}
this.opts.value.current = next;
this.opts.onValueChange.current(next);
}
handleOpen() {
if (this.opts.disabled.current) return;
this.opts.open.current = true;
this.opts.onOpenChange.current(true);
}
handleClose() {
this.highlightedId = '';
this.opts.open.current = false;
this.opts.onOpenChange.current(false);
this.inputRef.current?.focus();
}
@ -136,7 +130,6 @@ export class ComboboxProvider extends Provider<ComboboxOpts> {
updateInputValue(value: string) {
this.opts.inputValue.current = value;
this.opts.onInputValueChange.current(value);
}
/** Get display text for the current value. */
@ -163,13 +156,13 @@ export class ComboboxInputProvider extends Provider<ComboboxInputOpts> {
return new ComboboxInputProvider(opts);
}
readonly root: ComboboxProvider;
readonly provider: ComboboxProvider;
private constructor(opts: ComboboxInputOpts) {
super(opts, 'Combobox', 'input', attrs.input);
this.root = ComboboxProvider.ctx.get();
this.root.inputId.current = opts.id.current;
this.root.inputRef = opts.ref;
this.provider = ComboboxProvider.ctx.get();
this.provider.inputId.current = opts.id.current;
this.provider.inputRef = opts.ref;
// Input is the floating anchor
FloatingAnchor.create({
@ -180,56 +173,56 @@ export class ComboboxInputProvider extends Provider<ComboboxInputOpts> {
readonly oninput = (e: Event) => {
const target = e.target as HTMLInputElement;
this.root.updateInputValue(target.value);
if (!this.root.opts.open.current) {
this.root.handleOpen();
this.provider.updateInputValue(target.value);
if (!this.provider.opts.open.current) {
this.provider.handleOpen();
}
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLInputElement>) => {
if (e.key === KEYS.ARROW_DOWN) {
e.preventDefault();
if (!this.root.opts.open.current) {
this.root.handleOpen();
if (!this.provider.opts.open.current) {
this.provider.handleOpen();
}
// Focus first item
requestAnimationFrame(() => {
const container = this.root.contentRef.current;
const container = this.provider.contentRef.current;
if (!container) return;
const items = this.root.getItems(container);
const items = this.provider.getItems(container);
if (items.length > 0) {
items[0].focus();
this.root.highlightedId = items[0].id;
this.provider.highlightedId = items[0].id;
}
});
} else if (e.key === KEYS.ARROW_UP) {
e.preventDefault();
if (!this.root.opts.open.current) {
this.root.handleOpen();
if (!this.provider.opts.open.current) {
this.provider.handleOpen();
}
// Focus last item
requestAnimationFrame(() => {
const container = this.root.contentRef.current;
const container = this.provider.contentRef.current;
if (!container) return;
const items = this.root.getItems(container);
const items = this.provider.getItems(container);
if (items.length > 0) {
const last = items[items.length - 1];
last.focus();
this.root.highlightedId = last.id;
this.provider.highlightedId = last.id;
}
});
} else if (e.key === KEYS.ESCAPE) {
if (this.root.opts.open.current) {
if (this.provider.opts.open.current) {
e.preventDefault();
this.root.handleClose();
this.provider.handleClose();
}
} else if (e.key === KEYS.ENTER) {
if (this.root.opts.open.current && this.root.highlightedId) {
if (this.provider.opts.open.current && this.provider.highlightedId) {
e.preventDefault();
const container = this.root.contentRef.current;
const container = this.provider.contentRef.current;
if (container) {
const highlighted = container.querySelector<HTMLElement>(
`#${CSS.escape(this.root.highlightedId)}`,
`#${CSS.escape(this.provider.highlightedId)}`,
);
if (highlighted) {
highlighted.click();
@ -245,15 +238,15 @@ export class ComboboxInputProvider extends Provider<ComboboxInputOpts> {
type: 'text' as const,
role: 'combobox' as const,
'aria-haspopup': 'listbox' as const,
'aria-expanded': this.root.opts.open.current,
'aria-controls': this.root.contentId.current || undefined,
'aria-activedescendant': this.root.highlightedId || undefined,
'aria-required': this.root.opts.required.current || undefined,
'aria-expanded': this.provider.opts.open.current,
'aria-controls': this.provider.contentId.current || undefined,
'aria-activedescendant': this.provider.highlightedId || undefined,
'aria-required': this.provider.opts.required.current || undefined,
'aria-autocomplete': 'list' as const,
'data-state': getDataOpenClosed(this.root.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.root.opts.disabled.current),
disabled: this.root.opts.disabled.current || undefined,
value: this.root.opts.inputValue.current,
'data-state': getDataOpenClosed(this.provider.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
disabled: this.provider.opts.disabled.current || undefined,
value: this.provider.opts.inputValue.current,
oninput: this.oninput,
onkeydown: this.onkeydown,
} as const),
@ -269,17 +262,17 @@ export class ComboboxTriggerProvider extends Provider<ComboboxTriggerOpts> {
return new ComboboxTriggerProvider(opts);
}
readonly root: ComboboxProvider;
readonly provider: ComboboxProvider;
private constructor(opts: ComboboxTriggerOpts) {
super(opts, 'Combobox', 'trigger', attrs.trigger);
this.root = ComboboxProvider.ctx.get();
this.root.triggerId.current = opts.id.current;
this.root.triggerRef = opts.ref;
this.provider = ComboboxProvider.ctx.get();
this.provider.triggerId.current = opts.id.current;
this.provider.triggerRef = opts.ref;
}
readonly onclick = () => {
this.root.toggle();
this.provider.toggle();
};
readonly props = $derived.by(() =>
@ -289,11 +282,11 @@ export class ComboboxTriggerProvider extends Provider<ComboboxTriggerOpts> {
tabindex: -1,
'aria-label': 'Toggle' as const,
'aria-haspopup': 'listbox' as const,
'aria-expanded': this.root.opts.open.current,
'aria-controls': this.root.contentId.current || undefined,
'data-state': getDataOpenClosed(this.root.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.root.opts.disabled.current),
disabled: this.root.opts.disabled.current || undefined,
'aria-expanded': this.provider.opts.open.current,
'aria-controls': this.provider.contentId.current || undefined,
'data-state': getDataOpenClosed(this.provider.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
disabled: this.provider.opts.disabled.current || undefined,
onclick: this.onclick,
} as const),
);
@ -328,16 +321,16 @@ export class ComboboxContentProvider extends Provider<ComboboxContentOpts> {
return new ComboboxContentProvider(opts);
}
readonly root: ComboboxProvider;
readonly provider: ComboboxProvider;
readonly floating: FloatingContent;
readonly dismissal: Dismissal;
private constructor(opts: ComboboxContentOpts) {
super(opts, 'Combobox', 'content', attrs.content, undefined, (el) => {
this.root.setContentRef(el);
this.provider.setContentRef(el);
});
this.root = ComboboxProvider.ctx.get();
this.root.contentId.current = opts.id.current;
this.provider = ComboboxProvider.ctx.get();
this.provider.contentId.current = opts.id.current;
// Floating
this.floating = FloatingContent.create({
@ -358,28 +351,28 @@ export class ComboboxContentProvider extends Provider<ComboboxContentOpts> {
onPlaced: readableActive(() => () => {}),
dir: opts.dir,
style: opts.style,
enabled: readableActive(() => this.root.opts.open.current),
enabled: readableActive(() => this.provider.opts.open.current),
customAnchor: readableActive(() => null),
});
// Dismissal — isValidEvent returns false for clicks on the input element
this.dismissal = Dismissal.use({
ref: readableActive(() => opts.ref.current),
enabled: readableActive(() => this.root.opts.open.current),
enabled: readableActive(() => this.provider.opts.open.current),
onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => {
opts.onEscapeKeydown.current(e);
if (!e.defaultPrevented) this.root.handleClose();
if (!e.defaultPrevented) this.provider.handleClose();
}),
escapeKeydownBehavior: opts.escapeKeydownBehavior,
onInteractOutside: readableActive(() => (e: PointerEvent) => {
opts.onInteractOutside.current(e);
if (!e.defaultPrevented) this.root.handleClose();
if (!e.defaultPrevented) this.provider.handleClose();
}),
interactOutsideBehavior: opts.interactOutsideBehavior,
onFocusOutside: readableActive(() => () => {}),
isValidEvent: readableActive(() => (e: PointerEvent | FocusEvent) => {
// Clicks on the input element should not close the dropdown
const inputEl = this.root.inputRef.current;
const inputEl = this.provider.inputRef.current;
if (inputEl && e.target instanceof Node && inputEl.contains(e.target)) {
return false;
}
@ -390,7 +383,7 @@ export class ComboboxContentProvider extends Provider<ComboboxContentOpts> {
// No ScrollLock for combobox
}
readonly isPresent = $derived.by(() => this.root.contentPresence.isPresent);
readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent);
readonly wrapperProps = $derived.by(() => ({
...this.floating.wrapperProps,
@ -404,10 +397,10 @@ export class ComboboxContentProvider extends Provider<ComboboxContentOpts> {
const container = this.opts.ref.current;
if (!container) return;
const items = this.root.getItems(container);
const items = this.provider.getItems(container);
const active = document.activeElement as HTMLElement;
const currentIndex = items.indexOf(active);
const loop = this.root.opts.loop.current;
const loop = this.provider.opts.loop.current;
let targetIndex = -1;
@ -420,8 +413,8 @@ export class ComboboxContentProvider extends Provider<ComboboxContentOpts> {
e.preventDefault();
// If on the first item, return focus to input
if (currentIndex === 0 && !loop) {
this.root.highlightedId = '';
this.root.inputRef.current?.focus();
this.provider.highlightedId = '';
this.provider.inputRef.current?.focus();
return;
}
targetIndex = loop
@ -435,11 +428,11 @@ export class ComboboxContentProvider extends Provider<ComboboxContentOpts> {
targetIndex = items.length - 1;
} else if (e.key === KEYS.TAB) {
e.preventDefault();
this.root.handleClose();
this.provider.handleClose();
return;
} else if (e.key === KEYS.ESCAPE) {
e.preventDefault();
this.root.handleClose();
this.provider.handleClose();
return;
} else {
return;
@ -449,7 +442,7 @@ export class ComboboxContentProvider extends Provider<ComboboxContentOpts> {
const target = items[targetIndex];
target.focus();
target.scrollIntoView({ block: 'nearest' });
this.root.highlightedId = target.id;
this.provider.highlightedId = target.id;
}
};
@ -457,10 +450,10 @@ export class ComboboxContentProvider extends Provider<ComboboxContentOpts> {
this.assertProps({
...this.baseProps,
role: 'listbox' as const,
'aria-multiselectable': this.root.opts.type.current === 'multiple' || undefined,
'data-state': getDataOpenClosed(this.root.opts.open.current),
'aria-multiselectable': this.provider.opts.type.current === 'multiple' || undefined,
'data-state': getDataOpenClosed(this.provider.opts.open.current),
...this.floating.props,
...this.root.contentPresence.transitionAttrs,
...this.provider.contentPresence.transitionAttrs,
...this.dismissal.props,
tabindex: -1,
onkeydown: this.onkeydown,
@ -504,27 +497,27 @@ export class ComboboxItemProvider extends Provider<ComboboxItemOpts> {
return new ComboboxItemProvider(opts);
}
readonly root: ComboboxProvider;
readonly provider: ComboboxProvider;
private constructor(opts: ComboboxItemOpts) {
super(opts, 'Combobox', 'item', attrs.item);
this.root = ComboboxProvider.ctx.get();
this.provider = ComboboxProvider.ctx.get();
}
readonly isSelected = $derived.by(() =>
this.root.isSelected(this.opts.value.current),
this.provider.isSelected(this.opts.value.current),
);
readonly onclick = () => {
if (this.opts.disabled.current) return;
this.root.selectItem(this.opts.value.current);
this.provider.selectItem(this.opts.value.current);
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
if (this.opts.disabled.current) return;
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
this.root.selectItem(this.opts.value.current);
this.provider.selectItem(this.opts.value.current);
}
};

@ -3,13 +3,13 @@
import { mergeProps } from '$soma/props';
import { styleToString } from '$soma/css';
import { createId } from '$soma/id';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { ComboboxContentProvider } from '../combobox-provider.svelte';
import type { ComboboxContentProps } from '../types';
import { noop } from '$lib/util/funcs';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
ref = $bindable(null),
@ -30,7 +30,7 @@
escapeKeydownBehavior = 'close',
onInteractOutside = noop,
interactOutsideBehavior = 'close',
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
style = null,
children,
child,
@ -74,10 +74,10 @@
{#if state.isPresent || forceMount}
<div {...state.wrapperProps} style={wrapperStyleStr}>
{#if child}
{@render child({ open: state.root.opts.open.current, props: mergedProps })}
{@render child({ open: state.provider.opts.open.current, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.({ open: state.root.opts.open.current })}
{@render children?.({ open: state.provider.opts.open.current })}
</div>
{/if}
</div>

@ -4,10 +4,10 @@
import { ComboboxProvider } from '../combobox-provider.svelte';
import type { ComboboxProps } from '../types';
import { noop } from '$lib/util/funcs';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
id = createId(uid, 'combobox'),
@ -20,7 +20,7 @@
name = '',
loop = false,
allowDeselect = false,
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
onValueChange = noop,
onOpenChange = noop,
onOpenChangeComplete = noop,
@ -32,15 +32,24 @@
id: readableActive(() => id),
open: writableActive(
() => open,
(v) => (open = v),
(v) => {
open = v;
onOpenChange(v);
},
),
value: writableActive(
() => value,
(v) => (value = v),
(v) => {
value = v;
onValueChange(v);
},
),
inputValue: writableActive(
() => inputValue,
(v) => (inputValue = v),
(v) => {
inputValue = v;
onInputValueChange(v);
},
),
type: readableActive(() => type),
disabled: readableActive(() => disabled),
@ -49,10 +58,7 @@
loop: readableActive(() => loop),
allowDeselect: readableActive(() => allowDeselect),
dir: readableActive(() => dir),
onValueChange: readableActive(() => onValueChange),
onOpenChange: readableActive(() => onOpenChange),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
onInputValueChange: readableActive(() => onInputValueChange),
});
</script>

@ -52,10 +52,10 @@
{#if state.isPresent || forceMount}
{#if child}
{@render child({ open: state.root.opts.open.current, props: mergedProps })}
{@render child({ open: state.provider.opts.open.current, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.({ open: state.root.opts.open.current })}
{@render children?.({ open: state.provider.opts.open.current })}
</div>
{/if}
{/if}

@ -22,9 +22,11 @@
id: readableActive(() => id),
open: writableActive(
() => open,
(v) => (open = v),
(v) => {
open = v;
onOpenChange(v);
},
),
onOpenChange: readableActive(() => onOpenChange),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
variant: readableActive(() => variant),
modal: readableActive(() => modal),

@ -3,7 +3,8 @@ import { createAttrs, boolToEmptyStrOrUndef, getDataOpenClosed } from '$soma/att
import { readableActive, state, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, SomaMouseEvent, SomaKeyboardEvent } from '$soma/types';
import { KEYS } from '$soma/keyboard';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { resolveSomaTranslationPath } from '$soma/core/translator.svelte';
import { watch } from 'runed';
import { untrack } from 'svelte';
@ -29,7 +30,6 @@ interface DialogOpts
disabled: boolean;
modal: boolean;
variant: DialogVariant;
onOpenChange: OnChangeFn<boolean>;
onOpenChangeComplete: OnChangeFn<boolean>;
}> {}
@ -40,7 +40,7 @@ export class DialogProvider extends Provider<DialogOpts> {
return new DialogProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
// tracked IDs for ARIA
contentId = state('');
@ -67,7 +67,7 @@ export class DialogProvider extends Provider<DialogOpts> {
// Read parent BEFORE registering in context (ctx.set happens in super)
const parent = DialogProvider.ctx.getOr(undefined) as DialogProvider | undefined;
super(opts, 'Dialog', 'provider', attrs.provider, DialogProvider.ctx);
this.soma = Soma.getOr();
this.parent = parent;
this.depth = parent ? parent.depth + 1 : 0;
@ -103,13 +103,11 @@ export class DialogProvider extends Provider<DialogOpts> {
handleOpen() {
if (this.opts.disabled.current) return;
this.opts.open.current = true;
this.opts.onOpenChange.current(true);
}
handleClose() {
if (this.opts.disabled.current) return;
this.opts.open.current = false;
this.opts.onOpenChange.current(false);
}
setContentRef(el: HTMLElement | null) {
@ -122,7 +120,7 @@ export class DialogProvider extends Provider<DialogOpts> {
/** Translate a dialog key with fallback. */
translate(path: string, fallback: string): string {
return this.soma?.translate(`dialog.${path}`, fallback) ?? fallback;
return resolveSomaTranslationPath(`soma.dialog.${path}`, this.config.translator.current, fallback);
}
readonly props = $derived.by(() =>
@ -143,22 +141,22 @@ export class DialogTriggerProvider extends Provider<DialogTriggerOpts> {
return new DialogTriggerProvider(opts);
}
readonly root: DialogProvider;
readonly provider: DialogProvider;
private constructor(opts: DialogTriggerOpts) {
super(opts, 'Dialog', 'trigger', attrs.trigger);
this.root = DialogProvider.ctx.get();
this.root.triggerId.current = opts.id.current;
this.provider = DialogProvider.ctx.get();
this.provider.triggerId.current = opts.id.current;
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.root.handleOpen();
this.provider.handleOpen();
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
this.root.handleOpen();
this.provider.handleOpen();
}
};
@ -167,11 +165,11 @@ export class DialogTriggerProvider extends Provider<DialogTriggerOpts> {
...this.baseProps,
type: 'button' as const,
'aria-haspopup': 'dialog' as const,
'aria-expanded': this.root.opts.open.current,
'aria-controls': this.root.contentId.current || undefined,
'aria-label': this.root.translate('trigger', 'Open dialog'),
'data-state': getDataOpenClosed(this.root.opts.open.current),
disabled: this.root.opts.disabled.current || undefined,
'aria-expanded': this.provider.opts.open.current,
'aria-controls': this.provider.contentId.current || undefined,
'aria-label': this.provider.translate('trigger', 'Open dialog'),
'data-state': getDataOpenClosed(this.provider.opts.open.current),
disabled: this.provider.opts.disabled.current || undefined,
onclick: this.onclick,
onkeydown: this.onkeydown,
...this.attachment,
@ -202,7 +200,7 @@ export class DialogContentProvider extends Provider<DialogContentOpts> {
return new DialogContentProvider(opts);
}
readonly root: DialogProvider;
readonly provider: DialogProvider;
// ── Behavior layers ──────────────────────────────────────────────────────
readonly focusScope: ReturnType<typeof FocusScope.use>;
@ -212,10 +210,10 @@ export class DialogContentProvider extends Provider<DialogContentOpts> {
private constructor(opts: DialogContentOpts) {
super(opts, 'Dialog', 'content', attrs.content, undefined, (el) => {
this.root.setContentRef(el);
this.provider.setContentRef(el);
});
this.root = DialogProvider.ctx.get();
this.root.contentId.current = opts.id.current;
this.provider = DialogProvider.ctx.get();
this.provider.contentId.current = opts.id.current;
// Layers — all integrated as behaviors
this.focusScope = FocusScope.use({
@ -224,13 +222,13 @@ export class DialogContentProvider extends Provider<DialogContentOpts> {
trap: opts.trapFocus,
loop: opts.loop,
ref: opts.ref,
enabled: readableActive(() => this.root.contentPresence.isPresent),
enabled: readableActive(() => this.provider.contentPresence.isPresent),
});
// Behavior resolution: explicit prop wins → variant/modal defaults → 'close'
const isAlert = () => this.root.opts.variant.current === 'alertdialog';
const isAlert = () => this.provider.opts.variant.current === 'alertdialog';
const resolvedInteractBehavior = readableActive(
() => opts.interactOutsideBehavior.current ?? (this.root.opts.modal.current || isAlert() ? 'ignore' : 'close'),
() => opts.interactOutsideBehavior.current ?? (this.provider.opts.modal.current || isAlert() ? 'ignore' : 'close'),
);
const resolvedEscapeBehavior = readableActive(
() => opts.escapeKeydownBehavior.current ?? (isAlert() ? 'ignore' : 'close'),
@ -238,15 +236,15 @@ export class DialogContentProvider extends Provider<DialogContentOpts> {
this.dismissal = Dismissal.use({
ref: readableActive(() => opts.ref.current),
enabled: readableActive(() => this.root.opts.open.current),
enabled: readableActive(() => this.provider.opts.open.current),
onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => {
opts.onEscapeKeydown.current(e);
if (!e.defaultPrevented) this.root.handleClose();
if (!e.defaultPrevented) this.provider.handleClose();
}),
escapeKeydownBehavior: resolvedEscapeBehavior,
onInteractOutside: readableActive(() => (e: PointerEvent) => {
opts.onInteractOutside.current(e);
if (!e.defaultPrevented) this.root.handleClose();
if (!e.defaultPrevented) this.provider.handleClose();
}),
interactOutsideBehavior: resolvedInteractBehavior,
onFocusOutside: opts.onFocusOutside,
@ -258,36 +256,36 @@ export class DialogContentProvider extends Provider<DialogContentOpts> {
() => opts.restoreScrollDelay.current,
);
$effect(() => {
this.scrollLock.locked.current = this.root.opts.open.current && opts.preventScroll.current;
this.scrollLock.locked.current = this.provider.opts.open.current && opts.preventScroll.current;
});
this.textSelection = TextSelection.use({
ref: readableActive(() => opts.ref.current),
enabled: readableActive(() => this.root.opts.open.current),
enabled: readableActive(() => this.provider.opts.open.current),
onPointerDown: readableActive(() => () => {}),
onPointerUp: readableActive(() => () => {}),
});
}
readonly isPresent = $derived.by(() => this.root.contentPresence.isPresent);
readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent);
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: this.root.opts.variant.current,
role: this.provider.opts.variant.current,
'aria-modal': true as const,
'aria-roledescription': this.root.translate('content.roledescription', '') || undefined,
'aria-describedby': this.root.descriptionId.current || undefined,
'aria-labelledby': this.root.titleId.current || undefined,
'data-state': getDataOpenClosed(this.root.opts.open.current),
'data-nested': this.root.isNested ? '' : undefined,
'data-nested-open': this.root.hasNestedOpen ? '' : undefined,
'aria-roledescription': this.provider.translate('content.roledescription', '') || undefined,
'aria-describedby': this.provider.descriptionId.current || undefined,
'aria-labelledby': this.provider.titleId.current || undefined,
'data-state': getDataOpenClosed(this.provider.opts.open.current),
'data-nested': this.provider.isNested ? '' : undefined,
'data-nested-open': this.provider.hasNestedOpen ? '' : undefined,
style: {
'pointer-events': 'auto',
'--soma-dialog-depth': `${this.root.depth}`,
'--soma-dialog-nested-count': `${this.root.nestedOpenCount}`,
'--soma-dialog-depth': `${this.provider.depth}`,
'--soma-dialog-nested-count': `${this.provider.nestedOpenCount}`,
},
...this.root.contentPresence.transitionAttrs,
...this.provider.contentPresence.transitionAttrs,
...this.focusScope.props,
...this.dismissal.props,
} as const),
@ -303,30 +301,30 @@ export class DialogOverlayProvider extends Provider<DialogOverlayOpts> {
return new DialogOverlayProvider(opts);
}
readonly root: DialogProvider;
readonly provider: DialogProvider;
private constructor(opts: DialogOverlayOpts) {
super(opts, 'Dialog', 'overlay', attrs.overlay, undefined, (el) => {
this.root.setOverlayRef(el);
this.provider.setOverlayRef(el);
});
this.root = DialogProvider.ctx.get();
this.provider = DialogProvider.ctx.get();
}
readonly isPresent = $derived.by(() => this.root.overlayPresence.isPresent);
readonly isPresent = $derived.by(() => this.provider.overlayPresence.isPresent);
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-state': getDataOpenClosed(this.root.opts.open.current),
'data-state': getDataOpenClosed(this.provider.opts.open.current),
'aria-hidden': true as const,
'data-nested': this.root.isNested ? '' : undefined,
'data-nested-open': this.root.hasNestedOpen ? '' : undefined,
'data-nested': this.provider.isNested ? '' : undefined,
'data-nested-open': this.provider.hasNestedOpen ? '' : undefined,
style: {
'pointer-events': 'auto',
'--soma-dialog-depth': `${this.root.depth}`,
'--soma-dialog-nested-count': `${this.root.nestedOpenCount}`,
'--soma-dialog-depth': `${this.provider.depth}`,
'--soma-dialog-nested-count': `${this.provider.nestedOpenCount}`,
},
...this.root.overlayPresence.transitionAttrs,
...this.provider.overlayPresence.transitionAttrs,
} as const),
);
}
@ -340,12 +338,12 @@ export class DialogTitleProvider extends Provider<DialogTitleOpts> {
return new DialogTitleProvider(opts);
}
readonly root: DialogProvider;
readonly provider: DialogProvider;
private constructor(opts: DialogTitleOpts) {
super(opts, 'Dialog', 'title', attrs.title);
this.root = DialogProvider.ctx.get();
this.root.titleId.current = opts.id.current;
this.provider = DialogProvider.ctx.get();
this.provider.titleId.current = opts.id.current;
}
readonly props = $derived.by(() =>
@ -366,12 +364,12 @@ export class DialogDescriptionProvider extends Provider<DialogDescriptionOpts> {
return new DialogDescriptionProvider(opts);
}
readonly root: DialogProvider;
readonly provider: DialogProvider;
private constructor(opts: DialogDescriptionOpts) {
super(opts, 'Dialog', 'description', attrs.description);
this.root = DialogProvider.ctx.get();
this.root.descriptionId.current = opts.id.current;
this.provider = DialogProvider.ctx.get();
this.provider.descriptionId.current = opts.id.current;
}
readonly props = $derived.by(() =>
@ -390,22 +388,22 @@ export class DialogCloseProvider extends Provider<DialogCloseOpts> {
return new DialogCloseProvider(opts);
}
readonly root: DialogProvider;
readonly provider: DialogProvider;
private constructor(opts: DialogCloseOpts) {
super(opts, 'Dialog', 'close', attrs.close);
this.root = DialogProvider.ctx.get();
this.provider = DialogProvider.ctx.get();
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.root.handleClose();
this.provider.handleClose();
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' as const,
'aria-label': this.root.translate('close', 'Close'),
'aria-label': this.provider.translate('close', 'Close'),
onclick: this.onclick,
} as const),
);

@ -30,12 +30,14 @@
),
checked: writableActive(
() => checked,
(v) => (checked = v),
(v) => {
checked = v;
onCheckedChange(v);
},
),
disabled: readableActive(() => disabled),
textValue: readableActive(() => textValue),
closeOnSelect: readableActive(() => closeOnSelect),
onCheckedChange: readableActive(() => onCheckedChange),
onSelect: readableActive(() => onSelect),
});

@ -3,13 +3,13 @@
import { mergeProps } from '$soma/props';
import { styleToString } from '$soma/css';
import { createId } from '$soma/id';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { MenuContentProvider } from '../dropdown-menu-provider.svelte';
import type { MenuContentProps } from '../types';
import { noop } from '$lib/util/funcs';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
ref = $bindable(null),
@ -37,7 +37,7 @@
onInteractOutside = noop,
interactOutsideBehavior = undefined,
onFocusOutside = noop,
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
style = null,
children,
child,
@ -88,10 +88,10 @@
{#if state.isPresent || forceMount}
<div {...state.wrapperProps} style={wrapperStyleStr}>
{#if child}
{@render child({ open: state.root.opts.open.current, props: mergedProps })}
{@render child({ open: state.provider.opts.open.current, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.({ open: state.root.opts.open.current })}
{@render children?.({ open: state.provider.opts.open.current })}
</div>
{/if}
</div>

@ -26,9 +26,11 @@
),
value: writableActive(
() => value,
(v) => (value = v),
(v) => {
value = v;
onValueChange(v);
},
),
onValueChange: readableActive(() => onValueChange),
});
const mergedProps = $derived(mergeProps(restProps, state.props));

@ -3,12 +3,12 @@
import { mergeProps } from '$soma/props';
import { styleToString } from '$soma/css';
import { createId } from '$soma/id';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { MenuSubContentProvider } from '../dropdown-menu-provider.svelte';
import type { MenuSubContentProps } from '../types';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
ref = $bindable(null),
@ -22,7 +22,7 @@
collisionBoundary = [],
collisionPadding = 0,
loop = false,
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
style = null,
children,
child,

@ -1,5 +1,5 @@
<script lang="ts">
import { readableActive, writableActive } from '$soma/reactive';
import { writableActive } from '$soma/reactive';
import { MenuSubProvider } from '../dropdown-menu-provider.svelte';
import type { MenuSubProps } from '../types';
import { noop } from '$lib/util/funcs';
@ -13,9 +13,11 @@
MenuSubProvider.from({
open: writableActive(
() => open,
(v) => (open = v),
(v) => {
open = v;
onOpenChange(v);
},
),
onOpenChange: readableActive(() => onOpenChange),
});
</script>

@ -4,10 +4,10 @@
import { MenuProvider } from '../dropdown-menu-provider.svelte';
import type { MenuProps } from '../types';
import { noop } from '$lib/util/funcs';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
id = createId(uid, 'dropdown-menu'),
@ -15,7 +15,7 @@
onOpenChange = noop,
onOpenChangeComplete = noop,
modal = true,
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
children,
}: MenuProps = $props();
@ -23,9 +23,11 @@
id: readableActive(() => id),
open: writableActive(
() => open,
(v) => (open = v),
(v) => {
open = v;
onOpenChange(v);
},
),
onOpenChange: readableActive(() => onOpenChange),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
modal: readableActive(() => modal),
dir: readableActive(() => dir),

@ -3,7 +3,7 @@ import { createAttrs, boolToEmptyStrOrUndef, getDataOpenClosed } from '$soma/att
import { readableActive, state, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, SomaMouseEvent, SomaKeyboardEvent, Direction, Arrayable, StyleProperties } from '$soma/types';
import { KEYS, getDirectionalKeys } from '$soma/keyboard';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { untrack } from 'svelte';
import { Presence } from '$soma/layers/presence.svelte';
@ -65,7 +65,6 @@ interface MenuOpts
ActiveProps<{
modal: boolean;
dir: Direction;
onOpenChange: OnChangeFn<boolean>;
onOpenChangeComplete: OnChangeFn<boolean>;
}> {}
@ -76,7 +75,7 @@ export class MenuProvider extends Provider<MenuOpts> {
return new MenuProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
readonly floatingProvider: FloatingProvider;
readonly contentPresence: Presence;
readonly typeahead = new Typeahead();
@ -87,7 +86,7 @@ export class MenuProvider extends Provider<MenuOpts> {
private constructor(opts: MenuOpts) {
super(opts, 'DropdownMenu', 'root', attrs.root, MenuProvider.ctx);
this.soma = Soma.getOr();
this.floatingProvider = FloatingProvider.create();
this.contentPresence = new Presence({
@ -99,13 +98,11 @@ export class MenuProvider extends Provider<MenuOpts> {
handleOpen() {
this.opts.open.current = true;
this.opts.onOpenChange.current(true);
}
handleClose() {
this.typeahead.reset();
this.opts.open.current = false;
this.opts.onOpenChange.current(false);
}
setContentRef(el: HTMLElement | null) {
@ -129,12 +126,12 @@ export class MenuTriggerProvider extends Provider<MenuTriggerOpts> {
return new MenuTriggerProvider(opts);
}
readonly root: MenuProvider;
readonly provider: MenuProvider;
private constructor(opts: MenuTriggerOpts) {
super(opts, 'DropdownMenu', 'trigger', attrs.trigger);
this.root = MenuProvider.ctx.get();
this.root.triggerId.current = opts.id.current;
this.provider = MenuProvider.ctx.get();
this.provider.triggerId.current = opts.id.current;
FloatingAnchor.create({
id: opts.id,
@ -143,14 +140,14 @@ export class MenuTriggerProvider extends Provider<MenuTriggerOpts> {
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.root.opts.open.current) this.root.handleClose();
else this.root.handleOpen();
if (this.provider.opts.open.current) this.provider.handleClose();
else this.provider.handleOpen();
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
if (e.key === KEYS.ARROW_DOWN || e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
this.root.handleOpen();
this.provider.handleOpen();
}
};
@ -159,9 +156,9 @@ export class MenuTriggerProvider extends Provider<MenuTriggerOpts> {
...this.baseProps,
type: 'button' as const,
'aria-haspopup': 'menu' as const,
'aria-expanded': this.root.opts.open.current,
'aria-controls': this.root.contentId.current || undefined,
'data-state': getDataOpenClosed(this.root.opts.open.current),
'aria-expanded': this.provider.opts.open.current,
'aria-controls': this.provider.contentId.current || undefined,
'data-state': getDataOpenClosed(this.provider.opts.open.current),
onclick: this.onclick,
onkeydown: this.onkeydown,
} as const),
@ -204,7 +201,7 @@ export class MenuContentProvider extends Provider<MenuContentOpts> {
return new MenuContentProvider(opts);
}
readonly root: MenuProvider;
readonly provider: MenuProvider;
readonly floating: FloatingContent;
readonly focusScope: ReturnType<typeof FocusScope.use>;
readonly dismissal: Dismissal;
@ -213,10 +210,10 @@ export class MenuContentProvider extends Provider<MenuContentOpts> {
private constructor(opts: MenuContentOpts) {
super(opts, 'DropdownMenu', 'content', attrs.content, undefined, (el) => {
this.root.setContentRef(el);
this.provider.setContentRef(el);
});
this.root = MenuProvider.ctx.get();
this.root.contentId.current = opts.id.current;
this.provider = MenuProvider.ctx.get();
this.provider.contentId.current = opts.id.current;
// Floating
this.floating = FloatingContent.create({
@ -237,7 +234,7 @@ export class MenuContentProvider extends Provider<MenuContentOpts> {
onPlaced: readableActive(() => () => {}),
dir: opts.dir,
style: opts.style,
enabled: readableActive(() => this.root.opts.open.current),
enabled: readableActive(() => this.provider.opts.open.current),
customAnchor: readableActive(() => null),
});
@ -248,7 +245,7 @@ export class MenuContentProvider extends Provider<MenuContentOpts> {
trap: opts.trapFocus,
loop: opts.loop,
ref: opts.ref,
enabled: readableActive(() => this.root.contentPresence.isPresent),
enabled: readableActive(() => this.provider.contentPresence.isPresent),
});
// Dismissal — menus always close on outside click (unlike dialogs where modal=true ignores it)
@ -258,15 +255,15 @@ export class MenuContentProvider extends Provider<MenuContentOpts> {
this.dismissal = Dismissal.use({
ref: readableActive(() => opts.ref.current),
enabled: readableActive(() => this.root.opts.open.current),
enabled: readableActive(() => this.provider.opts.open.current),
onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => {
opts.onEscapeKeydown.current(e);
if (!e.defaultPrevented) this.root.handleClose();
if (!e.defaultPrevented) this.provider.handleClose();
}),
escapeKeydownBehavior: opts.escapeKeydownBehavior,
onInteractOutside: readableActive(() => (e: PointerEvent) => {
opts.onInteractOutside.current(e);
if (!e.defaultPrevented) this.root.handleClose();
if (!e.defaultPrevented) this.provider.handleClose();
}),
interactOutsideBehavior: resolvedInteractBehavior,
onFocusOutside: opts.onFocusOutside,
@ -276,19 +273,19 @@ export class MenuContentProvider extends Provider<MenuContentOpts> {
// Scroll lock
this.scrollLock = new ScrollLock();
$effect(() => {
this.scrollLock.locked.current = this.root.opts.open.current && opts.preventScroll.current;
this.scrollLock.locked.current = this.provider.opts.open.current && opts.preventScroll.current;
});
// Text selection
this.textSelection = TextSelection.use({
ref: readableActive(() => opts.ref.current),
enabled: readableActive(() => this.root.opts.open.current),
enabled: readableActive(() => this.provider.opts.open.current),
onPointerDown: readableActive(() => () => {}),
onPointerUp: readableActive(() => () => {}),
});
}
readonly isPresent = $derived.by(() => this.root.contentPresence.isPresent);
readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent);
readonly wrapperProps = $derived.by(() => ({
...this.floating.wrapperProps,
@ -302,10 +299,10 @@ export class MenuContentProvider extends Provider<MenuContentOpts> {
const container = this.opts.ref.current;
if (!container) return;
const items = this.root.getItems(container);
const items = this.provider.getItems(container);
const active = document.activeElement as HTMLElement;
const currentIndex = items.indexOf(active);
const dir = this.root.opts.dir.current;
const dir = this.provider.opts.dir.current;
const { nextKey, prevKey } = getDirectionalKeys(dir, 'vertical');
let targetIndex = -1;
@ -328,7 +325,7 @@ export class MenuContentProvider extends Provider<MenuContentOpts> {
targetIndex = items.length - 1;
} else {
// Typeahead
const match = this.root.typeahead.handle(e.key, items);
const match = this.provider.typeahead.handle(e.key, items);
if (match) match.focus();
return;
}
@ -343,9 +340,9 @@ export class MenuContentProvider extends Provider<MenuContentOpts> {
...this.baseProps,
role: 'menu' as const,
'aria-orientation': 'vertical' as const,
'data-state': getDataOpenClosed(this.root.opts.open.current),
'data-state': getDataOpenClosed(this.provider.opts.open.current),
...this.floating.props,
...this.root.contentPresence.transitionAttrs,
...this.provider.contentPresence.transitionAttrs,
...this.focusScope.props,
...this.dismissal.props,
onkeydown: this.onkeydown,
@ -390,17 +387,17 @@ export class MenuItemProvider extends Provider<MenuItemOpts> {
return new MenuItemProvider(opts);
}
readonly root: MenuProvider;
readonly provider: MenuProvider;
private constructor(opts: MenuItemOpts) {
super(opts, 'DropdownMenu', 'item', attrs.item);
this.root = MenuProvider.ctx.get();
this.provider = MenuProvider.ctx.get();
}
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
if (this.opts.disabled.current) return;
this.opts.onSelect.current();
if (this.opts.closeOnSelect.current) this.root.handleClose();
if (this.opts.closeOnSelect.current) this.provider.handleClose();
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
@ -408,7 +405,7 @@ export class MenuItemProvider extends Provider<MenuItemOpts> {
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
this.opts.onSelect.current();
if (this.opts.closeOnSelect.current) this.root.handleClose();
if (this.opts.closeOnSelect.current) this.provider.handleClose();
}
};
@ -503,7 +500,6 @@ interface MenuCheckboxItemOpts
disabled: boolean;
textValue: string;
closeOnSelect: boolean;
onCheckedChange: OnChangeFn<boolean>;
onSelect: () => void;
}> {}
@ -512,20 +508,19 @@ export class MenuCheckboxItemProvider extends Provider<MenuCheckboxItemOpts> {
return new MenuCheckboxItemProvider(opts);
}
readonly root: MenuProvider;
readonly provider: MenuProvider;
private constructor(opts: MenuCheckboxItemOpts) {
super(opts, 'DropdownMenu', 'checkbox-item', attrs['checkbox-item']);
this.root = MenuProvider.ctx.get();
this.provider = MenuProvider.ctx.get();
}
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
if (this.opts.disabled.current) return;
const next = !this.opts.checked.current;
this.opts.checked.current = next;
this.opts.onCheckedChange.current(next);
this.opts.onSelect.current();
if (this.opts.closeOnSelect.current) this.root.handleClose();
if (this.opts.closeOnSelect.current) this.provider.handleClose();
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
@ -555,8 +550,7 @@ export class MenuCheckboxItemProvider extends Provider<MenuCheckboxItemOpts> {
interface MenuRadioGroupOpts
extends WithRefOpts,
StateProps<{ value: string }>,
ActiveProps<{ onValueChange: OnChangeFn<string> }> {}
StateProps<{ value: string }> {}
export class MenuRadioGroupProvider extends Provider<MenuRadioGroupOpts> {
static readonly ctx = context<MenuRadioGroupProvider>('DropdownMenuRadioGroup');
@ -594,12 +588,12 @@ export class MenuRadioItemProvider extends Provider<MenuRadioItemOpts> {
return new MenuRadioItemProvider(opts);
}
readonly root: MenuProvider;
readonly provider: MenuProvider;
readonly radioGroup: MenuRadioGroupProvider;
private constructor(opts: MenuRadioItemOpts) {
super(opts, 'DropdownMenu', 'radio-item', attrs['radio-item']);
this.root = MenuProvider.ctx.get();
this.provider = MenuProvider.ctx.get();
this.radioGroup = MenuRadioGroupProvider.ctx.get();
}
@ -610,9 +604,8 @@ export class MenuRadioItemProvider extends Provider<MenuRadioItemOpts> {
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
if (this.opts.disabled.current) return;
this.radioGroup.opts.value.current = this.opts.value.current;
this.radioGroup.opts.onValueChange.current(this.opts.value.current);
this.opts.onSelect.current();
if (this.opts.closeOnSelect.current) this.root.handleClose();
if (this.opts.closeOnSelect.current) this.provider.handleClose();
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
@ -640,7 +633,7 @@ export class MenuRadioItemProvider extends Provider<MenuRadioItemOpts> {
// ── Sub ──────────────────────────────────────────────────────────────────────
interface MenuSubOpts extends StateProps<{ open: boolean }>, ActiveProps<{ onOpenChange: OnChangeFn<boolean> }> {}
interface MenuSubOpts extends StateProps<{ open: boolean }> {}
export class MenuSubProvider {
static readonly ctx = context<MenuSubProvider>('DropdownMenuSub');
@ -650,7 +643,7 @@ export class MenuSubProvider {
}
readonly opts: MenuSubOpts;
readonly root: MenuProvider;
readonly provider: MenuProvider;
readonly contentPresence: Presence;
readonly floatingProvider: FloatingProvider;
@ -659,7 +652,7 @@ export class MenuSubProvider {
private constructor(opts: MenuSubOpts) {
this.opts = opts;
this.root = MenuProvider.ctx.get();
this.provider = MenuProvider.ctx.get();
this.floatingProvider = FloatingProvider.create();
this.contentPresence = new Presence({
@ -670,12 +663,10 @@ export class MenuSubProvider {
handleOpen() {
this.opts.open.current = true;
this.opts.onOpenChange.current(true);
}
handleClose() {
this.opts.open.current = false;
this.opts.onOpenChange.current(false);
}
setContentRef(el: HTMLElement | null) {
@ -720,7 +711,7 @@ export class MenuSubTriggerProvider extends Provider<MenuSubTriggerOpts> {
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
if (this.opts.disabled.current) return;
const dir = this.sub.root.opts.dir.current;
const dir = this.sub.provider.opts.dir.current;
const openKey = dir === 'rtl' ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT;
if (e.key === openKey || e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
@ -812,7 +803,7 @@ export class MenuSubContentProvider extends Provider<MenuSubContentOpts> {
}));
readonly onkeydown = (e: KeyboardEvent) => {
const dir = this.sub.root.opts.dir.current;
const dir = this.sub.provider.opts.dir.current;
const closeKey = dir === 'rtl' ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT;
if (e.key === closeKey || e.key === KEYS.ESCAPE) {
e.preventDefault();
@ -822,7 +813,7 @@ export class MenuSubContentProvider extends Provider<MenuSubContentOpts> {
// Arrow nav within sub
const container = this.opts.ref.current;
if (!container) return;
const items = this.sub.root.getItems(container);
const items = this.sub.provider.getItems(container);
const active = document.activeElement as HTMLElement;
const currentIndex = items.indexOf(active);

@ -38,11 +38,17 @@
),
value: writableActive(
() => value,
(v) => (value = v),
(v) => {
value = v;
onValueChange(v);
},
),
editing: writableActive(
() => editing,
(v) => (editing = v),
(v) => {
editing = v;
onEditingChange(v);
},
),
placeholder: readableActive(() => placeholder),
activationMode: readableActive(() => activationMode),
@ -52,10 +58,8 @@
maxLength: readableActive(() => maxLength),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonlyProp),
onValueChange: readableActive(() => onValueChange),
onValueCommit: readableActive(() => onValueCommit),
onValueRevert: readableActive(() => onValueRevert),
onEditingChange: readableActive(() => onEditingChange),
});
const mergedProps = $derived(mergeProps(restProps, state.props));

@ -3,7 +3,8 @@ import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
import { state, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, SomaKeyboardEvent, SomaMouseEvent } from '$soma/types';
import { KEYS } from '$soma/keyboard';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { resolveSomaTranslationPath } from '$soma/core/translator.svelte';
const attrs = createAttrs({
component: 'editable',
@ -30,10 +31,8 @@ interface EditableOpts
maxLength: number | undefined;
disabled: boolean;
readonly: boolean;
onValueChange: OnChangeFn<string>;
onValueCommit: OnChangeFn<string>;
onValueRevert: () => void;
onEditingChange: OnChangeFn<boolean>;
}> {}
export class EditableProvider extends Provider<EditableOpts> {
@ -43,7 +42,7 @@ export class EditableProvider extends Provider<EditableOpts> {
return new EditableProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
// Draft value (in-progress edit)
draft = $state('');
@ -51,7 +50,7 @@ export class EditableProvider extends Provider<EditableOpts> {
private constructor(opts: EditableOpts) {
super(opts, 'Editable', 'root', attrs.root, EditableProvider.ctx);
this.soma = Soma.getOr();
this.draft = opts.value.current;
}
@ -69,7 +68,6 @@ export class EditableProvider extends Provider<EditableOpts> {
this.draft = this.opts.value.current;
this.opts.editing.current = true;
this.opts.onEditingChange.current(true);
// Focus input on next tick
requestAnimationFrame(() => {
@ -89,9 +87,7 @@ export class EditableProvider extends Provider<EditableOpts> {
this.opts.value.current = this.draft;
this.opts.editing.current = false;
this.opts.onValueChange.current(this.draft);
this.opts.onValueCommit.current(this.draft);
this.opts.onEditingChange.current(false);
}
cancel() {
@ -100,7 +96,6 @@ export class EditableProvider extends Provider<EditableOpts> {
this.draft = this.opts.value.current;
this.opts.editing.current = false;
this.opts.onValueRevert.current();
this.opts.onEditingChange.current(false);
}
readonly props = $derived.by(() =>
@ -123,19 +118,19 @@ export class EditableAreaProvider extends Provider<EditableAreaOpts> {
return new EditableAreaProvider(opts);
}
readonly root: EditableProvider;
readonly provider: EditableProvider;
private constructor(opts: EditableAreaOpts) {
super(opts, 'Editable', 'area', attrs.area);
this.root = EditableProvider.ctx.get();
this.provider = EditableProvider.ctx.get();
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-editing': boolToEmptyStrOrUndef(this.root.isEditing),
'data-disabled': boolToEmptyStrOrUndef(this.root.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.root.opts.readonly.current),
'data-editing': boolToEmptyStrOrUndef(this.provider.isEditing),
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
} as const),
);
}
@ -149,46 +144,46 @@ export class EditablePreviewProvider extends Provider<EditablePreviewOpts> {
return new EditablePreviewProvider(opts);
}
readonly root: EditableProvider;
readonly provider: EditableProvider;
private constructor(opts: EditablePreviewOpts) {
super(opts, 'Editable', 'preview', attrs.preview);
this.root = EditableProvider.ctx.get();
this.provider = EditableProvider.ctx.get();
}
readonly displayValue = $derived.by(() =>
this.root.opts.value.current || this.root.opts.placeholder.current,
this.provider.opts.value.current || this.provider.opts.placeholder.current,
);
readonly isPlaceholder = $derived.by(() => !this.root.opts.value.current);
readonly isPlaceholder = $derived.by(() => !this.provider.opts.value.current);
private handleActivation() {
this.root.startEdit();
this.provider.startEdit();
}
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
const mode = this.root.opts.activationMode.current;
const mode = this.provider.opts.activationMode.current;
if (mode === 'click') this.handleActivation();
};
readonly ondblclick = (_e: MouseEvent) => {
const mode = this.root.opts.activationMode.current;
const mode = this.provider.opts.activationMode.current;
if (mode === 'dblclick') this.handleActivation();
};
readonly onfocus = () => {
const mode = this.root.opts.activationMode.current;
const mode = this.provider.opts.activationMode.current;
if (mode === 'focus') this.handleActivation();
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
hidden: this.root.isEditing || undefined,
tabindex: this.root.opts.disabled.current ? undefined : 0,
hidden: this.provider.isEditing || undefined,
tabindex: this.provider.opts.disabled.current ? undefined : 0,
'data-placeholder-shown': boolToEmptyStrOrUndef(this.isPlaceholder),
'data-disabled': boolToEmptyStrOrUndef(this.root.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.root.opts.readonly.current),
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
onclick: this.onclick,
ondblclick: this.ondblclick,
onfocus: this.onfocus,
@ -205,31 +200,31 @@ export class EditableInputProvider extends Provider<EditableInputOpts> {
return new EditableInputProvider(opts);
}
readonly root: EditableProvider;
readonly provider: EditableProvider;
private constructor(opts: EditableInputOpts) {
super(opts, 'Editable', 'input', attrs.input, undefined, (el) => {
this.root.inputRef.current = el;
this.provider.inputRef.current = el;
});
this.root = EditableProvider.ctx.get();
this.provider = EditableProvider.ctx.get();
}
readonly oninput = (e: Event) => {
const target = e.currentTarget as HTMLInputElement | HTMLTextAreaElement;
this.root.draft = target.value;
this.provider.draft = target.value;
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
const submitMode = this.root.opts.submitMode.current;
const submitMode = this.provider.opts.submitMode.current;
if (e.key === KEYS.ENTER && !e.shiftKey) {
if (submitMode === 'enter' || submitMode === 'both') {
e.preventDefault();
this.root.submit();
this.provider.submit();
}
} else if (e.key === KEYS.ESCAPE) {
e.preventDefault();
this.root.cancel();
this.provider.cancel();
}
};
@ -244,27 +239,27 @@ export class EditableInputProvider extends Provider<EditableInputOpts> {
if (isControl) return;
}
const submitMode = this.root.opts.submitMode.current;
const submitMode = this.provider.opts.submitMode.current;
if (submitMode === 'blur' || submitMode === 'both') {
this.root.submit();
this.provider.submit();
} else {
this.root.cancel();
this.provider.cancel();
}
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
hidden: !this.root.isEditing || undefined,
value: this.root.draft,
placeholder: this.root.opts.placeholder.current || undefined,
maxlength: this.root.opts.maxLength.current ?? undefined,
disabled: this.root.opts.disabled.current || undefined,
readonly: this.root.opts.readonly.current || undefined,
'aria-disabled': this.root.opts.disabled.current || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.root.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.root.opts.readonly.current),
style: this.root.opts.autoResize.current
hidden: !this.provider.isEditing || undefined,
value: this.provider.draft,
placeholder: this.provider.opts.placeholder.current || undefined,
maxlength: this.provider.opts.maxLength.current ?? undefined,
disabled: this.provider.opts.disabled.current || undefined,
readonly: this.provider.opts.readonly.current || undefined,
'aria-disabled': this.provider.opts.disabled.current || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
style: this.provider.opts.autoResize.current
? {
'field-sizing': 'content',
'min-width': '2ch',
@ -286,25 +281,25 @@ export class EditableEditTriggerProvider extends Provider<EditableEditTriggerOpt
return new EditableEditTriggerProvider(opts);
}
readonly root: EditableProvider;
readonly provider: EditableProvider;
private constructor(opts: EditableEditTriggerOpts) {
super(opts, 'Editable', 'edit-trigger', attrs['edit-trigger']);
this.root = EditableProvider.ctx.get();
this.provider = EditableProvider.ctx.get();
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.root.startEdit();
this.provider.startEdit();
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' as const,
hidden: this.root.isEditing || undefined,
hidden: this.provider.isEditing || undefined,
'aria-label':
this.root.soma?.translate('editable.edit', 'Edit') ?? 'Edit',
disabled: this.root.opts.disabled.current || this.root.opts.readonly.current || undefined,
resolveSomaTranslationPath('soma.editable.edit', this.provider.config.translator.current, 'Edit'),
disabled: this.provider.opts.disabled.current || this.provider.opts.readonly.current || undefined,
onclick: this.onclick,
} as const),
);
@ -319,24 +314,24 @@ export class EditableSubmitTriggerProvider extends Provider<EditableSubmitTrigge
return new EditableSubmitTriggerProvider(opts);
}
readonly root: EditableProvider;
readonly provider: EditableProvider;
private constructor(opts: EditableSubmitTriggerOpts) {
super(opts, 'Editable', 'submit-trigger', attrs['submit-trigger']);
this.root = EditableProvider.ctx.get();
this.provider = EditableProvider.ctx.get();
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.root.submit();
this.provider.submit();
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' as const,
hidden: !this.root.isEditing || undefined,
hidden: !this.provider.isEditing || undefined,
'aria-label':
this.root.soma?.translate('editable.submit', 'Save') ?? 'Save',
resolveSomaTranslationPath('soma.editable.submit', this.provider.config.translator.current, 'Save'),
onclick: this.onclick,
} as const),
);
@ -351,24 +346,24 @@ export class EditableCancelTriggerProvider extends Provider<EditableCancelTrigge
return new EditableCancelTriggerProvider(opts);
}
readonly root: EditableProvider;
readonly provider: EditableProvider;
private constructor(opts: EditableCancelTriggerOpts) {
super(opts, 'Editable', 'cancel-trigger', attrs['cancel-trigger']);
this.root = EditableProvider.ctx.get();
this.provider = EditableProvider.ctx.get();
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.root.cancel();
this.provider.cancel();
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' as const,
hidden: !this.root.isEditing || undefined,
hidden: !this.provider.isEditing || undefined,
'aria-label':
this.root.soma?.translate('editable.cancel', 'Cancel') ?? 'Cancel',
resolveSomaTranslationPath('soma.editable.cancel', this.provider.config.translator.current, 'Cancel'),
onclick: this.onclick,
} as const),
);

@ -5,6 +5,7 @@ export * as Combobox from './combobox';
export * as Dialog from './dialog';
export * as DropdownMenu from './dropdown-menu';
export * as Editable from './editable';
export * as NumberField from './number-field';
export * as Pagination from './pagination';
export * as Popover from './popover';
export * as RadioGroup from './radio-group';
@ -14,6 +15,7 @@ export * as Slider from './slider';
export * as Splitter from './splitter';
export * as Stepper from './stepper';
export * as Switch from './switch';
export * as Table from './table';
export * as Tabs from './tabs';
export * as TagsInput from './tags-input';
export * as Toast from './toast';

@ -3,7 +3,7 @@
import { getAllContexts, mount, unmount } from 'svelte';
import { watch } from 'runed';
import { isBrowser } from '$soma/dom';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import PortalConsumer from './portal-consumer.svelte';
export type PortalTarget = Element | string;
@ -16,11 +16,11 @@
let { to: toProp, children, disabled }: PortalProps = $props();
const soma = Soma.getOr();
const soma = getSoma();
const context = getAllContexts();
// Resolve portal target: prop → soma → 'body'
const resolvedTo = $derived(toProp ?? soma?.portalTo.current ?? 'body');
const resolvedTo = $derived(toProp ?? soma.portalTo.current ?? 'body');
let target = $derived(getTarget());

@ -1,11 +1,16 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { readableActive } from '$soma/reactive';
import { Soma } from '$soma/core/soma.svelte';
import type { SomaProps } from '$soma/types';
let { children, ...props }: SomaProps & { children?: Snippet } = $props();
let { translator, presentation, portalTo, logger, children }: SomaProps = $props();
Soma.create(props);
Soma.create({
portalTo: readableActive(() => portalTo),
translator: readableActive(() => translator),
presentation: readableActive(() => presentation),
logger: readableActive(() => logger)
});
</script>
{@render children?.()}

@ -0,0 +1,184 @@
# NumberField
A numeric input with increment/decrement triggers, keyboard shortcuts, optional scrubber for drag-based adjustment, mouse wheel support, and locale-aware formatting via `createNumr` from glob. Implements the WAI-ARIA spinbutton pattern.
## Anatomy
```svelte
<NumberField.Provider bind:value min={0} max={100}>
<NumberField.DecrementTrigger>-</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField.Provider>
```
Optional scrubber for pointer-drag adjustment:
```svelte
<NumberField.Provider bind:value>
<NumberField.Scrubber>Drag to adjust</NumberField.Scrubber>
<NumberField.Input />
</NumberField.Provider>
```
## Parts
| Part | Element | Description |
|------|---------|-------------|
| `Provider` | `<div>` | Root container. Manages value, formatting, constraints. |
| `Input` | `<input>` | Text input with `role="spinbutton"`. Handles keyboard, formatting. |
| `IncrementTrigger` | `<button>` | Increment button. Hold to repeat. Auto-disabled at max. |
| `DecrementTrigger` | `<button>` | Decrement button. Hold to repeat. Auto-disabled at min. |
| `Scrubber` | `<div>` | Optional drag area. Pointer drag adjusts value by step. |
## ARIA
| Part | Attribute | Value |
|------|-----------|-------|
| Input | `role` | `spinbutton` |
| Input | `aria-valuenow` | Current numeric value |
| Input | `aria-valuemin` | Minimum (if set) |
| Input | `aria-valuemax` | Maximum (if set) |
| Input | `aria-valuetext` | String representation of value |
| Input | `aria-disabled` | `true` (when disabled) |
| Input | `aria-invalid` | `true` (when invalid) |
| Input | `aria-required` | `true` (when required) |
| Input | `aria-readonly` | `true` (when readonly) |
| IncrementTrigger | `aria-label` | Translated "Increment" |
| DecrementTrigger | `aria-label` | Translated "Decrement" |
## Data Attributes
| Part | Attribute | Values |
|------|-----------|--------|
| Provider | `data-number-field` | Always present |
| Provider | `data-disabled` | Present when disabled |
| Provider | `data-readonly` | Present when readonly |
| Provider | `data-required` | Present when required |
| Provider | `data-invalid` | Present when invalid |
| Provider | `data-focused` | Present when input is focused |
| Provider | `data-scrubbing` | Present during scrub interaction |
| Input | `data-number-field-input` | Always present |
| Input | `data-disabled` | Present when disabled |
| Input | `data-readonly` | Present when readonly |
| Input | `data-invalid` | Present when invalid |
| IncrementTrigger | `data-number-field-increment-trigger` | Always present |
| IncrementTrigger | `data-disabled` | Present when disabled or at max |
| DecrementTrigger | `data-number-field-decrement-trigger` | Always present |
| DecrementTrigger | `data-disabled` | Present when disabled or at min |
| Scrubber | `data-number-field-scrubber` | Always present |
| Scrubber | `data-scrubbing` | Present during drag |
| Scrubber | `data-disabled` | Present when disabled |
## Keyboard
| Key | Action |
|-----|--------|
| `ArrowUp` | Increment by step |
| `ArrowDown` | Decrement by step |
| `Shift+ArrowUp` | Increment by largeStep |
| `Shift+ArrowDown` | Decrement by largeStep |
| `PageUp` | Increment by largeStep |
| `PageDown` | Decrement by largeStep |
| `Home` | Set to minimum |
| `End` | Set to maximum |
## Behavior
### Formatting
Uses `createNumr` from glob with `NumrFormat` for locale-aware formatting and parsing. Two formatters:
- **Display formatter** (`numr`): uses the full consumer format (style, currency, percent, etc.)
- **Edit formatter** (`editNumr`): decimal only, no style decoration. For percent, edits 0.75 as 75.
### Locale resolution
Locale follows: prop → `Soma.translator.getLocale()` → `'en'`.
### Direction resolution
Dir follows: prop → `Soma.opts.presentation.dir` → `'ltr'`.
### Decimal precision
Uses `getDecimalPrecision(step)` to avoid floating point drift on increment/decrement (same approach as terra).
### Hold to repeat
Pressing and holding increment/decrement triggers repeats the action every 100ms.
### Scrubber
Pointer drag on the Scrubber area adjusts the value proportionally. `sensitivity` controls pixels per step (default 5). Supports horizontal and vertical directions.
## Usage
### Basic
```svelte
<script>
import { NumberField } from '$soma/components';
let quantity = $state<number | undefined>(5);
</script>
<NumberField.Provider bind:value={quantity} min={0} max={99}>
<NumberField.DecrementTrigger>-</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField.Provider>
```
### Currency
```svelte
<NumberField.Provider
bind:value={price}
min={0}
step={0.01}
largeStep={1}
formatOptions={{ style: 'currency', currency: 'EUR', minDecimals: 2 }}
>
<NumberField.Input />
</NumberField.Provider>
```
### Percentage
```svelte
<NumberField.Provider
bind:value={percent}
min={0}
max={1}
step={0.01}
formatOptions={{ style: 'percent' }}
>
<!-- Internal 0.75 edits as 75, displays as 75% -->
<NumberField.Input />
</NumberField.Provider>
```
### With scrubber
```svelte
<NumberField.Provider bind:value={opacity} min={0} max={100}>
<NumberField.Scrubber>Opacity</NumberField.Scrubber>
<NumberField.Input />
</NumberField.Provider>
```
## Comparison with reference libraries
| Feature | Ark UI | React Aria | Radix Vue | Base UI | Mantine | Soma |
|---------|--------|------------|-----------|---------|---------|------|
| Parts | Root, Input, Increment, Decrement, Scrubber, Label, ValueText | Hook-based | Root, Input, Increment, Decrement | Root, Input, Increment, Decrement, ScrubArea | Monolithic | Provider, Input, IncrementTrigger, DecrementTrigger, Scrubber |
| `role="spinbutton"` | Yes | Yes | Yes | Yes | No | Yes |
| `aria-valuenow/min/max` | Yes | Yes | Yes | Yes | No | Yes |
| Format via glob (NumrFormat) | No (Intl direct) | No (Intl direct) | No (Intl direct) | No (Intl direct) | No (custom) | Yes (createNumr) |
| Dual formatter (display/edit) | No | No | No | No | No | Yes |
| Percent scaling (0.75 → 75) | No | No | No | No | No | Yes |
| ArrowUp/Down | Yes | Yes | Yes | Yes | No | Yes |
| Shift+Arrow (large step) | No | Yes | No | Yes | No | Yes |
| PageUp/PageDown | Yes | Yes | Yes | No | No | Yes |
| Home/End | Yes | Yes | Yes | No | No | Yes |
| Mouse wheel | Yes | Yes | No | Yes | No | Future |
| Scrubber (drag) | Yes | No | No | Yes | No | Yes |
| Hold to repeat | Yes | Yes | Yes | Yes | Yes | Yes |
| Decimal precision fix | Yes | Yes | No | No | No | Yes |
| onValueCommit | Yes | No | No | Yes | No | Yes |
| disabled/readonly/required/invalid | Yes | Yes | Yes | Yes | Yes | Yes |
| data-scrubbing | Yes | No | No | Yes | No | Yes |
| data-focused | Yes | No | No | Yes | No | Yes |

@ -0,0 +1,30 @@
<script lang="ts">
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { NumberFieldDecrementTriggerProvider } from '../number-field-provider.svelte';
import type { NumberFieldDecrementTriggerProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'number-field-decrement'),
children,
child,
...restProps
}: NumberFieldDecrementTriggerProps = $props();
const state = NumberFieldDecrementTriggerProvider.from({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>{@render children?.()}</button>
{/if}

@ -0,0 +1,30 @@
<script lang="ts">
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { NumberFieldIncrementTriggerProvider } from '../number-field-provider.svelte';
import type { NumberFieldIncrementTriggerProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'number-field-increment'),
children,
child,
...restProps
}: NumberFieldIncrementTriggerProps = $props();
const state = NumberFieldIncrementTriggerProvider.from({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>{@render children?.()}</button>
{/if}

@ -0,0 +1,30 @@
<script lang="ts">
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { NumberFieldInputProvider } from '../number-field-provider.svelte';
import type { NumberFieldInputProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'number-field-input'),
children,
child,
...restProps
}: NumberFieldInputProps = $props();
const state = NumberFieldInputProvider.from({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<input {...mergedProps} />
{/if}

@ -0,0 +1,34 @@
<script lang="ts">
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { NumberFieldScrubberProvider } from '../number-field-provider.svelte';
import type { NumberFieldScrubberProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'number-field-scrubber'),
direction = 'horizontal',
sensitivity = 5,
children,
child,
...restProps
}: NumberFieldScrubberProps = $props();
const state = NumberFieldScrubberProvider.from({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
direction: readableActive(() => direction),
sensitivity: readableActive(() => sensitivity),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>{@render children?.()}</div>
{/if}

@ -0,0 +1,69 @@
<script lang="ts">
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { NumberFieldProvider } from '../number-field-provider.svelte';
import type { NumberFieldProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'number-field'),
value = $bindable(),
formatOptions,
min,
max,
step = 1,
largeStep,
locale,
dir,
allowMouseWheel = false,
clampOnBlur = true,
disabled = false,
readonly: readonlyProp = false,
required = false,
invalid = false,
name,
onValueChange,
onValueCommit,
children,
child,
...restProps
}: NumberFieldProps = $props();
const state = NumberFieldProvider.from({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
value: writableActive(
() => value,
(v) => {
value = v;
onValueChange?.(v);
},
),
formatOptions: readableActive(() => formatOptions),
min: readableActive(() => min),
max: readableActive(() => max),
step: readableActive(() => step),
largeStep: readableActive(() => largeStep),
locale: readableActive(() => locale),
dir: readableActive(() => dir),
allowMouseWheel: readableActive(() => allowMouseWheel),
clampOnBlur: readableActive(() => clampOnBlur),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonlyProp),
required: readableActive(() => required),
invalid: readableActive(() => invalid),
name: readableActive(() => name),
onValueCommit: readableActive(() => onValueCommit),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>{@render children?.()}</div>
{/if}

@ -0,0 +1,15 @@
export { default as Provider } from './components/number-field.svelte';
export { default as Input } from './components/number-field-input.svelte';
export { default as IncrementTrigger } from './components/number-field-increment-trigger.svelte';
export { default as DecrementTrigger } from './components/number-field-decrement-trigger.svelte';
export { default as Scrubber } from './components/number-field-scrubber.svelte';
export type { NumrFormat } from './number-field-provider.svelte';
export type {
NumberFieldProps as ProviderProps,
NumberFieldInputProps as InputProps,
NumberFieldIncrementTriggerProps as IncrementTriggerProps,
NumberFieldDecrementTriggerProps as DecrementTriggerProps,
NumberFieldScrubberProps as ScrubberProps,
} from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,492 @@
import { watch } from 'runed';
import { Provider, context, type WithRefOpts } from '$soma/provider';
import { createAttrs, boolToEmptyStrOrUndef, boolToStr } from '$soma/attrs';
import { state, readableActive, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, Direction, SomaKeyboardEvent, SomaFocusEvent, SomaInputEvent } from '$soma/types';
import { KEYS } from '$soma/keyboard';
import { getSoma } from '$soma/core/soma.svelte';
import { resolveSomaTranslationPath } from '$soma/core/translator.svelte';
import { createNumr, type NumrFormat } from '@/lib';
export type { NumrFormat };
const attrs = createAttrs({
component: 'number-field',
parts: ['root', 'input', 'increment-trigger', 'decrement-trigger', 'scrubber'] as const,
});
// ── Helpers ──────────────────────────────────────────────────────────────────
function getDecimalPrecision(n: number): number {
const str = String(n);
const dot = str.indexOf('.');
return dot >= 0 ? str.length - dot - 1 : 0;
}
// ── Root ─────────────────────────────────────────────────────────────────────
interface NumberFieldOpts
extends WithRefOpts,
StateProps<{ value: number | undefined }>,
ActiveProps<{
min: number | undefined;
max: number | undefined;
step: number;
largeStep: number | undefined;
locale: string | undefined;
dir: Direction | undefined;
formatOptions: NumrFormat | undefined;
clampOnBlur: boolean;
allowMouseWheel: boolean;
disabled: boolean;
readonly: boolean;
required: boolean;
invalid: boolean;
name: string | undefined;
onValueCommit: OnChangeFn<number | undefined> | undefined;
}> {}
export class NumberFieldProvider extends Provider<NumberFieldOpts> {
static readonly ctx = context<NumberFieldProvider>('NumberField');
static from(opts: NumberFieldOpts) {
return new NumberFieldProvider(opts);
}
readonly config = getSoma();
inputRef = state<HTMLInputElement | null>(null);
// Input display value
inputValue = $state('');
focused = $state(false);
// Scrub state
scrubbing = $state(false);
/** Resolved locale: prop → Soma.format.locale → Soma.translator.getLocale() → 'en'. */
readonly locale: Active<string>;
/** Resolved direction: prop → Soma.dir → 'ltr'. */
readonly resolvedDir: Active<Direction>;
private constructor(opts: NumberFieldOpts) {
super(opts, 'NumberField', 'root', attrs.root, NumberFieldProvider.ctx);
this.locale = readableActive(() =>
opts.locale.current
?? this.config.translator.current.getLocale()
?? 'en',
);
this.resolvedDir = readableActive(() =>
opts.dir.current
?? this.config.presentation.current?.dir
?? 'ltr',
);
// Sync inputValue with external value when not focused.
// Must be after locale/resolvedDir assignment since numr depends on locale.
watch.pre(
() => [opts.value.current, this.locale.current, opts.formatOptions.current] as const,
([value]) => {
this.inputValue = this.focused
? this.formatEditValue(value)
: this.formatValue(value);
},
);
}
// ── Computed ──────────────────────────────────────────────────────────────
readonly isDisabled = $derived.by(() => this.opts.disabled.current);
readonly isReadonly = $derived.by(() => this.opts.readonly.current);
readonly isRequired = $derived.by(() => this.opts.required.current);
readonly isInvalid = $derived.by(() => this.opts.invalid.current);
readonly step = $derived.by(() => this.opts.step.current);
readonly largeStep = $derived.by(() => this.opts.largeStep.current ?? this.step * 10);
readonly min = $derived.by(() => this.opts.min.current);
readonly max = $derived.by(() => this.opts.max.current);
readonly dir = $derived.by(() => this.resolvedDir.current);
/**
* Display formatter: uses the full consumer format (style, currency, percent...).
* Recreated when formatOptions or locale change.
*/
readonly numr = $derived.by(() => {
const fmt: NumrFormat = { ...(this.opts.formatOptions.current ?? {}) };
return createNumr(fmt, this.locale.current);
});
/**
* Edit formatter: always decimal, no style decoration.
* Allows the user to edit "75" instead of "75%" or "$1,234.56".
* For `percent`, edit value is `value * 100`.
*/
readonly editNumr = $derived.by(() => {
const fmt = this.opts.formatOptions.current ?? {};
const editFmt: NumrFormat = {
minDecimals: fmt.minDecimals,
maxDecimals: fmt.maxDecimals,
grouping: fmt.grouping,
};
return createNumr(editFmt, this.locale.current);
});
readonly atMin = $derived.by(
() => this.min !== undefined
&& this.opts.value.current !== undefined
&& this.opts.value.current <= this.min,
);
readonly atMax = $derived.by(
() => this.max !== undefined
&& this.opts.value.current !== undefined
&& this.opts.value.current >= this.max,
);
// ── Format / Parse ───────────────────────────────────────────────────────
/** For percent, internal 0.75 is edited as 75. */
toEditValue(value: number): number {
return this.opts.formatOptions.current?.style === 'percent' ? value * 100 : value;
}
fromEditValue(editValue: number): number {
return this.opts.formatOptions.current?.style === 'percent' ? editValue / 100 : editValue;
}
/** Format for display: applies style, currency, %, etc. */
formatValue(value: number | undefined): string {
if (value === undefined || Number.isNaN(value)) return '';
return this.numr.f(value);
}
/** Format for editing: pure decimal, value scaled by style. */
formatEditValue(value: number | undefined): string {
if (value === undefined || Number.isNaN(value)) return '';
return this.editNumr.f(this.toEditValue(value));
}
/** Parse edit string and convert to internal value. */
parseValue(raw: string): number | undefined {
if (!raw.trim()) return undefined;
const n = this.editNumr.parse(raw);
if (Number.isNaN(n)) return undefined;
return this.fromEditValue(n);
}
clamp(value: number): number {
let v = value;
if (this.min !== undefined) v = Math.max(this.min, v);
if (this.max !== undefined) v = Math.min(this.max, v);
return v;
}
round(value: number, step: number): number {
const precision = getDecimalPrecision(step);
const factor = Math.pow(10, precision);
return Math.round(value * factor) / factor;
}
commit(value: number | undefined): void {
if (value === undefined) {
this.opts.value.current = undefined;
this.inputValue = '';
return;
}
const clamped = this.clamp(this.round(value, this.step));
this.opts.value.current = clamped;
this.opts.onValueCommit.current?.(clamped);
this.inputValue = this.focused ? this.formatEditValue(clamped) : this.formatValue(clamped);
}
// ── Actions ──────────────────────────────────────────────────────────────
increment(by: number = this.step): void {
if (this.isDisabled || this.isReadonly) return;
const current = this.opts.value.current ?? 0;
this.commit(current + by);
}
decrement(by: number = this.step): void {
if (this.isDisabled || this.isReadonly) return;
const current = this.opts.value.current ?? 0;
this.commit(current - by);
}
// ── Event handlers ───────────────────────────────────────────────────────
readonly handleFocus = (_e: SomaFocusEvent<HTMLInputElement>): void => {
this.focused = true;
const raw = this.opts.value.current;
this.inputValue = raw !== undefined ? this.formatEditValue(raw) : '';
};
readonly handleBlur = (_e: SomaFocusEvent<HTMLInputElement>): void => {
this.focused = false;
const parsed = this.parseValue(this.inputValue);
this.commit(parsed);
};
readonly handleInput = (e: SomaInputEvent<HTMLInputElement>): void => {
this.inputValue = e.currentTarget.value;
};
readonly handleKeydown = (e: SomaKeyboardEvent<HTMLInputElement>): void => {
if (this.isDisabled || this.isReadonly) return;
switch (e.key) {
case KEYS.ARROW_UP:
e.preventDefault();
this.increment(e.shiftKey ? this.largeStep : this.step);
break;
case KEYS.ARROW_DOWN:
e.preventDefault();
this.decrement(e.shiftKey ? this.largeStep : this.step);
break;
case 'PageUp':
e.preventDefault();
this.increment(this.largeStep);
break;
case 'PageDown':
e.preventDefault();
this.decrement(this.largeStep);
break;
case KEYS.HOME:
if (this.min !== undefined) {
e.preventDefault();
this.commit(this.min);
}
break;
case KEYS.END:
if (this.max !== undefined) {
e.preventDefault();
this.commit(this.max);
}
break;
}
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.isReadonly),
'data-required': boolToEmptyStrOrUndef(this.isRequired),
'data-invalid': boolToEmptyStrOrUndef(this.isInvalid),
'data-focused': boolToEmptyStrOrUndef(this.focused),
'data-scrubbing': boolToEmptyStrOrUndef(this.scrubbing),
} as const),
);
}
// ── Input ────────────────────────────────────────────────────────────────────
interface NumberFieldInputOpts extends WithRefOpts {}
export class NumberFieldInputProvider extends Provider<NumberFieldInputOpts> {
static from(opts: NumberFieldInputOpts) {
return new NumberFieldInputProvider(opts);
}
readonly provider: NumberFieldProvider;
private constructor(opts: NumberFieldInputOpts) {
super(opts, 'NumberField', 'input', attrs.input);
this.provider = NumberFieldProvider.ctx.get();
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'spinbutton' as const,
inputmode: 'decimal' as const,
autocomplete: 'off' as const,
autocorrect: 'off' as const,
spellcheck: false,
dir: this.provider.dir,
value: this.provider.inputValue,
'aria-valuenow': this.provider.opts.value.current,
'aria-valuemin': this.provider.opts.min.current,
'aria-valuemax': this.provider.opts.max.current,
'aria-valuetext': this.provider.opts.value.current !== undefined
? String(this.provider.opts.value.current)
: undefined,
'aria-required': boolToStr(this.provider.isRequired),
'aria-disabled': boolToStr(this.provider.isDisabled),
'aria-readonly': boolToStr(this.provider.isReadonly),
'aria-invalid': boolToStr(this.provider.isInvalid),
disabled: this.provider.isDisabled || undefined,
readonly: this.provider.isReadonly || undefined,
required: this.provider.isRequired || undefined,
name: this.provider.opts.name.current || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.provider.isReadonly),
'data-invalid': boolToEmptyStrOrUndef(this.provider.isInvalid),
onfocus: this.provider.handleFocus,
onblur: this.provider.handleBlur,
oninput: this.provider.handleInput,
onkeydown: this.provider.handleKeydown,
} as const),
);
}
// ── IncrementTrigger ─────────────────────────────────────────────────────────
interface NumberFieldIncrementTriggerOpts extends WithRefOpts {}
export class NumberFieldIncrementTriggerProvider extends Provider<NumberFieldIncrementTriggerOpts> {
static from(opts: NumberFieldIncrementTriggerOpts) {
return new NumberFieldIncrementTriggerProvider(opts);
}
readonly provider: NumberFieldProvider;
private constructor(opts: NumberFieldIncrementTriggerOpts) {
super(opts, 'NumberField', 'increment-trigger', attrs['increment-trigger']);
this.provider = NumberFieldProvider.ctx.get();
}
readonly isDisabled = $derived.by(() => this.provider.isDisabled || this.provider.atMax);
readonly handleClick = () => { this.provider.increment(); };
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' as const,
tabindex: -1,
disabled: this.isDisabled || undefined,
'aria-label':
resolveSomaTranslationPath('soma.number-field.increment', this.provider.config.translator.current, 'Increment'),
'aria-disabled': boolToStr(this.isDisabled),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
onclick: this.handleClick,
} as const),
);
}
// ── DecrementTrigger ─────────────────────────────────────────────────────────
interface NumberFieldDecrementTriggerOpts extends WithRefOpts {}
export class NumberFieldDecrementTriggerProvider extends Provider<NumberFieldDecrementTriggerOpts> {
static from(opts: NumberFieldDecrementTriggerOpts) {
return new NumberFieldDecrementTriggerProvider(opts);
}
readonly provider: NumberFieldProvider;
private constructor(opts: NumberFieldDecrementTriggerOpts) {
super(opts, 'NumberField', 'decrement-trigger', attrs['decrement-trigger']);
this.provider = NumberFieldProvider.ctx.get();
}
readonly isDisabled = $derived.by(() => this.provider.isDisabled || this.provider.atMin);
readonly handleClick = () => { this.provider.decrement(); };
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' as const,
tabindex: -1,
disabled: this.isDisabled || undefined,
'aria-label':
resolveSomaTranslationPath('soma.number-field.decrement', this.provider.config.translator.current, 'Decrement'),
'aria-disabled': boolToStr(this.isDisabled),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
onclick: this.handleClick,
} as const),
);
}
// ── Scrubber ─────────────────────────────────────────────────────────────────
interface NumberFieldScrubberOpts
extends WithRefOpts,
ActiveProps<{
direction: 'horizontal' | 'vertical';
sensitivity: number;
}> {}
export class NumberFieldScrubberProvider extends Provider<NumberFieldScrubberOpts> {
static from(opts: NumberFieldScrubberOpts) {
return new NumberFieldScrubberProvider(opts);
}
readonly provider: NumberFieldProvider;
private startX = 0;
private startY = 0;
private startValue = 0;
private constructor(opts: NumberFieldScrubberOpts) {
super(opts, 'NumberField', 'scrubber', attrs.scrubber);
this.provider = NumberFieldProvider.ctx.get();
}
private get isDisabled(): boolean {
return this.provider.isDisabled || this.provider.isReadonly;
}
readonly onpointerdown = (e: PointerEvent) => {
if (this.isDisabled || e.button !== 0) return;
e.preventDefault();
this.provider.scrubbing = true;
this.startX = e.clientX;
this.startY = e.clientY;
this.startValue = this.provider.opts.value.current ?? 0;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
readonly onpointermove = (e: PointerEvent) => {
if (!this.provider.scrubbing) return;
e.preventDefault();
const sensitivity = this.opts.sensitivity.current;
const step = this.provider.step;
const isHorizontal = this.opts.direction.current === 'horizontal';
const delta = isHorizontal
? e.clientX - this.startX
: -(e.clientY - this.startY);
const steps = Math.round(delta / sensitivity);
const newValue = this.startValue + steps * step;
this.provider.commit(newValue);
};
readonly onpointerup = () => {
if (!this.provider.scrubbing) return;
this.provider.scrubbing = false;
};
readonly onlostpointercapture = () => {
if (this.provider.scrubbing) {
this.provider.scrubbing = false;
}
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-scrubbing': boolToEmptyStrOrUndef(this.provider.scrubbing),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
style: {
cursor: this.isDisabled
? 'default'
: this.opts.direction.current === 'horizontal'
? 'ew-resize'
: 'ns-resize',
'user-select': this.provider.scrubbing ? 'none' : undefined,
'touch-action': 'none',
},
onpointerdown: this.onpointerdown,
onpointermove: this.onpointermove,
onpointerup: this.onpointerup,
onlostpointercapture: this.onlostpointercapture,
} as const),
);
}

@ -0,0 +1,75 @@
import type { WithChild, Without, OnChangeFn, Direction } from '$soma/types';
import type { PrimitiveDivAttributes, PrimitiveButtonAttributes, PrimitiveInputAttributes } from '$soma/types';
import type { NumrFormat } from './number-field-provider.svelte';
export type NumberFieldProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Current numeric value. Bindable. @default undefined */
value?: number;
/**
* Number format definition (style, currency, unit, decimals, grouping, etc.).
* Uses NumrFormat from glob. Determines display formatting and edit parsing.
* @see NumrFormat
*/
formatOptions?: NumrFormat;
/** Minimum allowed value. */
min?: number;
/** Maximum allowed value. */
max?: number;
/** Increment/decrement amount. @default 1 */
step?: number;
/** Large step for Shift+Arrow or PageUp/PageDown. @default step * 10 */
largeStep?: number;
/** BCP 47 locale. Resolved via: prop → Soma.format.locale → 'en'. */
locale?: string;
/** Text direction. Resolved via: prop → Soma.dir → 'ltr'. */
dir?: Direction;
/** Allow mouse wheel to increment/decrement when focused. @default false */
allowMouseWheel?: boolean;
/** Clamp value to min/max on blur. @default true */
clampOnBlur?: boolean;
/** Disables all interaction. @default false */
disabled?: boolean;
/** Prevents value changes but keeps focusable. @default false */
readonly?: boolean;
/** Marks as required for form validation. @default false */
required?: boolean;
/** Marks as invalid for form validation. @default false */
invalid?: boolean;
/** Form submission field name. */
name?: string;
/** Callback fired when value changes. */
onValueChange?: OnChangeFn<number | undefined>;
/** Callback fired when value is committed (on blur or Enter). */
onValueCommit?: OnChangeFn<number | undefined>;
}> &
Without<PrimitiveDivAttributes, {}>;
export type NumberFieldInputProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveInputAttributes, {}>;
export type NumberFieldIncrementTriggerProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveButtonAttributes, {}>;
export type NumberFieldDecrementTriggerProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveButtonAttributes, {}>;
export type NumberFieldScrubberProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Drag direction. @default 'horizontal' */
direction?: 'horizontal' | 'vertical';
/** Pixels per step during scrub. @default 5 */
sensitivity?: number;
}> &
Without<PrimitiveDivAttributes, {}>;

@ -31,14 +31,16 @@
),
page: writableActive(
() => page,
(v) => (page = v),
(v) => {
page = v;
onPageChange(v);
},
),
count: readableActive(() => count),
pageSize: readableActive(() => pageSize),
siblingCount: readableActive(() => siblingCount),
boundaryCount: readableActive(() => boundaryCount),
disabled: readableActive(() => disabled),
onPageChange: readableActive(() => onPageChange),
});
const mergedProps = $derived(mergeProps(restProps, state.props));

@ -1,8 +1,9 @@
import { Provider, context, type WithRefOpts } from '$soma/provider';
import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
import { type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, SomaMouseEvent } from '$soma/types';
import { Soma } from '$soma/core/soma.svelte';
import type { SomaMouseEvent } from '$soma/types';
import { getSoma } from '$soma/core/soma.svelte';
import { resolveSomaTranslationPath } from '$soma/core/translator.svelte';
const attrs = createAttrs({
component: 'pagination',
@ -102,7 +103,6 @@ interface PaginationOpts
siblingCount: number;
boundaryCount: number;
disabled: boolean;
onPageChange: OnChangeFn<number>;
}> {}
export class PaginationProvider extends Provider<PaginationOpts> {
@ -112,11 +112,11 @@ export class PaginationProvider extends Provider<PaginationOpts> {
return new PaginationProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
private constructor(opts: PaginationOpts) {
super(opts, 'Pagination', 'root', attrs.root, PaginationProvider.ctx);
this.soma = Soma.getOr();
}
readonly totalPages = $derived.by(() => {
@ -152,7 +152,6 @@ export class PaginationProvider extends Provider<PaginationOpts> {
if (page < 1 || page > this.totalPages) return;
if (page === this.opts.page.current) return;
this.opts.page.current = page;
this.opts.onPageChange.current(page);
}
goToPrevPage() {
@ -182,7 +181,7 @@ export class PaginationProvider extends Provider<PaginationOpts> {
...this.baseProps,
role: 'navigation' as const,
'aria-label':
this.soma?.translate('pagination.label', 'Pagination') ?? 'Pagination',
resolveSomaTranslationPath('soma.pagination.label', this.config.translator.current, 'Pagination'),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
} as const),
);
@ -201,20 +200,20 @@ export class PaginationPrevTriggerProvider extends Provider<PaginationPrevTrigge
return new PaginationPrevTriggerProvider(opts);
}
readonly root: PaginationProvider;
readonly provider: PaginationProvider;
private constructor(opts: PaginationPrevTriggerOpts) {
super(opts, 'Pagination', 'prev-trigger', attrs['prev-trigger']);
this.root = PaginationProvider.ctx.get();
this.provider = PaginationProvider.ctx.get();
}
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || this.root.opts.disabled.current || !this.root.hasPrevPage,
() => this.opts.disabled.current || this.provider.opts.disabled.current || !this.provider.hasPrevPage,
);
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.isDisabled) return;
this.root.goToPrevPage();
this.provider.goToPrevPage();
};
readonly props = $derived.by(() =>
@ -222,7 +221,7 @@ export class PaginationPrevTriggerProvider extends Provider<PaginationPrevTrigge
...this.baseProps,
type: 'button' as const,
'aria-label':
this.root.soma?.translate('pagination.prev', 'Previous page') ?? 'Previous page',
resolveSomaTranslationPath('soma.pagination.prev', this.provider.config.translator.current, 'Previous page'),
disabled: this.isDisabled || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
onclick: this.onclick,
@ -243,20 +242,20 @@ export class PaginationNextTriggerProvider extends Provider<PaginationNextTrigge
return new PaginationNextTriggerProvider(opts);
}
readonly root: PaginationProvider;
readonly provider: PaginationProvider;
private constructor(opts: PaginationNextTriggerOpts) {
super(opts, 'Pagination', 'next-trigger', attrs['next-trigger']);
this.root = PaginationProvider.ctx.get();
this.provider = PaginationProvider.ctx.get();
}
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || this.root.opts.disabled.current || !this.root.hasNextPage,
() => this.opts.disabled.current || this.provider.opts.disabled.current || !this.provider.hasNextPage,
);
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.isDisabled) return;
this.root.goToNextPage();
this.provider.goToNextPage();
};
readonly props = $derived.by(() =>
@ -264,7 +263,7 @@ export class PaginationNextTriggerProvider extends Provider<PaginationNextTrigge
...this.baseProps,
type: 'button' as const,
'aria-label':
this.root.soma?.translate('pagination.next', 'Next page') ?? 'Next page',
resolveSomaTranslationPath('soma.pagination.next', this.provider.config.translator.current, 'Next page'),
disabled: this.isDisabled || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
onclick: this.onclick,
@ -285,22 +284,22 @@ export class PaginationItemProvider extends Provider<PaginationItemOpts> {
return new PaginationItemProvider(opts);
}
readonly root: PaginationProvider;
readonly provider: PaginationProvider;
private constructor(opts: PaginationItemOpts) {
super(opts, 'Pagination', 'item', attrs.item);
this.root = PaginationProvider.ctx.get();
this.provider = PaginationProvider.ctx.get();
}
readonly isSelected = $derived.by(
() => this.root.opts.page.current === this.opts.value.current,
() => this.provider.opts.page.current === this.opts.value.current,
);
readonly isDisabled = $derived.by(() => this.root.opts.disabled.current);
readonly isDisabled = $derived.by(() => this.provider.opts.disabled.current);
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.isDisabled) return;
this.root.setPage(this.opts.value.current);
this.provider.setPage(this.opts.value.current);
};
readonly props = $derived.by(() =>

@ -3,13 +3,13 @@
import { mergeProps } from '$soma/props';
import { styleToString } from '$soma/css';
import { createId } from '$soma/id';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { PopoverContentProvider } from '../popover-provider.svelte';
import type { PopoverContentProps } from '../types';
import { noop } from '$lib/util/funcs';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
ref = $bindable(null),
@ -40,7 +40,7 @@
interactOutsideBehavior,
onFocusOutside = noop,
onPlaced = noop,
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
style = null,
children,
child,
@ -94,10 +94,10 @@
{#if state.isPresent || forceMount}
<div {...state.wrapperProps} style={wrapperStyleStr}>
{#if child}
{@render child({ open: state.root.opts.open.current, props: mergedProps })}
{@render child({ open: state.provider.opts.open.current, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.({ open: state.root.opts.open.current })}
{@render children?.({ open: state.provider.opts.open.current })}
</div>
{/if}
</div>

@ -1,4 +1,4 @@
<script lang="ts">
n<script lang="ts">
import { readableActive, writableActive } from '$soma/reactive';
import { createId } from '$soma/id';
import { PopoverProvider } from '../popover-provider.svelte';
@ -20,9 +20,11 @@
id: readableActive(() => id),
open: writableActive(
() => open,
(v) => (open = v),
(v) => {
open = v;
onOpenChange(v);
},
),
onOpenChange: readableActive(() => onOpenChange),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
modal: readableActive(() => modal),
});

@ -3,7 +3,8 @@ import { createAttrs, getDataOpenClosed } from '$soma/attrs';
import { readableActive, state, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, SomaMouseEvent, SomaKeyboardEvent, Direction, Arrayable, StyleProperties } from '$soma/types';
import { KEYS } from '$soma/keyboard';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { resolveSomaTranslationPath } from '$soma/core/translator.svelte';
import { watch } from 'runed';
import { untrack } from 'svelte';
@ -35,7 +36,6 @@ interface PopoverOpts
StateProps<{ open: boolean }>,
ActiveProps<{
modal: boolean;
onOpenChange: OnChangeFn<boolean>;
onOpenChangeComplete: OnChangeFn<boolean>;
}> {}
@ -46,7 +46,7 @@ export class PopoverProvider extends Provider<PopoverOpts> {
return new PopoverProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
readonly floatingProvider: FloatingProvider;
// presence
@ -61,7 +61,7 @@ export class PopoverProvider extends Provider<PopoverOpts> {
private constructor(opts: PopoverOpts) {
super(opts, 'Popover', 'provider', attrs.provider, PopoverProvider.ctx);
this.soma = Soma.getOr();
this.floatingProvider = FloatingProvider.create();
this.contentPresence = new Presence({
@ -78,12 +78,10 @@ export class PopoverProvider extends Provider<PopoverOpts> {
handleOpen() {
this.opts.open.current = true;
this.opts.onOpenChange.current(true);
}
handleClose() {
this.opts.open.current = false;
this.opts.onOpenChange.current(false);
}
toggle() {
@ -100,7 +98,7 @@ export class PopoverProvider extends Provider<PopoverOpts> {
}
translate(path: string, fallback: string): string {
return this.soma?.translate(`popover.${path}`, fallback) ?? fallback;
return resolveSomaTranslationPath(`soma.popover.${path}`, this.config.translator.current, fallback);
}
}
@ -119,13 +117,13 @@ export class PopoverTriggerProvider extends Provider<PopoverTriggerOpts> {
return new PopoverTriggerProvider(opts);
}
readonly root: PopoverProvider;
readonly provider: PopoverProvider;
private hoverTimeout: ReturnType<typeof setTimeout> | null = null;
private constructor(opts: PopoverTriggerOpts) {
super(opts, 'Popover', 'trigger', attrs.trigger);
this.root = PopoverProvider.ctx.get();
this.root.triggerId.current = opts.id.current;
this.provider = PopoverProvider.ctx.get();
this.provider.triggerId.current = opts.id.current;
// Register as floating anchor
FloatingAnchor.create({
@ -135,13 +133,13 @@ export class PopoverTriggerProvider extends Provider<PopoverTriggerOpts> {
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.root.toggle();
this.provider.toggle();
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
this.root.toggle();
this.provider.toggle();
}
};
@ -149,7 +147,7 @@ export class PopoverTriggerProvider extends Provider<PopoverTriggerOpts> {
if (!this.opts.openOnHover.current) return;
this.clearHoverTimeout();
this.hoverTimeout = setTimeout(() => {
this.root.handleOpen();
this.provider.handleOpen();
}, this.opts.openDelay.current);
};
@ -157,7 +155,7 @@ export class PopoverTriggerProvider extends Provider<PopoverTriggerOpts> {
if (!this.opts.openOnHover.current) return;
this.clearHoverTimeout();
this.hoverTimeout = setTimeout(() => {
this.root.handleClose();
this.provider.handleClose();
}, this.opts.closeDelay.current);
};
@ -173,9 +171,9 @@ export class PopoverTriggerProvider extends Provider<PopoverTriggerOpts> {
...this.baseProps,
type: 'button' as const,
'aria-haspopup': 'dialog' as const,
'aria-expanded': this.root.opts.open.current,
'aria-controls': this.root.contentId.current || undefined,
'data-state': getDataOpenClosed(this.root.opts.open.current),
'aria-expanded': this.provider.opts.open.current,
'aria-controls': this.provider.contentId.current || undefined,
'data-state': getDataOpenClosed(this.provider.opts.open.current),
onclick: this.onclick,
onkeydown: this.onkeydown,
onpointerenter: this.onpointerenter,
@ -223,7 +221,7 @@ export class PopoverContentProvider extends Provider<PopoverContentOpts> {
return new PopoverContentProvider(opts);
}
readonly root: PopoverProvider;
readonly provider: PopoverProvider;
readonly floating: FloatingContent;
// Behavior layers
@ -234,10 +232,10 @@ export class PopoverContentProvider extends Provider<PopoverContentOpts> {
private constructor(opts: PopoverContentOpts) {
super(opts, 'Popover', 'content', attrs.content, undefined, (el) => {
this.root.setContentRef(el);
this.provider.setContentRef(el);
});
this.root = PopoverProvider.ctx.get();
this.root.contentId.current = opts.id.current;
this.provider = PopoverProvider.ctx.get();
this.provider.contentId.current = opts.id.current;
// Floating positioning
this.floating = FloatingContent.create({
@ -258,7 +256,7 @@ export class PopoverContentProvider extends Provider<PopoverContentOpts> {
onPlaced: opts.onPlaced,
dir: opts.dir,
style: opts.style,
enabled: readableActive(() => this.root.opts.open.current),
enabled: readableActive(() => this.provider.opts.open.current),
customAnchor: opts.customAnchor,
});
@ -269,12 +267,12 @@ export class PopoverContentProvider extends Provider<PopoverContentOpts> {
trap: opts.trapFocus,
loop: opts.loop,
ref: opts.ref,
enabled: readableActive(() => this.root.contentPresence.isPresent),
enabled: readableActive(() => this.provider.contentPresence.isPresent),
});
// Dismissal — resolve behaviors from modal
const resolvedInteractBehavior = readableActive(
() => opts.interactOutsideBehavior.current ?? (this.root.opts.modal.current ? 'ignore' : 'close'),
() => opts.interactOutsideBehavior.current ?? (this.provider.opts.modal.current ? 'ignore' : 'close'),
);
const resolvedEscapeBehavior = readableActive(
() => opts.escapeKeydownBehavior.current ?? 'close',
@ -282,15 +280,15 @@ export class PopoverContentProvider extends Provider<PopoverContentOpts> {
this.dismissal = Dismissal.use({
ref: readableActive(() => opts.ref.current),
enabled: readableActive(() => this.root.opts.open.current),
enabled: readableActive(() => this.provider.opts.open.current),
onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => {
opts.onEscapeKeydown.current(e);
if (!e.defaultPrevented) this.root.handleClose();
if (!e.defaultPrevented) this.provider.handleClose();
}),
escapeKeydownBehavior: resolvedEscapeBehavior,
onInteractOutside: readableActive(() => (e: PointerEvent) => {
opts.onInteractOutside.current(e);
if (!e.defaultPrevented) this.root.handleClose();
if (!e.defaultPrevented) this.provider.handleClose();
}),
interactOutsideBehavior: resolvedInteractBehavior,
onFocusOutside: opts.onFocusOutside,
@ -300,19 +298,19 @@ export class PopoverContentProvider extends Provider<PopoverContentOpts> {
// Scroll lock
this.scrollLock = new ScrollLock(undefined, () => opts.restoreScrollDelay.current);
$effect(() => {
this.scrollLock.locked.current = this.root.opts.open.current && opts.preventScroll.current;
this.scrollLock.locked.current = this.provider.opts.open.current && opts.preventScroll.current;
});
// Text selection
this.textSelection = TextSelection.use({
ref: readableActive(() => opts.ref.current),
enabled: readableActive(() => this.root.opts.open.current),
enabled: readableActive(() => this.provider.opts.open.current),
onPointerDown: readableActive(() => () => {}),
onPointerUp: readableActive(() => () => {}),
});
}
readonly isPresent = $derived.by(() => this.root.contentPresence.isPresent);
readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent);
readonly wrapperProps = $derived.by(() => ({
...this.floating.wrapperProps,
@ -326,9 +324,9 @@ export class PopoverContentProvider extends Provider<PopoverContentOpts> {
this.assertProps({
...this.baseProps,
role: 'dialog' as const,
'data-state': getDataOpenClosed(this.root.opts.open.current),
'data-state': getDataOpenClosed(this.provider.opts.open.current),
...this.floating.props,
...this.root.contentPresence.transitionAttrs,
...this.provider.contentPresence.transitionAttrs,
...this.focusScope.props,
...this.dismissal.props,
} as const),
@ -368,22 +366,22 @@ export class PopoverCloseProvider extends Provider<PopoverCloseOpts> {
return new PopoverCloseProvider(opts);
}
readonly root: PopoverProvider;
readonly provider: PopoverProvider;
private constructor(opts: PopoverCloseOpts) {
super(opts, 'Popover', 'close', attrs.close);
this.root = PopoverProvider.ctx.get();
this.provider = PopoverProvider.ctx.get();
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.root.handleClose();
this.provider.handleClose();
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' as const,
'aria-label': this.root.translate('close', 'Close'),
'aria-label': this.provider.translate('close', 'Close'),
onclick: this.onclick,
} as const),
);
@ -424,24 +422,24 @@ export class PopoverOverlayProvider extends Provider<PopoverOverlayOpts> {
return new PopoverOverlayProvider(opts);
}
readonly root: PopoverProvider;
readonly provider: PopoverProvider;
private constructor(opts: PopoverOverlayOpts) {
super(opts, 'Popover', 'overlay', attrs.overlay, undefined, (el) => {
this.root.setOverlayRef(el);
this.provider.setOverlayRef(el);
});
this.root = PopoverProvider.ctx.get();
this.provider = PopoverProvider.ctx.get();
}
readonly isPresent = $derived.by(() => this.root.overlayPresence.isPresent);
readonly isPresent = $derived.by(() => this.provider.overlayPresence.isPresent);
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-state': getDataOpenClosed(this.root.opts.open.current),
'data-state': getDataOpenClosed(this.provider.opts.open.current),
'aria-hidden': true as const,
style: { 'pointer-events': 'auto' },
...this.root.overlayPresence.transitionAttrs,
...this.provider.overlayPresence.transitionAttrs,
} as const),
);
}

@ -32,7 +32,7 @@
{:else}
<button {...mergedProps}>
{@render children?.({ checked: state.isChecked })}
{#if state.root.opts.name.current}
{#if state.provider.opts.name.current}
<input {...state.inputProps} />
{/if}
</button>

@ -2,13 +2,13 @@
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { RadioGroupProvider } from '../radio-group-provider.svelte';
import type { RadioGroupProps } from '../types';
import { noop } from '$lib/util/funcs';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
ref = $bindable(null),
@ -20,7 +20,7 @@
readonly = false,
orientation = 'vertical',
loop = false,
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
name = '',
children,
child,
@ -30,8 +30,13 @@
const state = RadioGroupProvider.from({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
value: writableActive(() => value, (v) => (value = v)),
onValueChange: readableActive(() => onValueChange),
value: writableActive(
() => value,
(v) => {
value = v;
onValueChange(v);
},
),
disabled: readableActive(() => disabled),
required: readableActive(() => required),
readonly: readableActive(() => readonly),

@ -1,7 +1,7 @@
import { Provider, context, type WithRefOpts } from '$soma/provider';
import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
import { type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, Orientation, Direction, SomaMouseEvent, SomaKeyboardEvent } from '$soma/types';
import type { Orientation, Direction, SomaMouseEvent, SomaKeyboardEvent } from '$soma/types';
import { KEYS, getDirectionalKeys } from '$soma/keyboard';
import { Soma } from '$soma/core/soma.svelte';
@ -23,7 +23,6 @@ interface RadioGroupOpts
loop: boolean;
dir: Direction;
name: string;
onValueChange: OnChangeFn<string>;
}> {}
export class RadioGroupProvider extends Provider<RadioGroupOpts> {
@ -40,7 +39,6 @@ export class RadioGroupProvider extends Provider<RadioGroupOpts> {
selectItem(value: string) {
if (this.opts.disabled.current || this.opts.readonly.current) return;
this.opts.value.current = value;
this.opts.onValueChange.current(value);
}
/** Get all enabled radio items for keyboard navigation. */
@ -80,35 +78,35 @@ export class RadioItemProvider extends Provider<RadioItemOpts> {
return new RadioItemProvider(opts);
}
readonly root: RadioGroupProvider;
readonly provider: RadioGroupProvider;
private constructor(opts: RadioItemOpts) {
super(opts, 'RadioGroup', 'item', attrs.item);
this.root = RadioGroupProvider.ctx.get();
this.provider = RadioGroupProvider.ctx.get();
}
readonly isChecked = $derived.by(() =>
this.root.opts.value.current === this.opts.value.current,
this.provider.opts.value.current === this.opts.value.current,
);
readonly isDisabled = $derived.by(() =>
this.opts.disabled.current || this.root.opts.disabled.current,
this.opts.disabled.current || this.provider.opts.disabled.current,
);
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.isDisabled) return;
this.root.selectItem(this.opts.value.current);
this.provider.selectItem(this.opts.value.current);
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
if (this.isDisabled) return;
const orientation = this.root.opts.orientation.current;
const dir = this.root.opts.dir.current;
const orientation = this.provider.opts.orientation.current;
const dir = this.provider.opts.dir.current;
const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);
const items = this.root.getItems();
const items = this.provider.getItems();
const currentIndex = items.indexOf(e.currentTarget as HTMLElement);
const loop = this.root.opts.loop.current;
const loop = this.provider.opts.loop.current;
let targetIndex = -1;
@ -128,7 +126,7 @@ export class RadioItemProvider extends Provider<RadioItemOpts> {
items[targetIndex].focus();
// Auto-select on focus (roving tabindex pattern for radio)
const value = items[targetIndex].getAttribute('data-value');
if (value) this.root.selectItem(value);
if (value) this.provider.selectItem(value);
}
};
@ -141,7 +139,7 @@ export class RadioItemProvider extends Provider<RadioItemOpts> {
tabindex: this.isChecked ? 0 : -1,
'data-state': this.isChecked ? 'checked' : 'unchecked',
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-orientation': this.root.opts.orientation.current,
'data-orientation': this.provider.opts.orientation.current,
'data-value': this.opts.value.current,
disabled: this.isDisabled || undefined,
onclick: this.onclick,
@ -153,10 +151,10 @@ export class RadioItemProvider extends Provider<RadioItemOpts> {
type: 'radio' as const,
'aria-hidden': true as const,
tabindex: -1,
name: this.root.opts.name.current || undefined,
name: this.provider.opts.name.current || undefined,
value: this.opts.value.current,
checked: this.isChecked,
required: this.root.opts.required.current || undefined,
required: this.provider.opts.required.current || undefined,
disabled: this.isDisabled || undefined,
style: 'position:absolute;pointer-events:none;opacity:0;margin:0;width:0;height:0',
}));

@ -2,19 +2,19 @@
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { ScrollAreaProvider } from '../scroll-area-provider.svelte';
import type { ScrollAreaProps } from '../types';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
ref = $bindable(null),
id = createId(uid, 'scroll-area'),
type = 'hover',
scrollHideDelay = 600,
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
children,
child,
...restProps

@ -3,7 +3,7 @@ import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
import { readableActive, state, type Active, type ActiveProps } from '$soma/reactive';
import type { Direction } from '$soma/types';
import { isBrowser } from '$soma/dom';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
const attrs = createAttrs({
component: 'scroll-area',
@ -29,7 +29,7 @@ export class ScrollAreaProvider extends Provider<ScrollAreaOpts> {
return new ScrollAreaProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
// Viewport tracking
viewportRef = state<HTMLElement | null>(null);
@ -44,7 +44,7 @@ export class ScrollAreaProvider extends Provider<ScrollAreaOpts> {
private constructor(opts: ScrollAreaOpts) {
super(opts, 'ScrollArea', 'root', attrs.root, ScrollAreaProvider.ctx);
this.soma = Soma.getOr();
}
// ── Overflow detection ───────────────────────────────────────────────────
@ -137,17 +137,17 @@ export class ScrollAreaViewportProvider extends Provider<ScrollAreaViewportOpts>
return new ScrollAreaViewportProvider(opts);
}
readonly root: ScrollAreaProvider;
readonly provider: ScrollAreaProvider;
private constructor(opts: ScrollAreaViewportOpts) {
super(opts, 'ScrollArea', 'viewport', attrs.viewport, undefined, (el) => {
this.root.setupViewport(el);
this.provider.setupViewport(el);
});
this.root = ScrollAreaProvider.ctx.get();
this.provider = ScrollAreaProvider.ctx.get();
}
readonly onscroll = (e: Event) => {
this.root.handleScroll(e.currentTarget as HTMLElement);
this.provider.handleScroll(e.currentTarget as HTMLElement);
};
readonly props = $derived.by(() =>
@ -157,12 +157,12 @@ export class ScrollAreaViewportProvider extends Provider<ScrollAreaViewportOpts>
overflow: 'scroll',
'scrollbar-width': 'none',
},
'data-at-top': boolToEmptyStrOrUndef(this.root.isAtTop),
'data-at-bottom': boolToEmptyStrOrUndef(this.root.isAtBottom),
'data-at-left': boolToEmptyStrOrUndef(this.root.isAtLeft),
'data-at-right': boolToEmptyStrOrUndef(this.root.isAtRight),
'data-overflow-x': boolToEmptyStrOrUndef(this.root.hasOverflowX),
'data-overflow-y': boolToEmptyStrOrUndef(this.root.hasOverflowY),
'data-at-top': boolToEmptyStrOrUndef(this.provider.isAtTop),
'data-at-bottom': boolToEmptyStrOrUndef(this.provider.isAtBottom),
'data-at-left': boolToEmptyStrOrUndef(this.provider.isAtLeft),
'data-at-right': boolToEmptyStrOrUndef(this.provider.isAtRight),
'data-overflow-x': boolToEmptyStrOrUndef(this.provider.hasOverflowX),
'data-overflow-y': boolToEmptyStrOrUndef(this.provider.hasOverflowY),
onscroll: this.onscroll,
} as const),
);
@ -185,7 +185,7 @@ export class ScrollAreaScrollbarProvider extends Provider<ScrollAreaScrollbarOpt
return new ScrollAreaScrollbarProvider(opts);
}
readonly root: ScrollAreaProvider;
readonly provider: ScrollAreaProvider;
// Visibility state
visible = $state(false);
@ -195,7 +195,7 @@ export class ScrollAreaScrollbarProvider extends Provider<ScrollAreaScrollbarOpt
private constructor(opts: ScrollAreaScrollbarOpts) {
super(opts, 'ScrollArea', 'scrollbar', attrs.scrollbar, ScrollAreaScrollbarProvider.ctx);
this.root = ScrollAreaProvider.ctx.get();
this.provider = ScrollAreaProvider.ctx.get();
}
private get isVertical(): boolean {
@ -203,13 +203,13 @@ export class ScrollAreaScrollbarProvider extends Provider<ScrollAreaScrollbarOpt
}
private get hasOverflow(): boolean {
return this.isVertical ? this.root.hasOverflowY : this.root.hasOverflowX;
return this.isVertical ? this.provider.hasOverflowY : this.provider.hasOverflowX;
}
/** Whether the scrollbar should be displayed based on type and state. */
readonly shouldShow = $derived.by(() => {
if (!this.hasOverflow) return false;
const type = this.root.opts.type.current;
const type = this.provider.opts.type.current;
if (type === 'always') return true;
if (type === 'auto') return true;
if (type === 'hover') return this.visible;
@ -226,7 +226,7 @@ export class ScrollAreaScrollbarProvider extends Provider<ScrollAreaScrollbarOpt
/** Schedule hiding the scrollbar after delay. */
scheduleHide() {
this.clearHideTimer();
const delay = this.root.opts.scrollHideDelay.current;
const delay = this.provider.opts.scrollHideDelay.current;
this.hideTimer = setTimeout(() => {
if (!this.hovering && !this.dragging) {
this.visible = false;
@ -254,18 +254,18 @@ export class ScrollAreaScrollbarProvider extends Provider<ScrollAreaScrollbarOpt
/** Handle click on the track (not thumb) to scroll to that position. */
readonly onclick = (e: MouseEvent) => {
const el = this.opts.ref.current;
const viewport = this.root.viewportRef.current;
const viewport = this.provider.viewportRef.current;
if (!el || !viewport) return;
const rect = el.getBoundingClientRect();
if (this.isVertical) {
const clickRatio = (e.clientY - rect.top) / rect.height;
const maxScroll = this.root.contentHeight - this.root.viewportHeight;
const maxScroll = this.provider.contentHeight - this.provider.viewportHeight;
viewport.scrollTop = clickRatio * maxScroll;
} else {
const clickRatio = (e.clientX - rect.left) / rect.width;
const maxScroll = this.root.contentWidth - this.root.viewportWidth;
const maxScroll = this.provider.contentWidth - this.provider.viewportWidth;
viewport.scrollLeft = clickRatio * maxScroll;
}
};
@ -274,7 +274,7 @@ export class ScrollAreaScrollbarProvider extends Provider<ScrollAreaScrollbarOpt
private get scrollPercent(): number {
return Math.round(
(this.isVertical ? this.root.getScrollProgressY() : this.root.getScrollProgressX()) * 100,
(this.isVertical ? this.provider.getScrollProgressY() : this.provider.getScrollProgressX()) * 100,
);
}
@ -282,7 +282,7 @@ export class ScrollAreaScrollbarProvider extends Provider<ScrollAreaScrollbarOpt
this.assertProps({
...this.baseProps,
role: 'scrollbar' as const,
'aria-controls': this.root.viewportRef.current?.id || undefined,
'aria-controls': this.provider.viewportRef.current?.id || undefined,
'aria-valuenow': this.scrollPercent,
'aria-valuemin': 0,
'aria-valuemax': 100,
@ -291,8 +291,8 @@ export class ScrollAreaScrollbarProvider extends Provider<ScrollAreaScrollbarOpt
'data-orientation': this.opts.orientation.current,
'data-hover': boolToEmptyStrOrUndef(this.hovering),
'data-dragging': boolToEmptyStrOrUndef(this.dragging),
'data-overflow-x': boolToEmptyStrOrUndef(this.root.hasOverflowX),
'data-overflow-y': boolToEmptyStrOrUndef(this.root.hasOverflowY),
'data-overflow-x': boolToEmptyStrOrUndef(this.provider.hasOverflowX),
'data-overflow-y': boolToEmptyStrOrUndef(this.provider.hasOverflowY),
onpointerenter: this.onpointerenter,
onpointerleave: this.onpointerleave,
onclick: this.onclick,
@ -310,7 +310,7 @@ export class ScrollAreaThumbProvider extends Provider<ScrollAreaThumbOpts> {
}
readonly scrollbar: ScrollAreaScrollbarProvider;
readonly root: ScrollAreaProvider;
readonly provider: ScrollAreaProvider;
private dragStartY = 0;
private dragStartX = 0;
@ -320,7 +320,7 @@ export class ScrollAreaThumbProvider extends Provider<ScrollAreaThumbOpts> {
private constructor(opts: ScrollAreaThumbOpts) {
super(opts, 'ScrollArea', 'thumb', attrs.thumb);
this.scrollbar = ScrollAreaScrollbarProvider.ctx.get();
this.root = this.scrollbar.root;
this.provider = this.scrollbar.provider;
}
private get isVertical(): boolean {
@ -331,11 +331,11 @@ export class ScrollAreaThumbProvider extends Provider<ScrollAreaThumbOpts> {
private get thumbSize(): number {
if (this.isVertical) {
const ratio = this.root.viewportHeight / this.root.contentHeight;
const ratio = this.provider.viewportHeight / this.provider.contentHeight;
const trackHeight = this.scrollbar.opts.ref.current?.clientHeight ?? 0;
return Math.max(ratio * trackHeight, 20);
} else {
const ratio = this.root.viewportWidth / this.root.contentWidth;
const ratio = this.provider.viewportWidth / this.provider.contentWidth;
const trackWidth = this.scrollbar.opts.ref.current?.clientWidth ?? 0;
return Math.max(ratio * trackWidth, 20);
}
@ -348,11 +348,11 @@ export class ScrollAreaThumbProvider extends Provider<ScrollAreaThumbOpts> {
if (this.isVertical) {
const trackHeight = scrollbarEl.clientHeight;
const maxOffset = trackHeight - this.thumbSize;
return this.root.getScrollProgressY() * maxOffset;
return this.provider.getScrollProgressY() * maxOffset;
} else {
const trackWidth = scrollbarEl.clientWidth;
const maxOffset = trackWidth - this.thumbSize;
return this.root.getScrollProgressX() * maxOffset;
return this.provider.getScrollProgressX() * maxOffset;
}
}
@ -363,7 +363,7 @@ export class ScrollAreaThumbProvider extends Provider<ScrollAreaThumbOpts> {
e.preventDefault();
e.stopPropagation();
const viewport = this.root.viewportRef.current;
const viewport = this.provider.viewportRef.current;
if (!viewport) return;
this.scrollbar.dragging = true;
@ -379,7 +379,7 @@ export class ScrollAreaThumbProvider extends Provider<ScrollAreaThumbOpts> {
if (!this.scrollbar.dragging) return;
e.preventDefault();
const viewport = this.root.viewportRef.current;
const viewport = this.provider.viewportRef.current;
const scrollbarEl = this.scrollbar.opts.ref.current;
if (!viewport || !scrollbarEl) return;
@ -387,13 +387,13 @@ export class ScrollAreaThumbProvider extends Provider<ScrollAreaThumbOpts> {
const deltaY = e.clientY - this.dragStartY;
const trackHeight = scrollbarEl.clientHeight;
const scrollRatio = deltaY / (trackHeight - this.thumbSize);
const maxScroll = this.root.contentHeight - this.root.viewportHeight;
const maxScroll = this.provider.contentHeight - this.provider.viewportHeight;
viewport.scrollTop = this.dragStartScrollTop + scrollRatio * maxScroll;
} else {
const deltaX = e.clientX - this.dragStartX;
const trackWidth = scrollbarEl.clientWidth;
const scrollRatio = deltaX / (trackWidth - this.thumbSize);
const maxScroll = this.root.contentWidth - this.root.viewportWidth;
const maxScroll = this.provider.contentWidth - this.provider.viewportWidth;
viewport.scrollLeft = this.dragStartScrollLeft + scrollRatio * maxScroll;
}
};
@ -440,15 +440,15 @@ export class ScrollAreaCornerProvider extends Provider<ScrollAreaCornerOpts> {
return new ScrollAreaCornerProvider(opts);
}
readonly root: ScrollAreaProvider;
readonly provider: ScrollAreaProvider;
private constructor(opts: ScrollAreaCornerOpts) {
super(opts, 'ScrollArea', 'corner', attrs.corner);
this.root = ScrollAreaProvider.ctx.get();
this.provider = ScrollAreaProvider.ctx.get();
}
readonly shouldShow = $derived.by(
() => this.root.hasOverflowX && this.root.hasOverflowY,
() => this.provider.hasOverflowX && this.provider.hasOverflowY,
);
readonly props = $derived.by(() =>

@ -3,13 +3,13 @@
import { mergeProps } from '$soma/props';
import { styleToString } from '$soma/css';
import { createId } from '$soma/id';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { SelectContentProvider } from '../select-provider.svelte';
import type { SelectContentProps } from '../types';
import { noop } from '$lib/util/funcs';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
ref = $bindable(null),
@ -31,7 +31,7 @@
escapeKeydownBehavior = 'close',
onInteractOutside = noop,
interactOutsideBehavior = 'close',
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
style = null,
children,
child,
@ -76,10 +76,10 @@
{#if state.isPresent || forceMount}
<div {...state.wrapperProps} style={wrapperStyleStr}>
{#if child}
{@render child({ open: state.root.opts.open.current, props: mergedProps })}
{@render child({ open: state.provider.opts.open.current, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.({ open: state.root.opts.open.current })}
{@render children?.({ open: state.provider.opts.open.current })}
</div>
{/if}
</div>

@ -4,10 +4,10 @@
import { SelectProvider } from '../select-provider.svelte';
import type { SelectProps } from '../types';
import { noop } from '$lib/util/funcs';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
id = createId(uid, 'select'),
@ -20,7 +20,7 @@
loop = false,
allowDeselect = false,
scrollAlignment = 'nearest',
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
onValueChange = noop,
onOpenChange = noop,
onOpenChangeComplete = noop,
@ -31,11 +31,17 @@
id: readableActive(() => id),
open: writableActive(
() => open,
(v) => (open = v),
(v) => {
open = v;
onOpenChange(v);
},
),
value: writableActive(
() => value,
(v) => (value = v),
(v) => {
value = v;
onValueChange(v);
},
),
type: readableActive(() => type),
disabled: readableActive(() => disabled),
@ -45,8 +51,6 @@
allowDeselect: readableActive(() => allowDeselect),
scrollAlignment: readableActive(() => scrollAlignment),
dir: readableActive(() => dir),
onValueChange: readableActive(() => onValueChange),
onOpenChange: readableActive(() => onOpenChange),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
});
</script>

@ -3,7 +3,7 @@ import { createAttrs, boolToEmptyStrOrUndef, getDataOpenClosed } from '$soma/att
import { readableActive, state, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, SomaKeyboardEvent, Direction, Arrayable, StyleProperties } from '$soma/types';
import { KEYS } from '$soma/keyboard';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { Presence } from '$soma/layers/presence.svelte';
import { Dismissal, type DismissalBehavior } from '$soma/layers/dismissal.svelte';
@ -63,8 +63,6 @@ interface SelectOpts
allowDeselect: boolean;
scrollAlignment: ScrollAlignment;
dir: Direction;
onValueChange: OnChangeFn<string[]>;
onOpenChange: OnChangeFn<boolean>;
onOpenChangeComplete: OnChangeFn<boolean>;
}> {}
@ -75,7 +73,7 @@ export class SelectProvider extends Provider<SelectOpts> {
return new SelectProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
readonly floatingProvider: FloatingProvider;
readonly contentPresence: Presence;
readonly typeahead = new Typeahead();
@ -90,7 +88,7 @@ export class SelectProvider extends Provider<SelectOpts> {
private constructor(opts: SelectOpts) {
super(opts, 'Select', 'root', attrs.root, SelectProvider.ctx);
this.soma = Soma.getOr();
this.floatingProvider = FloatingProvider.create();
this.contentPresence = new Presence({
@ -126,13 +124,11 @@ export class SelectProvider extends Provider<SelectOpts> {
}
this.opts.value.current = next;
this.opts.onValueChange.current(next);
}
handleOpen() {
if (this.opts.disabled.current) return;
this.opts.open.current = true;
this.opts.onOpenChange.current(true);
// Scroll selected item into view after content mounts
requestAnimationFrame(() => {
@ -146,7 +142,6 @@ export class SelectProvider extends Provider<SelectOpts> {
this.typeahead.reset();
this.highlightedId = '';
this.opts.open.current = false;
this.opts.onOpenChange.current(false);
this.triggerRef.current?.focus();
}
@ -209,13 +204,13 @@ export class SelectTriggerProvider extends Provider<SelectTriggerOpts> {
return new SelectTriggerProvider(opts);
}
readonly root: SelectProvider;
readonly provider: SelectProvider;
private constructor(opts: SelectTriggerOpts) {
super(opts, 'Select', 'trigger', attrs.trigger);
this.root = SelectProvider.ctx.get();
this.root.triggerId.current = opts.id.current;
this.root.triggerRef = opts.ref;
this.provider = SelectProvider.ctx.get();
this.provider.triggerId.current = opts.id.current;
this.provider.triggerRef = opts.ref;
FloatingAnchor.create({
id: opts.id,
@ -224,16 +219,16 @@ export class SelectTriggerProvider extends Provider<SelectTriggerOpts> {
}
readonly onclick = () => {
this.root.toggle();
this.provider.toggle();
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
if (e.key === KEYS.ARROW_DOWN || e.key === KEYS.ARROW_UP) {
e.preventDefault();
this.root.handleOpen();
this.provider.handleOpen();
} else if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
this.root.toggle();
this.provider.toggle();
}
};
@ -243,14 +238,14 @@ export class SelectTriggerProvider extends Provider<SelectTriggerOpts> {
type: 'button' as const,
role: 'combobox' as const,
'aria-haspopup': 'listbox' as const,
'aria-expanded': this.root.opts.open.current,
'aria-controls': this.root.contentId.current || undefined,
'aria-activedescendant': this.root.highlightedId || undefined,
'aria-required': this.root.opts.required.current || undefined,
'data-state': getDataOpenClosed(this.root.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.root.opts.disabled.current),
'data-placeholder': this.root.opts.value.current.length === 0 ? '' : undefined,
disabled: this.root.opts.disabled.current || undefined,
'aria-expanded': this.provider.opts.open.current,
'aria-controls': this.provider.contentId.current || undefined,
'aria-activedescendant': this.provider.highlightedId || undefined,
'aria-required': this.provider.opts.required.current || undefined,
'data-state': getDataOpenClosed(this.provider.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
'data-placeholder': this.provider.opts.value.current.length === 0 ? '' : undefined,
disabled: this.provider.opts.disabled.current || undefined,
onclick: this.onclick,
onkeydown: this.onkeydown,
} as const),
@ -266,23 +261,23 @@ export class SelectValueProvider extends Provider<SelectValueOpts> {
return new SelectValueProvider(opts);
}
readonly root: SelectProvider;
readonly provider: SelectProvider;
private constructor(opts: SelectValueOpts) {
super(opts, 'Select', 'value', attrs.value);
this.root = SelectProvider.ctx.get();
this.provider = SelectProvider.ctx.get();
}
readonly displayText = $derived.by(() => {
const val = this.root.opts.value.current;
const val = this.provider.opts.value.current;
if (val.length === 0) return this.opts.placeholder.current;
return this.root.getDisplayText();
return this.provider.getDisplayText();
});
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-placeholder': this.root.opts.value.current.length === 0 ? '' : undefined,
'data-placeholder': this.provider.opts.value.current.length === 0 ? '' : undefined,
} as const),
);
}
@ -317,17 +312,17 @@ export class SelectContentProvider extends Provider<SelectContentOpts> {
return new SelectContentProvider(opts);
}
readonly root: SelectProvider;
readonly provider: SelectProvider;
readonly floating: FloatingContent;
readonly dismissal: Dismissal;
readonly scrollLock: ScrollLock;
private constructor(opts: SelectContentOpts) {
super(opts, 'Select', 'content', attrs.content, undefined, (el) => {
this.root.setContentRef(el);
this.provider.setContentRef(el);
});
this.root = SelectProvider.ctx.get();
this.root.contentId.current = opts.id.current;
this.provider = SelectProvider.ctx.get();
this.provider.contentId.current = opts.id.current;
// Floating
this.floating = FloatingContent.create({
@ -348,22 +343,22 @@ export class SelectContentProvider extends Provider<SelectContentOpts> {
onPlaced: readableActive(() => () => {}),
dir: opts.dir,
style: opts.style,
enabled: readableActive(() => this.root.opts.open.current),
enabled: readableActive(() => this.provider.opts.open.current),
customAnchor: readableActive(() => null),
});
// Dismissal — always close on outside click
this.dismissal = Dismissal.use({
ref: readableActive(() => opts.ref.current),
enabled: readableActive(() => this.root.opts.open.current),
enabled: readableActive(() => this.provider.opts.open.current),
onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => {
opts.onEscapeKeydown.current(e);
if (!e.defaultPrevented) this.root.handleClose();
if (!e.defaultPrevented) this.provider.handleClose();
}),
escapeKeydownBehavior: opts.escapeKeydownBehavior,
onInteractOutside: readableActive(() => (e: PointerEvent) => {
opts.onInteractOutside.current(e);
if (!e.defaultPrevented) this.root.handleClose();
if (!e.defaultPrevented) this.provider.handleClose();
}),
interactOutsideBehavior: opts.interactOutsideBehavior,
onFocusOutside: readableActive(() => () => {}),
@ -373,11 +368,11 @@ export class SelectContentProvider extends Provider<SelectContentOpts> {
// Scroll lock
this.scrollLock = new ScrollLock();
$effect(() => {
this.scrollLock.locked.current = this.root.opts.open.current && opts.preventScroll.current;
this.scrollLock.locked.current = this.provider.opts.open.current && opts.preventScroll.current;
});
}
readonly isPresent = $derived.by(() => this.root.contentPresence.isPresent);
readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent);
readonly wrapperProps = $derived.by(() => ({
...this.floating.wrapperProps,
@ -391,10 +386,10 @@ export class SelectContentProvider extends Provider<SelectContentOpts> {
const container = this.opts.ref.current;
if (!container) return;
const items = this.root.getItems(container);
const items = this.provider.getItems(container);
const active = document.activeElement as HTMLElement;
const currentIndex = items.indexOf(active);
const loop = this.root.opts.loop.current;
const loop = this.provider.opts.loop.current;
let targetIndex = -1;
@ -417,15 +412,15 @@ export class SelectContentProvider extends Provider<SelectContentOpts> {
} else if (e.key === KEYS.TAB) {
// Tab closes select (returns focus to trigger)
e.preventDefault();
this.root.handleClose();
this.provider.handleClose();
return;
} else {
// Typeahead
const match = this.root.typeahead.handle(e.key, items);
const match = this.provider.typeahead.handle(e.key, items);
if (match) {
match.focus();
match.scrollIntoView({ block: this.root.opts.scrollAlignment.current });
this.root.highlightedId = match.id;
match.scrollIntoView({ block: this.provider.opts.scrollAlignment.current });
this.provider.highlightedId = match.id;
}
return;
}
@ -433,8 +428,8 @@ export class SelectContentProvider extends Provider<SelectContentOpts> {
if (targetIndex >= 0 && items[targetIndex]) {
const target = items[targetIndex];
target.focus();
target.scrollIntoView({ block: this.root.opts.scrollAlignment.current });
this.root.highlightedId = target.id;
target.scrollIntoView({ block: this.provider.opts.scrollAlignment.current });
this.provider.highlightedId = target.id;
}
};
@ -442,10 +437,10 @@ export class SelectContentProvider extends Provider<SelectContentOpts> {
this.assertProps({
...this.baseProps,
role: 'listbox' as const,
'aria-multiselectable': this.root.opts.type.current === 'multiple' || undefined,
'data-state': getDataOpenClosed(this.root.opts.open.current),
'aria-multiselectable': this.provider.opts.type.current === 'multiple' || undefined,
'data-state': getDataOpenClosed(this.provider.opts.open.current),
...this.floating.props,
...this.root.contentPresence.transitionAttrs,
...this.provider.contentPresence.transitionAttrs,
...this.dismissal.props,
tabindex: -1,
onkeydown: this.onkeydown,
@ -489,27 +484,27 @@ export class SelectItemProvider extends Provider<SelectItemOpts> {
return new SelectItemProvider(opts);
}
readonly root: SelectProvider;
readonly provider: SelectProvider;
private constructor(opts: SelectItemOpts) {
super(opts, 'Select', 'item', attrs.item);
this.root = SelectProvider.ctx.get();
this.provider = SelectProvider.ctx.get();
}
readonly isSelected = $derived.by(() =>
this.root.isSelected(this.opts.value.current),
this.provider.isSelected(this.opts.value.current),
);
readonly onclick = () => {
if (this.opts.disabled.current) return;
this.root.selectItem(this.opts.value.current);
this.provider.selectItem(this.opts.value.current);
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
if (this.opts.disabled.current) return;
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
this.root.selectItem(this.opts.value.current);
this.provider.selectItem(this.opts.value.current);
}
};

@ -30,7 +30,7 @@
{:else}
<span {...mergedProps}>
{@render children?.()}
{#if state.root.opts.name.current}
{#if state.provider.opts.name.current}
<input {...state.inputProps} />
{/if}
</span>

@ -2,13 +2,13 @@
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { SliderProvider } from '../slider-provider.svelte';
import type { SliderProps } from '../types';
import { noop } from '$lib/util/funcs';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
ref = $bindable(null),
@ -22,7 +22,7 @@
step = 1,
orientation = 'horizontal',
disabled = false,
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
name = '',
children,
child,
@ -33,8 +33,13 @@
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
type: readableActive(() => type),
value: writableActive(() => value, (v) => (value = v)),
onValueChange: readableActive(() => onValueChange),
value: writableActive(
() => value,
(v) => {
value = v;
onValueChange(v);
},
),
onValueCommit: readableActive(() => onValueCommit),
min: readableActive(() => min),
max: readableActive(() => max),

@ -42,7 +42,6 @@ interface SliderOpts
disabled: boolean;
dir: Direction;
name: string;
onValueChange: OnChangeFn<number[]>;
onValueCommit: OnChangeFn<number[]>;
}> {}
@ -79,7 +78,6 @@ export class SliderProvider extends Provider<SliderOpts> {
}
this.opts.value.current = values;
this.opts.onValueChange.current(values);
}
commitValue() {
@ -174,20 +172,20 @@ export class SliderRangeProvider extends Provider<SliderRangeOpts> {
return new SliderRangeProvider(opts);
}
readonly root: SliderProvider;
readonly provider: SliderProvider;
private constructor(opts: SliderRangeOpts) {
super(opts, 'Slider', 'range', attrs.range);
this.root = SliderProvider.ctx.get();
this.provider = SliderProvider.ctx.get();
}
readonly rangeStyle = $derived.by(() => {
const values = this.root.opts.value.current;
const isHorizontal = this.root.opts.orientation.current === 'horizontal';
const values = this.provider.opts.value.current;
const isHorizontal = this.provider.opts.orientation.current === 'horizontal';
const min = Math.min(...values);
const max = Math.max(...values);
const startPct = this.root.getPercent(values.length > 1 ? min : this.root.opts.min.current);
const endPct = this.root.getPercent(max);
const startPct = this.provider.getPercent(values.length > 1 ? min : this.provider.opts.min.current);
const endPct = this.provider.getPercent(max);
if (isHorizontal) {
return { left: `${startPct}%`, width: `${endPct - startPct}%` };
@ -198,8 +196,8 @@ export class SliderRangeProvider extends Provider<SliderRangeOpts> {
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-orientation': this.root.opts.orientation.current,
'data-disabled': boolToEmptyStrOrUndef(this.root.opts.disabled.current),
'data-orientation': this.provider.opts.orientation.current,
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
style: {
position: 'absolute',
...this.rangeStyle,
@ -217,28 +215,28 @@ export class SliderThumbProvider extends Provider<SliderThumbOpts> {
return new SliderThumbProvider(opts);
}
readonly root: SliderProvider;
readonly provider: SliderProvider;
private constructor(opts: SliderThumbOpts) {
super(opts, 'Slider', 'thumb', attrs.thumb);
this.root = SliderProvider.ctx.get();
this.provider = SliderProvider.ctx.get();
}
readonly value = $derived.by(() =>
this.root.opts.value.current[this.opts.index.current] ?? this.root.opts.min.current,
this.provider.opts.value.current[this.opts.index.current] ?? this.provider.opts.min.current,
);
readonly percent = $derived.by(() => this.root.getPercent(this.value));
readonly percent = $derived.by(() => this.provider.getPercent(this.value));
readonly onkeydown = (e: KeyboardEvent) => {
if (this.root.opts.disabled.current) return;
const step = this.root.opts.step.current;
if (this.provider.opts.disabled.current) return;
const step = this.provider.opts.step.current;
const bigStep = step * 10;
const index = this.opts.index.current;
let delta = 0;
const isHorizontal = this.root.opts.orientation.current === 'horizontal';
const isRtl = this.root.opts.dir.current === 'rtl';
const isHorizontal = this.provider.opts.orientation.current === 'horizontal';
const isRtl = this.provider.opts.dir.current === 'rtl';
if (e.key === KEYS.ARROW_RIGHT || e.key === KEYS.ARROW_UP) {
delta = (isHorizontal && isRtl) ? -step : step;
@ -249,26 +247,26 @@ export class SliderThumbProvider extends Provider<SliderThumbOpts> {
} else if (e.key === 'PageDown') {
delta = -bigStep;
} else if (e.key === KEYS.HOME) {
this.root.updateValue(index, this.root.opts.min.current);
this.root.commitValue();
this.provider.updateValue(index, this.provider.opts.min.current);
this.provider.commitValue();
e.preventDefault();
return;
} else if (e.key === KEYS.END) {
this.root.updateValue(index, this.root.opts.max.current);
this.root.commitValue();
this.provider.updateValue(index, this.provider.opts.max.current);
this.provider.commitValue();
e.preventDefault();
return;
}
if (delta !== 0) {
e.preventDefault();
this.root.updateValue(index, this.value + delta);
this.root.commitValue();
this.provider.updateValue(index, this.value + delta);
this.provider.commitValue();
}
};
readonly thumbStyle = $derived.by(() => {
const isHorizontal = this.root.opts.orientation.current === 'horizontal';
const isHorizontal = this.provider.opts.orientation.current === 'horizontal';
if (isHorizontal) return { left: `${this.percent}%`, top: '50%', transform: 'translate(-50%, -50%)' };
return { bottom: `${this.percent}%`, left: '50%', transform: 'translate(-50%, 50%)' };
});
@ -277,14 +275,14 @@ export class SliderThumbProvider extends Provider<SliderThumbOpts> {
this.assertProps({
...this.baseProps,
role: 'slider' as const,
tabindex: this.root.opts.disabled.current ? undefined : 0,
'aria-valuemin': this.root.opts.min.current,
'aria-valuemax': this.root.opts.max.current,
tabindex: this.provider.opts.disabled.current ? undefined : 0,
'aria-valuemin': this.provider.opts.min.current,
'aria-valuemax': this.provider.opts.max.current,
'aria-valuenow': this.value,
'aria-orientation': this.root.opts.orientation.current,
'aria-disabled': this.root.opts.disabled.current || undefined,
'data-orientation': this.root.opts.orientation.current,
'data-disabled': boolToEmptyStrOrUndef(this.root.opts.disabled.current),
'aria-orientation': this.provider.opts.orientation.current,
'aria-disabled': this.provider.opts.disabled.current || undefined,
'data-orientation': this.provider.opts.orientation.current,
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
style: { position: 'absolute', ...this.thumbStyle },
onkeydown: this.onkeydown,
} as const),
@ -292,11 +290,11 @@ export class SliderThumbProvider extends Provider<SliderThumbOpts> {
readonly inputProps = $derived.by(() => ({
type: 'hidden' as const,
name: this.root.opts.name.current
? `${this.root.opts.name.current}${this.root.opts.type.current === 'multiple' ? `[${this.opts.index.current}]` : ''}`
name: this.provider.opts.name.current
? `${this.provider.opts.name.current}${this.provider.opts.type.current === 'multiple' ? `[${this.opts.index.current}]` : ''}`
: undefined,
value: this.value,
disabled: this.root.opts.disabled.current || undefined,
disabled: this.provider.opts.disabled.current || undefined,
}));
}
@ -309,29 +307,29 @@ export class SliderTickProvider extends Provider<SliderTickOpts> {
return new SliderTickProvider(opts);
}
readonly root: SliderProvider;
readonly provider: SliderProvider;
private constructor(opts: SliderTickOpts) {
super(opts, 'Slider', 'tick', attrs.tick);
this.root = SliderProvider.ctx.get();
this.provider = SliderProvider.ctx.get();
}
readonly percent = $derived.by(() =>
this.root.getPercent(this.opts.value.current),
this.provider.getPercent(this.opts.value.current),
);
readonly isActive = $derived.by(() => {
const values = this.root.opts.value.current;
const values = this.provider.opts.value.current;
const min = Math.min(...values);
const max = Math.max(...values);
return this.opts.value.current >= min && this.opts.value.current <= max;
});
readonly props = $derived.by(() => {
const isHorizontal = this.root.opts.orientation.current === 'horizontal';
const isHorizontal = this.provider.opts.orientation.current === 'horizontal';
return this.assertProps({
...this.baseProps,
'data-orientation': this.root.opts.orientation.current,
'data-orientation': this.provider.opts.orientation.current,
'data-active': this.isActive ? '' : undefined,
'data-value': this.opts.value.current,
style: {

@ -2,20 +2,20 @@
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { SplitterProvider } from '../splitter-provider.svelte';
import type { SplitterProps } from '../types';
import { noop } from '$lib/util/funcs';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
ref = $bindable(null),
id = createId(uid, 'splitter'),
orientation = 'horizontal',
disabled = false,
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
keyboardStep = 1,
onResize = noop,
onResizeEnd = noop,

@ -3,7 +3,7 @@ import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
import { state, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, Orientation, Direction, SomaKeyboardEvent } from '$soma/types';
import { KEYS, getDirectionalKeys } from '$soma/keyboard';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
const attrs = createAttrs({
component: 'splitter',
@ -60,12 +60,12 @@ export class SplitterProvider extends Provider<SplitterOpts> {
return new SplitterProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
private panels: PanelState[] = $state([]);
private constructor(opts: SplitterOpts) {
super(opts, 'Splitter', 'root', attrs.root, SplitterProvider.ctx);
this.soma = Soma.getOr();
}
// ── Panel registration ───────────────────────────────────────────────────
@ -252,14 +252,14 @@ export class SplitterPanelProvider extends Provider<SplitterPanelOpts> {
return new SplitterPanelProvider(opts);
}
readonly root: SplitterProvider;
readonly provider: SplitterProvider;
private cleanup: (() => void) | undefined;
private constructor(opts: SplitterPanelOpts) {
super(opts, 'Splitter', 'panel', attrs.panel);
this.root = SplitterProvider.ctx.get();
this.provider = SplitterProvider.ctx.get();
this.cleanup = this.root.registerPanel({
this.cleanup = this.provider.registerPanel({
id: opts.panelId.current,
defaultSize: opts.defaultSize.current,
minSize: opts.minSize.current,
@ -270,11 +270,11 @@ export class SplitterPanelProvider extends Provider<SplitterPanelOpts> {
}
readonly size = $derived.by(
() => this.root.getPanelSize(this.opts.panelId.current),
() => this.provider.getPanelSize(this.opts.panelId.current),
);
readonly isCollapsed = $derived.by(
() => this.root.isPanelCollapsed(this.opts.panelId.current),
() => this.provider.isPanelCollapsed(this.opts.panelId.current),
);
readonly props = $derived.by(() =>
@ -285,12 +285,12 @@ export class SplitterPanelProvider extends Provider<SplitterPanelOpts> {
'flex-grow': this.size,
'flex-shrink': 0,
overflow: 'hidden',
'min-width': this.root.opts.orientation.current === 'horizontal' ? '0px' : undefined,
'min-height': this.root.opts.orientation.current === 'vertical' ? '0px' : undefined,
'min-width': this.provider.opts.orientation.current === 'horizontal' ? '0px' : undefined,
'min-height': this.provider.opts.orientation.current === 'vertical' ? '0px' : undefined,
},
'data-panel-id': this.opts.panelId.current,
'data-state': this.isCollapsed ? 'collapsed' : 'expanded',
'data-orientation': this.root.opts.orientation.current,
'data-orientation': this.provider.opts.orientation.current,
} as const),
);
}
@ -310,7 +310,7 @@ export class SplitterResizeTriggerProvider extends Provider<SplitterResizeTrigge
return new SplitterResizeTriggerProvider(opts);
}
readonly root: SplitterProvider;
readonly provider: SplitterProvider;
// Drag state
dragging = $state(false);
@ -321,15 +321,15 @@ export class SplitterResizeTriggerProvider extends Provider<SplitterResizeTrigge
private constructor(opts: SplitterResizeTriggerOpts) {
super(opts, 'Splitter', 'resize-trigger', attrs['resize-trigger']);
this.root = SplitterProvider.ctx.get();
this.provider = SplitterProvider.ctx.get();
}
private get isDisabled(): boolean {
return this.opts.disabled.current || this.root.opts.disabled.current;
return this.opts.disabled.current || this.provider.opts.disabled.current;
}
private get isVertical(): boolean {
return this.root.opts.orientation.current === 'vertical';
return this.provider.opts.orientation.current === 'vertical';
}
private get panelBeforeId(): string {
@ -343,15 +343,15 @@ export class SplitterResizeTriggerProvider extends Provider<SplitterResizeTrigge
// ── ARIA value ───────────────────────────────────────────────────────────
private get ariaValueNow(): number {
return Math.round(this.root.getPanelSize(this.panelBeforeId));
return Math.round(this.provider.getPanelSize(this.panelBeforeId));
}
private get ariaValueMin(): number {
return this.root.getPanelState(this.panelBeforeId)?.config.minSize ?? 0;
return this.provider.getPanelState(this.panelBeforeId)?.config.minSize ?? 0;
}
private get ariaValueMax(): number {
return this.root.getPanelState(this.panelBeforeId)?.config.maxSize ?? 100;
return this.provider.getPanelState(this.panelBeforeId)?.config.maxSize ?? 100;
}
// ── Pointer events ───────────────────────────────────────────────────────
@ -362,8 +362,8 @@ export class SplitterResizeTriggerProvider extends Provider<SplitterResizeTrigge
this.dragging = true;
this.dragStartPos = this.isVertical ? e.clientY : e.clientX;
this.dragStartSizeA = this.root.getPanelSize(this.panelBeforeId);
this.dragStartSizeB = this.root.getPanelSize(this.panelAfterId);
this.dragStartSizeA = this.provider.getPanelSize(this.panelBeforeId);
this.dragStartSizeB = this.provider.getPanelSize(this.panelAfterId);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
@ -372,7 +372,7 @@ export class SplitterResizeTriggerProvider extends Provider<SplitterResizeTrigge
if (!this.dragging) return;
e.preventDefault();
const rootEl = this.root.opts.ref?.current;
const rootEl = this.provider.opts.ref?.current;
if (!rootEl) return;
const currentPos = this.isVertical ? e.clientY : e.clientX;
@ -385,26 +385,26 @@ export class SplitterResizeTriggerProvider extends Provider<SplitterResizeTrigge
const deltaPercent = (deltaPx / totalSize) * 100;
// Reset to drag start and apply delta
const panelA = this.root.getPanelState(this.panelBeforeId);
const panelB = this.root.getPanelState(this.panelAfterId);
const panelA = this.provider.getPanelState(this.panelBeforeId);
const panelB = this.provider.getPanelState(this.panelAfterId);
if (panelA && panelB) {
panelA.size = this.dragStartSizeA;
panelB.size = this.dragStartSizeB;
}
this.root.resizePanels(this.panelBeforeId, this.panelAfterId, deltaPercent);
this.provider.resizePanels(this.panelBeforeId, this.panelAfterId, deltaPercent);
};
readonly onpointerup = (_e: PointerEvent) => {
if (!this.dragging) return;
this.dragging = false;
this.root.notifyResizeEnd();
this.provider.notifyResizeEnd();
};
readonly onlostpointercapture = () => {
if (this.dragging) {
this.dragging = false;
this.root.notifyResizeEnd();
this.provider.notifyResizeEnd();
}
};
@ -419,9 +419,9 @@ export class SplitterResizeTriggerProvider extends Provider<SplitterResizeTrigge
readonly ondblclick = () => {
if (this.isDisabled) return;
// Double-click toggles collapse on the panel before the trigger
const panelBefore = this.root.getPanelState(this.panelBeforeId);
const panelBefore = this.provider.getPanelState(this.panelBeforeId);
if (panelBefore?.config.collapsible) {
this.root.toggleCollapse(this.panelBeforeId);
this.provider.toggleCollapse(this.panelBeforeId);
}
};
@ -430,45 +430,45 @@ export class SplitterResizeTriggerProvider extends Provider<SplitterResizeTrigge
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
if (this.isDisabled) return;
const orientation = this.root.opts.orientation.current;
const dir = this.root.opts.dir.current;
const orientation = this.provider.opts.orientation.current;
const dir = this.provider.opts.dir.current;
const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);
const step = this.root.opts.keyboardStep.current;
const step = this.provider.opts.keyboardStep.current;
if (e.key === nextKey) {
e.preventDefault();
this.root.resizePanels(this.panelBeforeId, this.panelAfterId, step);
this.root.notifyResizeEnd();
this.provider.resizePanels(this.panelBeforeId, this.panelAfterId, step);
this.provider.notifyResizeEnd();
} else if (e.key === prevKey) {
e.preventDefault();
this.root.resizePanels(this.panelBeforeId, this.panelAfterId, -step);
this.root.notifyResizeEnd();
this.provider.resizePanels(this.panelBeforeId, this.panelAfterId, -step);
this.provider.notifyResizeEnd();
} else if (e.key === KEYS.HOME) {
e.preventDefault();
// Minimize panel before
const panelA = this.root.getPanelState(this.panelBeforeId);
const panelA = this.provider.getPanelState(this.panelBeforeId);
if (panelA) {
const min = panelA.config.minSize ?? 0;
const delta = min - panelA.size;
this.root.resizePanels(this.panelBeforeId, this.panelAfterId, delta);
this.root.notifyResizeEnd();
this.provider.resizePanels(this.panelBeforeId, this.panelAfterId, delta);
this.provider.notifyResizeEnd();
}
} else if (e.key === KEYS.END) {
e.preventDefault();
// Maximize panel before
const panelA = this.root.getPanelState(this.panelBeforeId);
const panelA = this.provider.getPanelState(this.panelBeforeId);
if (panelA) {
const max = panelA.config.maxSize ?? 100;
const delta = max - panelA.size;
this.root.resizePanels(this.panelBeforeId, this.panelAfterId, delta);
this.root.notifyResizeEnd();
this.provider.resizePanels(this.panelBeforeId, this.panelAfterId, delta);
this.provider.notifyResizeEnd();
}
} else if (e.key === KEYS.ENTER) {
e.preventDefault();
// Toggle collapse on panel before
const panelBefore = this.root.getPanelState(this.panelBeforeId);
const panelBefore = this.provider.getPanelState(this.panelBeforeId);
if (panelBefore?.config.collapsible) {
this.root.toggleCollapse(this.panelBeforeId);
this.provider.toggleCollapse(this.panelBeforeId);
}
}
};
@ -485,13 +485,13 @@ export class SplitterResizeTriggerProvider extends Provider<SplitterResizeTrigge
this.assertProps({
...this.baseProps,
role: 'separator' as const,
'aria-orientation': this.root.opts.orientation.current,
'aria-orientation': this.provider.opts.orientation.current,
'aria-valuenow': this.ariaValueNow,
'aria-valuemin': this.ariaValueMin,
'aria-valuemax': this.ariaValueMax,
tabindex: this.isDisabled ? -1 : 0,
'data-state': this.dataState,
'data-orientation': this.root.opts.orientation.current,
'data-orientation': this.provider.opts.orientation.current,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-dragging': boolToEmptyStrOrUndef(this.dragging),
style: {

@ -2,13 +2,13 @@
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { StepperProvider } from '../stepper-provider.svelte';
import type { StepperProps } from '../types';
import { noop } from '$lib/util/funcs';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
ref = $bindable(null),
@ -16,7 +16,7 @@
value = $bindable(0),
onValueChange = noop,
orientation = 'horizontal',
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
linear = false,
children,
child,
@ -31,9 +31,11 @@
),
value: writableActive(
() => value,
(v) => (value = v),
(v) => {
value = v;
onValueChange(v);
},
),
onValueChange: readableActive(() => onValueChange),
orientation: readableActive(() => orientation),
dir: readableActive(() => dir),
linear: readableActive(() => linear),

@ -1,9 +1,9 @@
import { Provider, context, type WithRefOpts } from '$soma/provider';
import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
import { state, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, Orientation, Direction, SomaKeyboardEvent, SomaMouseEvent } from '$soma/types';
import type { Orientation, Direction, SomaKeyboardEvent, SomaMouseEvent } from '$soma/types';
import { KEYS, getDirectionalKeys } from '$soma/keyboard';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
const attrs = createAttrs({
component: 'stepper',
@ -32,7 +32,6 @@ interface StepperOpts
orientation: Orientation;
dir: Direction;
linear: boolean;
onValueChange: OnChangeFn<number>;
}> {}
export class StepperProvider extends Provider<StepperOpts> {
@ -42,12 +41,12 @@ export class StepperProvider extends Provider<StepperOpts> {
return new StepperProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
count = $state(0);
private constructor(opts: StepperOpts) {
super(opts, 'Stepper', 'root', attrs.root, StepperProvider.ctx);
this.soma = Soma.getOr();
}
get step(): number {
@ -67,7 +66,6 @@ export class StepperProvider extends Provider<StepperOpts> {
setStep(step: number) {
if (step < 0 || step > this.count) return;
this.opts.value.current = step;
this.opts.onValueChange.current(step);
}
goToNextStep() {
@ -117,19 +115,19 @@ export class StepperListProvider extends Provider<StepperListOpts> {
return new StepperListProvider(opts);
}
readonly root: StepperProvider;
readonly provider: StepperProvider;
private constructor(opts: StepperListOpts) {
super(opts, 'Stepper', 'list', attrs.list);
this.root = StepperProvider.ctx.get();
this.provider = StepperProvider.ctx.get();
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'tablist' as const,
'aria-orientation': this.root.opts.orientation.current,
'data-orientation': this.root.opts.orientation.current,
'aria-orientation': this.provider.opts.orientation.current,
'data-orientation': this.provider.opts.orientation.current,
} as const),
);
}
@ -149,23 +147,23 @@ export class StepperItemProvider extends Provider<StepperItemOpts> {
return new StepperItemProvider(opts);
}
readonly root: StepperProvider;
readonly provider: StepperProvider;
private constructor(opts: StepperItemOpts) {
super(opts, 'Stepper', 'item', attrs.item, StepperItemProvider.ctx);
this.root = StepperProvider.ctx.get();
this.provider = StepperProvider.ctx.get();
// Track count based on highest index seen
$effect(() => {
const idx = this.opts.index.current;
if (idx + 1 > this.root.count) {
this.root.count = idx + 1;
if (idx + 1 > this.provider.count) {
this.provider.count = idx + 1;
}
});
}
readonly stepState = $derived.by(
() => this.root.getStepState(this.opts.index.current),
() => this.provider.getStepState(this.opts.index.current),
);
readonly isCurrent = $derived.by(() => this.stepState === 'current');
@ -176,7 +174,7 @@ export class StepperItemProvider extends Provider<StepperItemOpts> {
this.assertProps({
...this.baseProps,
'data-state': this.stepState,
'data-orientation': this.root.opts.orientation.current,
'data-orientation': this.provider.opts.orientation.current,
'data-current': boolToEmptyStrOrUndef(this.isCurrent),
'data-complete': boolToEmptyStrOrUndef(this.isComplete),
'data-incomplete': boolToEmptyStrOrUndef(this.isIncomplete),
@ -197,12 +195,12 @@ export class StepperTriggerProvider extends Provider<StepperTriggerOpts> {
return new StepperTriggerProvider(opts);
}
readonly root: StepperProvider;
readonly provider: StepperProvider;
readonly item: StepperItemProvider;
private constructor(opts: StepperTriggerOpts) {
super(opts, 'Stepper', 'trigger', attrs.trigger);
this.root = StepperProvider.ctx.get();
this.provider = StepperProvider.ctx.get();
this.item = StepperItemProvider.ctx.get();
}
@ -212,21 +210,21 @@ export class StepperTriggerProvider extends Provider<StepperTriggerOpts> {
readonly isDisabled = $derived.by(() => {
if (this.opts.disabled.current) return true;
return !this.root.canSelectStep(this.index);
return !this.provider.canSelectStep(this.index);
});
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
if (this.isDisabled) return;
this.root.setStep(this.index);
this.provider.setStep(this.index);
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
if (this.isDisabled) return;
const orientation = this.root.opts.orientation.current;
const dir = this.root.opts.dir.current;
const orientation = this.provider.opts.orientation.current;
const dir = this.provider.opts.dir.current;
const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);
const triggers = this.root.getTriggers();
const triggers = this.provider.getTriggers();
const currentIndex = triggers.indexOf(e.currentTarget as HTMLElement);
let targetIndex = -1;
@ -262,7 +260,7 @@ export class StepperTriggerProvider extends Provider<StepperTriggerOpts> {
'data-current': boolToEmptyStrOrUndef(this.item.isCurrent),
'data-complete': boolToEmptyStrOrUndef(this.item.isComplete),
'data-incomplete': boolToEmptyStrOrUndef(this.item.isIncomplete),
'data-orientation': this.root.opts.orientation.current,
'data-orientation': this.provider.opts.orientation.current,
onclick: this.onclick,
onkeydown: this.onkeydown,
} as const),
@ -307,12 +305,12 @@ export class StepperSeparatorProvider extends Provider<StepperSeparatorOpts> {
}
readonly item: StepperItemProvider;
readonly root: StepperProvider;
readonly provider: StepperProvider;
private constructor(opts: StepperSeparatorOpts) {
super(opts, 'Stepper', 'separator', attrs.separator);
this.item = StepperItemProvider.ctx.get();
this.root = StepperProvider.ctx.get();
this.provider = StepperProvider.ctx.get();
}
readonly props = $derived.by(() =>
@ -321,7 +319,7 @@ export class StepperSeparatorProvider extends Provider<StepperSeparatorOpts> {
role: 'separator' as const,
'data-state': this.item.stepState,
'data-complete': boolToEmptyStrOrUndef(this.item.isComplete),
'data-orientation': this.root.opts.orientation.current,
'data-orientation': this.provider.opts.orientation.current,
} as const),
);
}
@ -339,19 +337,19 @@ export class StepperContentProvider extends Provider<StepperContentOpts> {
return new StepperContentProvider(opts);
}
readonly root: StepperProvider;
readonly provider: StepperProvider;
private constructor(opts: StepperContentOpts) {
super(opts, 'Stepper', 'content', attrs.content);
this.root = StepperProvider.ctx.get();
this.provider = StepperProvider.ctx.get();
}
readonly isCurrent = $derived.by(
() => this.root.step === this.opts.index.current,
() => this.provider.step === this.opts.index.current,
);
readonly stepState = $derived.by(
() => this.root.getStepState(this.opts.index.current),
() => this.provider.getStepState(this.opts.index.current),
);
readonly props = $derived.by(() =>
@ -372,11 +370,11 @@ export class StepperCompletedContentProvider extends Provider<StepperCompletedCo
return new StepperCompletedContentProvider(opts);
}
readonly root: StepperProvider;
readonly provider: StepperProvider;
private constructor(opts: StepperCompletedContentOpts) {
super(opts, 'Stepper', 'completed-content', attrs['completed-content']);
this.root = StepperProvider.ctx.get();
this.provider = StepperProvider.ctx.get();
}
readonly props = $derived.by(() =>
@ -399,20 +397,20 @@ export class StepperPrevTriggerProvider extends Provider<StepperPrevTriggerOpts>
return new StepperPrevTriggerProvider(opts);
}
readonly root: StepperProvider;
readonly provider: StepperProvider;
private constructor(opts: StepperPrevTriggerOpts) {
super(opts, 'Stepper', 'prev-trigger', attrs['prev-trigger']);
this.root = StepperProvider.ctx.get();
this.provider = StepperProvider.ctx.get();
}
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || !this.root.hasPrevStep,
() => this.opts.disabled.current || !this.provider.hasPrevStep,
);
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.isDisabled) return;
this.root.goToPrevStep();
this.provider.goToPrevStep();
};
readonly props = $derived.by(() =>
@ -439,20 +437,20 @@ export class StepperNextTriggerProvider extends Provider<StepperNextTriggerOpts>
return new StepperNextTriggerProvider(opts);
}
readonly root: StepperProvider;
readonly provider: StepperProvider;
private constructor(opts: StepperNextTriggerOpts) {
super(opts, 'Stepper', 'next-trigger', attrs['next-trigger']);
this.root = StepperProvider.ctx.get();
this.provider = StepperProvider.ctx.get();
}
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || !this.root.hasNextStep,
() => this.opts.disabled.current || !this.provider.hasNextStep,
);
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.isDisabled) return;
this.root.goToNextStep();
this.provider.goToNextStep();
};
readonly props = $derived.by(() =>

@ -25,8 +25,13 @@
const state = SwitchProvider.from({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
checked: writableActive(() => checked, (v) => (checked = v)),
onCheckedChange: readableActive(() => onCheckedChange),
checked: writableActive(
() => checked,
(v) => {
checked = v;
onCheckedChange(v);
},
),
disabled: readableActive(() => disabled),
required: readableActive(() => required),
name: readableActive(() => name),

@ -1,7 +1,7 @@
import { Provider, context, type WithRefOpts } from '$soma/provider';
import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
import { type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, SomaMouseEvent, SomaKeyboardEvent } from '$soma/types';
import type { SomaMouseEvent, SomaKeyboardEvent } from '$soma/types';
import { KEYS } from '$soma/keyboard';
const attrs = createAttrs({
@ -19,7 +19,6 @@ interface SwitchOpts
required: boolean;
name: string;
value: string;
onCheckedChange: OnChangeFn<boolean>;
}> {}
export class SwitchProvider extends Provider<SwitchOpts> {
@ -35,9 +34,7 @@ export class SwitchProvider extends Provider<SwitchOpts> {
toggle() {
if (this.opts.disabled.current) return;
const next = !this.opts.checked.current;
this.opts.checked.current = next;
this.opts.onCheckedChange.current(next);
this.opts.checked.current = !this.opts.checked.current;
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
@ -90,18 +87,18 @@ export class SwitchThumbProvider extends Provider<SwitchThumbOpts> {
return new SwitchThumbProvider(opts);
}
readonly root: SwitchProvider;
readonly provider: SwitchProvider;
private constructor(opts: SwitchThumbOpts) {
super(opts, 'Switch', 'thumb', attrs.thumb);
this.root = SwitchProvider.ctx.get();
this.provider = SwitchProvider.ctx.get();
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-state': this.root.opts.checked.current ? 'checked' : 'unchecked',
'data-disabled': boolToEmptyStrOrUndef(this.root.opts.disabled.current),
'data-state': this.provider.opts.checked.current ? 'checked' : 'unchecked',
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
} as const),
);
}

@ -0,0 +1,192 @@
# Table
Headless table with sorting, row selection, and pagination. Provides a reactive state manager (`createTable`) and Svelte components with ARIA grid pattern, keyboard support, and data-* attributes for styling.
## Anatomy
```svelte
<script>
import { Table, createTable, type ColumnDef } from '$soma/components/table';
const columns: ColumnDef<Person>[] = [
{ accessorKey: 'name', header: 'Name', enableSorting: true },
{ accessorKey: 'age', header: 'Age', enableSorting: true },
];
const table = createTable({
data: () => people,
columns,
sorting: { enabled: true },
selection: { mode: 'multiple' },
pagination: { enabled: true, pageSize: 10 },
});
</script>
<Table.Provider {table}>
<table role="grid">
<thead>
<tr>
{#each table.headers as header}
<Table.ColumnHeader {header}>{header.label}</Table.ColumnHeader>
{/each}
</tr>
</thead>
<tbody>
{#each table.rows as row (row.id)}
<Table.Row {row}>
{#each row.cells as cell, i}
<Table.Cell {cell} colIndex={i}>{cell.value}</Table.Cell>
{/each}
</Table.Row>
{/each}
</tbody>
</table>
</Table.Provider>
```
## Parts
| Part | Element | Description |
|------|---------|-------------|
| `Provider` | none | Root context. Receives the table instance. No DOM. |
| `ColumnHeader` | `<th>` | Column header. Click to sort. ARIA sort indicators. |
| `Row` | `<tr>` | Table row. Click to select. ARIA selected/rowindex. |
| `Cell` | `<td>` | Table cell. ARIA colindex. |
## State Manager (`createTable`)
```ts
const table = createTable({
data: () => dataArray, // reactive data source
columns: columnDefs, // column definitions
getRowId?: (row, idx) => id, // custom row ID (default: index)
sorting?: {
enabled: true,
multi: false, // allow multi-column sort (Shift+click)
manual: false, // server-side sort (skip client sort)
},
selection?: {
mode: 'none' | 'single' | 'multiple',
},
pagination?: {
enabled: true,
manual: false, // server-side pagination
pageSize: 10,
},
rowCount?: number, // total rows for server-side pagination
// Callbacks
onSortingChange?: (sorting) => void,
onSelectionChange?: (selection) => void,
onPaginationChange?: (pagination) => void,
});
```
## ARIA
| Part | Attribute | Value |
|------|-----------|-------|
| Root (`<table>`) | `role` | `grid` (consumer adds) |
| Root | `aria-label` | Consumer provides |
| Root | `aria-rowcount` | Consumer provides if server-side |
| ColumnHeader | `role` | `columnheader` |
| ColumnHeader | `aria-sort` | `ascending` \| `descending` \| `none` |
| ColumnHeader | `aria-label` | Translated sort state |
| Row | `role` | `row` |
| Row | `aria-rowindex` | Row index (1-based) |
| Row | `aria-selected` | `true` \| `false` (when selection enabled) |
| Cell | `role` | `gridcell` |
| Cell | `aria-colindex` | Column index (1-based) |
## Data Attributes
| Part | Attribute | Values |
|------|-----------|--------|
| ColumnHeader | `data-table-column-header` | Always present |
| ColumnHeader | `data-sortable` | Present when sortable |
| ColumnHeader | `data-sorted` | `asc` \| `desc` (when sorted) |
| Row | `data-table-row` | Always present |
| Row | `data-selected` | Present when selected |
| Cell | `data-table-cell` | Always present |
## Keyboard
| Key | Action |
|-----|--------|
| `Enter` / `Space` on ColumnHeader | Toggle sort (Shift for multi-sort) |
| `Enter` / `Space` on Row | Toggle row selection |
## Sorting
- **Click** a header: replaces current sort with that column (asc → desc → none)
- **Shift+click** a header: adds column to multi-sort (when `multi: true`)
- **Manual mode**: set `manual: true`, handle `onSortingChange`, sort data externally
## Selection
- **None**: no row selection
- **Single**: one row at a time, click to select/deselect
- **Multiple**: any number, click to toggle, checkbox integration via `getIsRowSelected` / `toggleAllSelection`
## Pagination
- **Client-side**: `createTable` slices data internally
- **Server-side**: set `manual: true` + `rowCount`, load data per page externally
## Usage
### Sortable + Selectable + Paginated
```svelte
<script>
const table = createTable({
data: () => people,
columns,
sorting: { enabled: true, multi: true },
selection: { mode: 'multiple' },
pagination: { enabled: true, pageSize: 10 },
});
</script>
```
### Server-side pagination
```svelte
<script>
const table = createTable({
data: () => currentPageData,
columns,
pagination: { enabled: true, manual: true, pageSize: 20 },
rowCount: totalRowsFromServer,
onPaginationChange: (p) => fetchPage(p.pageIndex),
});
</script>
```
## Comparison with reference libraries
| Feature | TanStack Table | React Aria | Soma Table |
|---------|---------------|------------|------------|
| Framework | Agnostic | React only | Svelte (soma) |
| Sorting (single) | Yes | Yes | Yes |
| Sorting (multi) | Yes | No | Yes (Shift+click) |
| Custom sort fn | Yes | No | Yes |
| Server-side sort | Yes | Manual | Yes (manual mode) |
| Selection none/single/multi | Yes | Yes | Yes |
| Select all / checkbox | Yes | Yes | Yes |
| Pagination (client) | Yes | No | Yes |
| Pagination (server) | Yes | No | Yes (manual mode) |
| ARIA grid pattern | No (manual) | Yes (auto) | Yes (auto) |
| Keyboard sort | No (manual) | Yes | Yes (Enter/Space) |
| Keyboard select | No (manual) | Yes | Yes (Enter/Space) |
| `aria-sort` on headers | No (manual) | Yes | Yes |
| `data-*` attributes | No | No | Yes |
| Svelte components | Via adapter | No | Native |
| Column filtering | Yes | No | Planned |
| Column visibility | Yes | No | Planned |
| Column resizing | Yes | Partial | Planned |
| Column pinning | Yes | No | Planned |
| Row expansion | Yes | No | Planned |
| Grouping / aggregation | Yes | No | Planned |
| Virtual scrolling | Via TanStack Virtual | No | Planned |

@ -0,0 +1,36 @@
<script lang="ts">
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { TableCellProvider } from '../table-provider.svelte';
import type { TableCellProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'table-cell'),
cell,
colIndex,
children,
child,
...restProps
}: TableCellProps = $props();
const state = TableCellProvider.from({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
cell: readableActive(() => cell),
colIndex: readableActive(() => colIndex),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<td {...mergedProps}>
{@render children?.()}
</td>
{/if}

@ -0,0 +1,34 @@
<script lang="ts">
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { TableColumnHeaderProvider } from '../table-provider.svelte';
import type { TableColumnHeaderProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'table-column-header'),
header,
children,
child,
...restProps
}: TableColumnHeaderProps = $props();
const state = TableColumnHeaderProvider.from({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
header: readableActive(() => header),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<th {...mergedProps}>
{@render children?.()}
</th>
{/if}

@ -0,0 +1,34 @@
<script lang="ts">
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { TableRowProvider } from '../table-provider.svelte';
import type { TableRowProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'table-row'),
row,
children,
child,
...restProps
}: TableRowProps = $props();
const state = TableRowProvider.from({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
row: readableActive(() => row),
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<tr {...mergedProps}>
{@render children?.()}
</tr>
{/if}

@ -0,0 +1,22 @@
<script lang="ts">
import { readableActive } from '$soma/reactive';
import { createId } from '$soma/id';
import { TableProvider } from '../table-provider.svelte';
import type { TableProviderProps } from '../types';
const uid = $props.id();
let {
table,
label = 'Data table',
children,
}: TableProviderProps = $props();
TableProvider.from({
id: readableActive(() => createId(uid, 'table')),
table: readableActive(() => table),
label: readableActive(() => label),
});
</script>
{@render children?.()}

@ -0,0 +1,34 @@
export { default as Provider } from './components/table.svelte';
export { default as ColumnHeader } from './components/table-column-header.svelte';
export { default as Row } from './components/table-row.svelte';
export { default as Cell } from './components/table-cell.svelte';
export { createTable, filterFns } from './table-core.svelte';
export type { BuiltInFilterFn } from './table-core.svelte';
export type {
ColumnDef,
SortingEntry,
SortingState,
PaginationState,
SelectionMode,
RowSelectionState,
ColumnVisibilityState,
ColumnPinningState,
ColumnSizingState,
ExpandedState,
ColumnFilterEntry,
ColumnFiltersState,
TableConfig,
TableInstance,
TableHeader,
TableRow,
TableCell,
} from './table-core.svelte';
export type {
TableProviderProps as ProviderProps,
TableColumnHeaderProps as ColumnHeaderProps,
TableRowProps as RowProps,
TableCellProps as CellProps,
} from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,699 @@
import { Provider, context, type WithRefOpts, type ProviderOpts } from '$soma/provider';
import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
import { state, readableActive, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, SomaKeyboardEvent } from '$soma/types';
import { KEYS } from '$soma/keyboard';
import { Soma } from '$soma/core/soma.svelte';
const attrs = createAttrs({
component: 'table',
parts: ['root', 'header', 'body', 'footer', 'column-header', 'row', 'cell'] as const,
});
// ── Column definition ────────────────────────────────────────────────────────
export interface ColumnDef<TData = any> {
/** Unique column identifier. Derived from accessorKey if omitted. */
id?: string;
/** Key in data object to access cell value. */
accessorKey?: string;
/** Function to access cell value from row data. */
accessorFn?: (row: TData, index: number) => any;
/** Header label or render function. */
header?: string;
/** Footer label. */
footer?: string;
/** Enable sorting for this column. @default false */
enableSorting?: boolean;
/** Custom sort comparator. Receives two cell values. */
sortingFn?: (a: any, b: any) => number;
/** Sort descending first when toggling. @default false */
sortDescFirst?: boolean;
/** Enable filtering for this column. @default false */
enableFiltering?: boolean;
/** Custom filter function. */
filterFn?: (row: TData, columnId: string, filterValue: any) => boolean;
/** Enable hiding for this column. @default true */
enableHiding?: boolean;
/** Enable resizing for this column. @default false */
enableResizing?: boolean;
/** Enable pinning for this column. @default false */
enablePinning?: boolean;
/** Initial column width in px. */
size?: number;
/** Minimum column width. @default 40 */
minSize?: number;
/** Maximum column width. */
maxSize?: number;
}
// ── Sort state ───────────────────────────────────────────────────────────────
export interface SortingEntry {
/** Column ID. */
id: string;
/** Sort descending. */
desc: boolean;
}
export type SortingState = SortingEntry[];
// ── Pagination state ─────────────────────────────────────────────────────────
export interface PaginationState {
pageIndex: number;
pageSize: number;
}
// ── Selection state ──────────────────────────────────────────────────────────
export type SelectionMode = 'none' | 'single' | 'multiple';
export type RowSelectionState = Record<string, boolean>;
// ── Column visibility ────────────────────────────────────────────────────────
export type ColumnVisibilityState = Record<string, boolean>;
// ── Column pinning ───────────────────────────────────────────────────────────
export interface ColumnPinningState {
left?: string[];
right?: string[];
}
// ── Column sizing ────────────────────────────────────────────────────────────
export type ColumnSizingState = Record<string, number>;
// ── Expansion ────────────────────────────────────────────────────────────────
export type ExpandedState = Record<string, boolean>;
// ── Filter state ─────────────────────────────────────────────────────────────
export interface ColumnFilterEntry {
id: string;
value: any;
}
export type ColumnFiltersState = ColumnFilterEntry[];
// ── Resolved column (internal) ───────────────────────────────────────────────
export interface ResolvedColumn<TData = any> {
id: string;
def: ColumnDef<TData>;
getvalue(row: TData): any;
}
function resolveColumns<TData>(defs: ColumnDef<TData>[]): ResolvedColumn<TData>[] {
return defs.map((def, idx) => {
const id = def.id ?? def.accessorKey ?? `col_${idx}`;
const getvalue = def.accessorFn
? (row: TData) => def.accessorFn!(row, 0)
: def.accessorKey
? (row: TData) => (row as any)[def.accessorKey!]
: () => undefined;
return { id, def, getvalue };
});
}
// ── Sort logic ───────────────────────────────────────────────────────────────
function defaultSortFn(a: any, b: any): number {
if (a == null && b == null) return 0;
if (a == null) return -1;
if (b == null) return 1;
if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b);
if (typeof a === 'number' && typeof b === 'number') return a - b;
return String(a).localeCompare(String(b));
}
function applySorting<TData>(
rows: TData[],
sorting: SortingState,
columns: ResolvedColumn<TData>[],
): TData[] {
if (sorting.length === 0) return rows;
const sorted = [...rows];
sorted.sort((a, b) => {
for (const entry of sorting) {
const col = columns.find((c) => c.id === entry.id);
if (!col) continue;
const sortFn = col.def.sortingFn ?? defaultSortFn;
const result = sortFn(col.getvalue(a), col.getvalue(b));
if (result !== 0) return entry.desc ? -result : result;
}
return 0;
});
return sorted;
}
// ── Built-in filter functions ─────────────────────────────────────────────
export const filterFns = {
includesString: (cellValue: any, filterValue: string) =>
String(cellValue).toLowerCase().includes(String(filterValue).toLowerCase()),
equals: (cellValue: any, filterValue: any) => cellValue === filterValue,
weakEquals: (cellValue: any, filterValue: any) => cellValue == filterValue,
inNumberRange: (cellValue: any, filterValue: [number, number]) => {
const num = Number(cellValue);
return num >= filterValue[0] && num <= filterValue[1];
},
arrIncludes: (cellValue: any, filterValue: any) =>
Array.isArray(cellValue) && cellValue.includes(filterValue),
} as const;
export type BuiltInFilterFn = keyof typeof filterFns;
// ── Filter logic ─────────────────────────────────────────────────────────
function applyColumnFilters<TData>(
rows: TData[],
filters: ColumnFiltersState,
columns: ResolvedColumn<TData>[],
): TData[] {
if (filters.length === 0) return rows;
return rows.filter((row) =>
filters.every((filter) => {
const col = columns.find((c) => c.id === filter.id);
if (!col) return true;
const cellValue = col.getvalue(row);
if (col.def.filterFn) return col.def.filterFn(row, col.id, filter.value);
return filterFns.includesString(cellValue, filter.value);
}),
);
}
function applyGlobalFilter<TData>(
rows: TData[],
globalFilter: string,
columns: ResolvedColumn<TData>[],
globalFilterFn?: (row: TData, columnId: string, filterValue: any) => boolean,
): TData[] {
if (!globalFilter) return rows;
return rows.filter((row) =>
columns.some((col) => {
if (col.def.enableFiltering === false) return false;
const cellValue = col.getvalue(row);
if (globalFilterFn) return globalFilterFn(row, col.id, globalFilter);
return filterFns.includesString(cellValue, globalFilter);
}),
);
}
// ── Pagination logic ─────────────────────────────────────────────────────────
function applyPagination<TData>(
rows: TData[],
pagination: PaginationState | undefined,
): TData[] {
if (!pagination) return rows;
const start = pagination.pageIndex * pagination.pageSize;
return rows.slice(start, start + pagination.pageSize);
}
// ── Table row/cell models ────────────────────────────────────────────────────
export interface TableHeader<TData = any> {
id: string;
column: ResolvedColumn<TData>;
label: string;
}
export interface TableCell<TData = any> {
id: string;
column: ResolvedColumn<TData>;
value: any;
}
export interface TableRow<TData = any> {
id: string;
index: number;
original: TData;
cells: TableCell<TData>[];
}
// ── Table instance ───────────────────────────────────────────────────────────
export interface TableConfig<TData> {
/** Row data. Function for reactivity. */
data: () => TData[];
/** Column definitions. */
columns: ColumnDef<TData>[];
/** Get unique row ID. Default: uses index. */
getRowId?: (row: TData, index: number) => string;
// ── Sorting ──────────────────────────────────────────────────────────
sorting?: {
enabled?: boolean;
/** Allow multi-column sort. @default false */
multi?: boolean;
/** Server-side sort (skip client sort). @default false */
manual?: boolean;
};
/** Initial or controlled sorting state. */
initialSorting?: SortingState;
onSortingChange?: OnChangeFn<SortingState>;
// ── Filtering ────────────────────────────────────────────────────────
filtering?: {
enabled?: boolean;
/** Server-side filtering (skip client filter). @default false */
manual?: boolean;
/** Custom global filter function. Default: includesString across all columns. */
globalFilterFn?: (row: TData, columnId: string, filterValue: any) => boolean;
};
initialColumnFilters?: ColumnFiltersState;
initialGlobalFilter?: string;
onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>;
onGlobalFilterChange?: OnChangeFn<string>;
// ── Selection ────────────────────────────────────────────────────────
selection?: {
mode?: SelectionMode;
};
initialSelection?: RowSelectionState;
onSelectionChange?: OnChangeFn<RowSelectionState>;
// ── Pagination ───────────────────────────────────────────────────────
pagination?: {
enabled?: boolean;
/** Server-side pagination (skip client slice). @default false */
manual?: boolean;
pageSize?: number;
};
initialPagination?: PaginationState;
onPaginationChange?: OnChangeFn<PaginationState>;
// ── Column visibility ────────────────────────────────────────────────
initialColumnVisibility?: ColumnVisibilityState;
onColumnVisibilityChange?: OnChangeFn<ColumnVisibilityState>;
/** Total row count (for server-side pagination). */
rowCount?: number;
}
export interface TableInstance<TData = any> {
// ── Computed ──────────────────────────────────────────────────────────
readonly columns: ResolvedColumn<TData>[];
readonly headers: TableHeader<TData>[];
readonly rows: TableRow<TData>[];
readonly allRows: TData[];
// ── Sorting ──────────────────────────────────────────────────────────
readonly sorting: SortingState;
toggleSort(columnId: string, multi?: boolean): void;
setSorting(sorting: SortingState): void;
clearSorting(): void;
getIsSorted(columnId: string): false | 'asc' | 'desc';
getCanSort(columnId: string): boolean;
// ── Filtering ────────────────────────────────────────────────────────
readonly columnFilters: ColumnFiltersState;
readonly globalFilter: string;
setColumnFilter(columnId: string, value: any): void;
removeColumnFilter(columnId: string): void;
setGlobalFilter(value: string): void;
clearFilters(): void;
getColumnFilterValue(columnId: string): any;
getCanFilter(columnId: string): boolean;
// ── Selection ────────────────────────────────────────────────────────
readonly selection: RowSelectionState;
readonly selectionMode: SelectionMode;
toggleRowSelection(rowId: string): void;
setRowSelection(selection: RowSelectionState): void;
toggleAllSelection(): void;
getIsRowSelected(rowId: string): boolean;
getIsAllSelected(): boolean;
getIsSomeSelected(): boolean;
// ── Column visibility ────────────────────────────────────────────────
readonly columnVisibility: ColumnVisibilityState;
toggleColumnVisibility(columnId: string): void;
setColumnVisibility(visibility: ColumnVisibilityState): void;
getIsColumnVisible(columnId: string): boolean;
getVisibleColumns(): ResolvedColumn<TData>[];
// ── Pagination ───────────────────────────────────────────────────────
readonly pagination: PaginationState;
readonly pageCount: number;
readonly canPreviousPage: boolean;
readonly canNextPage: boolean;
setPageIndex(index: number): void;
setPageSize(size: number): void;
previousPage(): void;
nextPage(): void;
firstPage(): void;
lastPage(): void;
}
// ── Factory ──────────────────────────────────────────────────────────────────
export function createTable<TData>(config: TableConfig<TData>): TableInstance<TData> {
const resolvedColumns = resolveColumns(config.columns);
const getRowId = config.getRowId ?? ((_row: TData, index: number) => String(index));
// ── Mutable state ────────────────────────────────────────────────────
let sorting = $state<SortingState>(config.initialSorting ?? []);
let columnFilters = $state<ColumnFiltersState>(config.initialColumnFilters ?? []);
let globalFilter = $state<string>(config.initialGlobalFilter ?? '');
let columnVisibility = $state<ColumnVisibilityState>(config.initialColumnVisibility ?? {});
let selection = $state<RowSelectionState>(config.initialSelection ?? {});
let pagination = $state<PaginationState>(
config.initialPagination ?? {
pageIndex: 0,
pageSize: config.pagination?.pageSize ?? 10,
},
);
// ── Derived: sorted + paginated rows ─────────────────────────────────
const allRows = $derived.by(() => config.data());
const filteredRows = $derived.by(() => {
if (!config.filtering?.enabled || config.filtering?.manual) return allRows;
let rows = applyColumnFilters(allRows, columnFilters, resolvedColumns);
rows = applyGlobalFilter(rows, globalFilter, resolvedColumns, config.filtering?.globalFilterFn);
return rows;
});
const sortedRows = $derived.by(() => {
if (!config.sorting?.enabled || config.sorting?.manual) return filteredRows;
return applySorting(filteredRows, sorting, resolvedColumns);
});
const totalRowCount = $derived.by(() => config.rowCount ?? sortedRows.length);
const pageCount = $derived.by(() => {
if (!config.pagination?.enabled) return 1;
return Math.max(1, Math.ceil(totalRowCount / pagination.pageSize));
});
const paginatedRows = $derived.by(() => {
if (!config.pagination?.enabled || config.pagination?.manual) return sortedRows;
return applyPagination(sortedRows, pagination);
});
// ── Derived: table model ─────────────────────────────────────────────
const headers = $derived.by(() =>
getVisibleColumns().map((col) => ({
id: col.id,
column: col,
label: col.def.header ?? col.id,
} as TableHeader<TData>)),
);
const rows = $derived.by(() =>
paginatedRows.map((data, idx) => {
const globalIdx = config.pagination?.enabled
? pagination.pageIndex * pagination.pageSize + idx
: idx;
const id = getRowId(data, globalIdx);
const visibleCols = getVisibleColumns();
const cells: TableCell<TData>[] = visibleCols.map((col) => ({
id: `${id}_${col.id}`,
column: col,
value: col.getvalue(data),
}));
return { id, index: globalIdx, original: data, cells } as TableRow<TData>;
}),
);
// ── Column visibility actions ────────────────────────────────────────
function getIsColumnVisible(columnId: string): boolean {
return columnVisibility[columnId] !== false;
}
function getVisibleColumns(): ResolvedColumn<TData>[] {
return resolvedColumns.filter((col) => getIsColumnVisible(col.id));
}
function toggleColumnVisibility(columnId: string) {
columnVisibility = { ...columnVisibility, [columnId]: !getIsColumnVisible(columnId) };
config.onColumnVisibilityChange?.(columnVisibility);
}
function setColumnVisibility(v: ColumnVisibilityState) {
columnVisibility = v;
config.onColumnVisibilityChange?.(v);
}
// ── Filtering actions ────────────────────────────────────────────────
function setColumnFilter(columnId: string, value: any) {
const existing = columnFilters.findIndex((f) => f.id === columnId);
if (existing >= 0) {
columnFilters = columnFilters.map((f) => (f.id === columnId ? { ...f, value } : f));
} else {
columnFilters = [...columnFilters, { id: columnId, value }];
}
config.onColumnFiltersChange?.(columnFilters);
// Reset to first page when filters change
if (config.pagination?.enabled) {
pagination = { ...pagination, pageIndex: 0 };
config.onPaginationChange?.(pagination);
}
}
function removeColumnFilter(columnId: string) {
columnFilters = columnFilters.filter((f) => f.id !== columnId);
config.onColumnFiltersChange?.(columnFilters);
}
function setGlobalFilter(value: string) {
globalFilter = value;
config.onGlobalFilterChange?.(value);
if (config.pagination?.enabled) {
pagination = { ...pagination, pageIndex: 0 };
config.onPaginationChange?.(pagination);
}
}
function clearFilters() {
columnFilters = [];
globalFilter = '';
config.onColumnFiltersChange?.([]);
config.onGlobalFilterChange?.('');
}
function getColumnFilterValue(columnId: string): any {
return columnFilters.find((f) => f.id === columnId)?.value;
}
function getCanFilter(columnId: string): boolean {
if (!config.filtering?.enabled) return false;
const col = resolvedColumns.find((c) => c.id === columnId);
return col?.def.enableFiltering !== false;
}
// ── Sorting actions ──────────────────────────────────────────────────
/**
* Toggle sort on a column.
* @param columnId Column to sort.
* @param multi If true, adds to multi-sort. If false, replaces.
* Typically bound to Shift key by the component.
*/
function toggleSort(columnId: string, multi: boolean = false) {
if (!config.sorting?.enabled) return;
const col = resolvedColumns.find((c) => c.id === columnId);
if (!col || col.def.enableSorting === false) return;
const useMulti = multi && config.sorting.multi === true;
const existing = sorting.find((s) => s.id === columnId);
let next: SortingState;
if (!existing) {
// Add new sort entry
const desc = col.def.sortDescFirst ?? false;
const entry: SortingEntry = { id: columnId, desc };
next = useMulti ? [...sorting, entry] : [entry];
} else if (!existing.desc) {
// asc → desc
if (useMulti) {
next = sorting.map((s) => (s.id === columnId ? { ...s, desc: true } : s));
} else {
next = [{ id: columnId, desc: true }];
}
} else {
// desc → remove
next = sorting.filter((s) => s.id !== columnId);
}
sorting = next;
config.onSortingChange?.(next);
}
function setSorting(s: SortingState) {
sorting = s;
config.onSortingChange?.(s);
}
function clearSorting() {
sorting = [];
config.onSortingChange?.([]);
}
function getIsSorted(columnId: string): false | 'asc' | 'desc' {
const entry = sorting.find((s) => s.id === columnId);
if (!entry) return false;
return entry.desc ? 'desc' : 'asc';
}
function getCanSort(columnId: string): boolean {
if (!config.sorting?.enabled) return false;
const col = resolvedColumns.find((c) => c.id === columnId);
return col?.def.enableSorting !== false;
}
// ── Selection actions ────────────────────────────────────────────────
const selectionMode = config.selection?.mode ?? 'none';
function toggleRowSelection(rowId: string) {
if (selectionMode === 'none') return;
const next = { ...selection };
if (selectionMode === 'single') {
if (next[rowId]) {
delete next[rowId];
} else {
for (const key of Object.keys(next)) delete next[key];
next[rowId] = true;
}
} else {
if (next[rowId]) {
delete next[rowId];
} else {
next[rowId] = true;
}
}
selection = next;
config.onSelectionChange?.(next);
}
function setRowSelection(s: RowSelectionState) {
selection = s;
config.onSelectionChange?.(s);
}
function toggleAllSelection() {
if (selectionMode !== 'multiple') return;
const allSelected = getIsAllSelected();
if (allSelected) {
selection = {};
} else {
const next: RowSelectionState = {};
sortedRows.forEach((row, idx) => {
next[getRowId(row, idx)] = true;
});
selection = next;
}
config.onSelectionChange?.(selection);
}
function getIsRowSelected(rowId: string): boolean {
return !!selection[rowId];
}
function getIsAllSelected(): boolean {
if (sortedRows.length === 0) return false;
return sortedRows.every((row, idx) => selection[getRowId(row, idx)]);
}
function getIsSomeSelected(): boolean {
const selectedCount = Object.keys(selection).filter((k) => selection[k]).length;
return selectedCount > 0 && selectedCount < sortedRows.length;
}
// ── Pagination actions ───────────────────────────────────────────────
const canPreviousPage = $derived.by(() => pagination.pageIndex > 0);
const canNextPage = $derived.by(() => pagination.pageIndex < pageCount - 1);
function setPageIndex(index: number) {
const clamped = Math.max(0, Math.min(index, pageCount - 1));
pagination = { ...pagination, pageIndex: clamped };
config.onPaginationChange?.(pagination);
}
function setPageSize(size: number) {
pagination = { pageIndex: 0, pageSize: size };
config.onPaginationChange?.(pagination);
}
function previousPage() { setPageIndex(pagination.pageIndex - 1); }
function nextPage() { setPageIndex(pagination.pageIndex + 1); }
function firstPage() { setPageIndex(0); }
function lastPage() { setPageIndex(pageCount - 1); }
// ── Instance ─────────────────────────────────────────────────────────
return {
get columns() { return resolvedColumns; },
get headers() { return headers; },
get rows() { return rows; },
get allRows() { return allRows; },
// Column visibility
get columnVisibility() { return columnVisibility; },
toggleColumnVisibility,
setColumnVisibility,
getIsColumnVisible,
getVisibleColumns,
// Filtering
get columnFilters() { return columnFilters; },
get globalFilter() { return globalFilter; },
setColumnFilter,
removeColumnFilter,
setGlobalFilter,
clearFilters,
getColumnFilterValue,
getCanFilter,
// Sorting
get sorting() { return sorting; },
toggleSort,
setSorting,
clearSorting,
getIsSorted,
getCanSort,
// Selection
get selection() { return selection; },
selectionMode,
toggleRowSelection,
setRowSelection,
toggleAllSelection,
getIsRowSelected,
getIsAllSelected,
getIsSomeSelected,
// Pagination
get pagination() { return pagination; },
get pageCount() { return pageCount; },
get canPreviousPage() { return canPreviousPage; },
get canNextPage() { return canNextPage; },
setPageIndex,
setPageSize,
previousPage,
nextPage,
firstPage,
lastPage,
};
}

@ -0,0 +1,206 @@
import { Provider, context, type ProviderOpts, type WithRefOpts } from '$soma/provider';
import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
import { type ActiveProps } from '$soma/reactive';
import type { SomaKeyboardEvent } from '$soma/types';
import { KEYS } from '$soma/keyboard';
import { getSoma } from '$soma/core/soma.svelte';
import { resolveSomaTranslationPath } from '$soma/core/translator.svelte';
import type { TableInstance, TableHeader, TableRow, TableCell } from './table-core.svelte';
const attrs = createAttrs({
component: 'table',
parts: ['root', 'header', 'body', 'footer', 'column-header', 'row', 'cell'] as const,
});
// ── Provider (root, no DOM) ─────────────────────────────────────────────────
interface TableProviderOpts
extends ProviderOpts,
ActiveProps<{
table: TableInstance;
label: string;
}> {}
export class TableProvider extends Provider<TableProviderOpts> {
static readonly ctx = context<TableProvider>('Table');
static from(opts: TableProviderOpts) {
return new TableProvider(opts);
}
readonly config = getSoma();
private constructor(opts: TableProviderOpts) {
super(opts, 'Table', 'root', attrs.root, TableProvider.ctx);
}
get table(): TableInstance {
return this.opts.table.current;
}
}
// ── ColumnHeader ─────────────────────────────────────────────────────────────
interface TableColumnHeaderOpts
extends WithRefOpts,
ActiveProps<{
header: TableHeader;
}> {}
export class TableColumnHeaderProvider extends Provider<TableColumnHeaderOpts> {
static from(opts: TableColumnHeaderOpts) {
return new TableColumnHeaderProvider(opts);
}
readonly provider: TableProvider;
private constructor(opts: TableColumnHeaderOpts) {
super(opts, 'Table', 'column-header', attrs['column-header']);
this.provider = TableProvider.ctx.get();
}
private get header(): TableHeader {
return this.opts.header.current;
}
private get table(): TableInstance {
return this.provider.table;
}
readonly onclick = (e: MouseEvent) => {
if (this.table.getCanSort(this.header.id)) {
this.table.toggleSort(this.header.id, e.shiftKey);
}
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
if (this.table.getCanSort(this.header.id)) {
e.preventDefault();
this.table.toggleSort(this.header.id, e.shiftKey);
}
}
};
private get sortLabel(): string {
const sorted = this.table.getIsSorted(this.header.id);
const translator = this.provider.config.translator.current;
if (!sorted) {
return resolveSomaTranslationPath('soma.table.sort', translator, 'Sort');
}
return sorted === 'asc'
? resolveSomaTranslationPath('soma.table.sortAsc', translator, 'Sorted ascending')
: resolveSomaTranslationPath('soma.table.sortDesc', translator, 'Sorted descending');
}
readonly props = $derived.by(() => {
const sorted = this.table.getIsSorted(this.header.id);
const canSort = this.table.getCanSort(this.header.id);
const ariaSort: 'ascending' | 'descending' | 'none' = sorted
? sorted === 'asc' ? 'ascending' : 'descending'
: 'none';
return this.assertProps({
...this.baseProps,
role: 'columnheader' as const,
'aria-sort': ariaSort,
'aria-label': canSort ? this.sortLabel : undefined,
title: canSort ? this.sortLabel : undefined,
tabindex: canSort ? 0 : undefined,
'data-sortable': boolToEmptyStrOrUndef(canSort),
'data-sorted': sorted || undefined,
onclick: canSort ? this.onclick : undefined,
onkeydown: canSort ? this.onkeydown : undefined,
} as const);
});
}
// ── Row ──────────────────────────────────────────────────────────────────────
interface TableRowOpts
extends WithRefOpts,
ActiveProps<{
row: TableRow;
}> {}
export class TableRowProvider extends Provider<TableRowOpts> {
static readonly ctx = context<TableRowProvider>('TableRow');
static from(opts: TableRowOpts) {
return new TableRowProvider(opts);
}
readonly provider: TableProvider;
private constructor(opts: TableRowOpts) {
super(opts, 'Table', 'row', attrs.row, TableRowProvider.ctx);
this.provider = TableProvider.ctx.get();
}
private get row(): TableRow {
return this.opts.row.current;
}
private get table(): TableInstance {
return this.provider.table;
}
readonly isSelected = $derived.by(
() => this.table.getIsRowSelected(this.row.id),
);
readonly onclick = () => {
if (this.table.selectionMode !== 'none') {
this.table.toggleRowSelection(this.row.id);
}
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
if (e.key === KEYS.SPACE || e.key === KEYS.ENTER) {
if (this.table.selectionMode !== 'none') {
e.preventDefault();
this.table.toggleRowSelection(this.row.id);
}
}
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'row' as const,
'aria-rowindex': this.row.index + 1,
'aria-selected': this.table.selectionMode !== 'none' ? this.isSelected : undefined,
tabindex: this.table.selectionMode !== 'none' ? 0 : undefined,
'data-selected': boolToEmptyStrOrUndef(this.isSelected),
onclick: this.table.selectionMode !== 'none' ? this.onclick : undefined,
onkeydown: this.table.selectionMode !== 'none' ? this.onkeydown : undefined,
} as const),
);
}
// ── Cell ─────────────────────────────────────────────────────────────────────
interface TableCellOpts
extends WithRefOpts,
ActiveProps<{
cell: TableCell;
colIndex: number;
}> {}
export class TableCellProvider extends Provider<TableCellOpts> {
static from(opts: TableCellOpts) {
return new TableCellProvider(opts);
}
private constructor(opts: TableCellOpts) {
super(opts, 'Table', 'cell', attrs.cell);
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'gridcell' as const,
'aria-colindex': this.opts.colIndex.current + 1,
} as const),
);
}

@ -0,0 +1,42 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without } from '$soma/types';
import type { PrimitiveDivAttributes } from '$soma/types';
import type { TableInstance, TableHeader, TableRow, TableCell } from './table-core.svelte';
export type TableProviderProps = {
/** The table instance. Created via `createTable()`. Required. */
table: TableInstance;
/**
* Accessible label for the table.
* @default 'Data table'
*/
label?: string;
/** Content. */
children?: Snippet;
};
export type TableColumnHeaderProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** The header object from `table.headers`. Required. */
header: TableHeader;
}> &
Without<PrimitiveDivAttributes, {}>;
export type TableRowProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** The row object from `table.rows`. Required. */
row: TableRow;
}> &
Without<PrimitiveDivAttributes, {}>;
export type TableCellProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** The cell object from `row.cells`. Required. */
cell: TableCell;
/** Column index (0-based). Required. */
colIndex: number;
}> &
Without<PrimitiveDivAttributes, {}>;

@ -2,13 +2,13 @@
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { TabsProvider } from '../tabs-provider.svelte';
import type { TabsProps } from '../types';
import { noop } from '$lib/util/funcs';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
ref = $bindable(null),
@ -20,7 +20,7 @@
activationMode = 'automatic',
disabled = false,
loop = true,
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
children,
child,
...restProps
@ -34,9 +34,11 @@
),
value: writableActive(
() => value,
(v) => (value = v),
(v) => {
value = v;
onValueChange(v);
},
),
onValueChange: readableActive(() => onValueChange),
onFocusChange: readableActive(() => onFocusChange),
orientation: readableActive(() => orientation),
activationMode: readableActive(() => activationMode),

@ -4,7 +4,7 @@ import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
import { readableActive, state, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, Orientation, Direction, SomaMouseEvent, SomaKeyboardEvent } from '$soma/types';
import { KEYS, getDirectionalKeys } from '$soma/keyboard';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { Presence } from '$soma/layers/presence.svelte';
@ -26,7 +26,6 @@ interface TabsOpts
disabled: boolean;
loop: boolean;
dir: Direction;
onValueChange: OnChangeFn<string>;
onFocusChange: OnChangeFn<string>;
}> {}
@ -37,7 +36,7 @@ export class TabsProvider extends Provider<TabsOpts> {
return new TabsProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
// ID registries for cross-linking trigger ↔ content by value
triggerIds: Record<string, string> = $state({});
@ -45,14 +44,13 @@ export class TabsProvider extends Provider<TabsOpts> {
private constructor(opts: TabsOpts) {
super(opts, 'Tabs', 'root', attrs.root, TabsProvider.ctx);
this.soma = Soma.getOr();
}
selectTab(value: string) {
if (this.opts.disabled.current) return;
if (this.opts.value.current === value) return;
this.opts.value.current = value;
this.opts.onValueChange.current(value);
}
/** Get all enabled trigger elements for keyboard navigation. */
@ -81,19 +79,19 @@ export class TabsListProvider extends Provider<TabsListOpts> {
return new TabsListProvider(opts);
}
readonly root: TabsProvider;
readonly provider: TabsProvider;
private constructor(opts: TabsListOpts) {
super(opts, 'Tabs', 'list', attrs.list);
this.root = TabsProvider.ctx.get();
this.provider = TabsProvider.ctx.get();
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'tablist' as const,
'aria-orientation': this.root.opts.orientation.current,
'data-orientation': this.root.opts.orientation.current,
'aria-orientation': this.provider.opts.orientation.current,
'data-orientation': this.provider.opts.orientation.current,
} as const),
);
}
@ -112,45 +110,45 @@ export class TabsTriggerProvider extends Provider<TabsTriggerOpts> {
return new TabsTriggerProvider(opts);
}
readonly root: TabsProvider;
readonly provider: TabsProvider;
private constructor(opts: TabsTriggerOpts) {
super(opts, 'Tabs', 'trigger', attrs.trigger);
this.root = TabsProvider.ctx.get();
this.root.triggerIds[opts.value.current] = opts.id.current;
this.provider = TabsProvider.ctx.get();
this.provider.triggerIds[opts.value.current] = opts.id.current;
}
readonly isActive = $derived.by(
() => this.root.opts.value.current === this.opts.value.current,
() => this.provider.opts.value.current === this.opts.value.current,
);
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || this.root.opts.disabled.current,
() => this.opts.disabled.current || this.provider.opts.disabled.current,
);
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.isDisabled) return;
this.root.selectTab(this.opts.value.current);
this.provider.selectTab(this.opts.value.current);
};
readonly onfocus = () => {
if (this.isDisabled) return;
this.root.opts.onFocusChange.current(this.opts.value.current);
if (this.root.opts.activationMode.current === 'automatic') {
this.root.selectTab(this.opts.value.current);
this.provider.opts.onFocusChange.current(this.opts.value.current);
if (this.provider.opts.activationMode.current === 'automatic') {
this.provider.selectTab(this.opts.value.current);
}
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
if (this.isDisabled) return;
const root = this.root;
const orientation = root.opts.orientation.current;
const dir = root.opts.dir.current;
const p = this.provider;
const orientation = p.opts.orientation.current;
const dir = p.opts.dir.current;
const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);
const triggers = root.getTriggers();
const triggers = p.getTriggers();
const currentIndex = triggers.indexOf(e.currentTarget as HTMLButtonElement);
const loop = root.opts.loop.current;
const loop = p.opts.loop.current;
let targetIndex = -1;
@ -172,10 +170,10 @@ export class TabsTriggerProvider extends Provider<TabsTriggerOpts> {
targetIndex = triggers.length - 1;
} else if (
(e.key === KEYS.ENTER || e.key === KEYS.SPACE) &&
root.opts.activationMode.current === 'manual'
p.opts.activationMode.current === 'manual'
) {
e.preventDefault();
root.selectTab(this.opts.value.current);
p.selectTab(this.opts.value.current);
return;
}
@ -190,11 +188,11 @@ export class TabsTriggerProvider extends Provider<TabsTriggerOpts> {
role: 'tab' as const,
type: 'button' as const,
'aria-selected': this.isActive,
'aria-controls': this.root.contentIds[this.opts.value.current] || undefined,
'aria-controls': this.provider.contentIds[this.opts.value.current] || undefined,
tabindex: this.isActive ? 0 : -1,
'data-state': this.isActive ? 'active' : 'inactive',
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-orientation': this.root.opts.orientation.current,
'data-orientation': this.provider.opts.orientation.current,
'data-value': this.opts.value.current,
disabled: this.isDisabled || undefined,
onclick: this.onclick,
@ -217,13 +215,13 @@ export class TabsContentProvider extends Provider<TabsContentOpts> {
return new TabsContentProvider(opts);
}
readonly root: TabsProvider;
readonly provider: TabsProvider;
readonly presence: Presence;
private constructor(opts: TabsContentOpts) {
super(opts, 'Tabs', 'content', attrs.content);
this.root = TabsProvider.ctx.get();
this.root.contentIds[opts.value.current] = opts.id.current;
this.provider = TabsProvider.ctx.get();
this.provider.contentIds[opts.value.current] = opts.id.current;
this.presence = new Presence({
open: readableActive(() => this.isActive),
@ -232,7 +230,7 @@ export class TabsContentProvider extends Provider<TabsContentOpts> {
}
readonly isActive = $derived.by(
() => this.root.opts.value.current === this.opts.value.current,
() => this.provider.opts.value.current === this.opts.value.current,
);
readonly isPresent = $derived.by(() => this.presence.isPresent);
@ -241,10 +239,10 @@ export class TabsContentProvider extends Provider<TabsContentOpts> {
this.assertProps({
...this.baseProps,
role: 'tabpanel' as const,
'aria-labelledby': this.root.triggerIds[this.opts.value.current] || undefined,
'aria-labelledby': this.provider.triggerIds[this.opts.value.current] || undefined,
tabindex: 0,
'data-state': this.isActive ? 'active' : 'inactive',
'data-orientation': this.root.opts.orientation.current,
'data-orientation': this.provider.opts.orientation.current,
...this.presence.transitionAttrs,
} as const),
);

@ -33,7 +33,13 @@
const state = TagsInputProvider.from({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
value: writableActive(() => value, (v) => (value = v)),
value: writableActive(
() => value,
(v) => {
value = v;
onValueChange(v);
},
),
inputValue: writableActive(() => inputValue, (v) => (inputValue = v)),
max: readableActive(() => max),
delimiter: readableActive(() => delimiter),
@ -45,7 +51,6 @@
invalid: readableActive(() => invalid),
placeholder: readableActive(() => placeholder),
validate: readableActive(() => validate),
onValueChange: readableActive(() => onValueChange),
onValueInvalid: readableActive(() => onValueInvalid),
});

@ -3,7 +3,8 @@ import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
import { state, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, SomaKeyboardEvent, SomaMouseEvent } from '$soma/types';
import { KEYS } from '$soma/keyboard';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { resolveSomaTranslationPath } from '$soma/core/translator.svelte';
const attrs = createAttrs({
component: 'tags-input',
@ -31,7 +32,6 @@ interface TagsInputOpts
invalid: boolean;
placeholder: string;
validate: (value: string) => boolean;
onValueChange: OnChangeFn<string[]>;
onValueInvalid: OnChangeFn<string>;
}> {}
@ -42,7 +42,7 @@ export class TagsInputProvider extends Provider<TagsInputOpts> {
return new TagsInputProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
// Focus tracking
highlightedIndex = $state(-1);
@ -50,7 +50,7 @@ export class TagsInputProvider extends Provider<TagsInputOpts> {
private constructor(opts: TagsInputOpts) {
super(opts, 'TagsInput', 'root', attrs.root, TagsInputProvider.ctx);
this.soma = Soma.getOr();
}
readonly isEmpty = $derived.by(() => this.opts.value.current.length === 0);
@ -78,7 +78,6 @@ export class TagsInputProvider extends Provider<TagsInputOpts> {
const next = [...this.opts.value.current, trimmed];
this.opts.value.current = next;
this.opts.onValueChange.current(next);
return true;
}
@ -89,7 +88,6 @@ export class TagsInputProvider extends Provider<TagsInputOpts> {
const next = current.filter((_, i) => i !== index);
this.opts.value.current = next;
this.opts.onValueChange.current(next);
// Adjust highlight
if (this.highlightedIndex >= next.length) {
@ -106,7 +104,6 @@ export class TagsInputProvider extends Provider<TagsInputOpts> {
clearAll() {
if (this.opts.disabled.current || this.opts.readonly.current) return;
this.opts.value.current = [];
this.opts.onValueChange.current([]);
this.highlightedIndex = -1;
}
@ -198,25 +195,25 @@ export class TagsInputControlProvider extends Provider<TagsInputControlOpts> {
return new TagsInputControlProvider(opts);
}
readonly root: TagsInputProvider;
readonly provider: TagsInputProvider;
private constructor(opts: TagsInputControlOpts) {
super(opts, 'TagsInput', 'control', attrs.control);
this.root = TagsInputProvider.ctx.get();
this.provider = TagsInputProvider.ctx.get();
}
readonly onclick = () => {
// Click on control focuses the input
this.root.inputRef.current?.focus();
this.provider.inputRef.current?.focus();
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-disabled': boolToEmptyStrOrUndef(this.root.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.root.opts.readonly.current),
'data-invalid': boolToEmptyStrOrUndef(this.root.opts.invalid.current),
'data-focus': boolToEmptyStrOrUndef(this.root.highlightedIndex === -1),
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
'data-invalid': boolToEmptyStrOrUndef(this.provider.opts.invalid.current),
'data-focus': boolToEmptyStrOrUndef(this.provider.highlightedIndex === -1),
onclick: this.onclick,
} as const),
);
@ -231,83 +228,83 @@ export class TagsInputInputProvider extends Provider<TagsInputInputOpts> {
return new TagsInputInputProvider(opts);
}
readonly root: TagsInputProvider;
readonly provider: TagsInputProvider;
private constructor(opts: TagsInputInputOpts) {
super(opts, 'TagsInput', 'input', attrs.input, undefined, (el) => {
this.root.inputRef.current = el;
this.provider.inputRef.current = el;
});
this.root = TagsInputProvider.ctx.get();
this.provider = TagsInputProvider.ctx.get();
}
readonly oninput = (e: Event) => {
const target = e.currentTarget as HTMLInputElement;
const value = target.value;
const delimiter = this.root.opts.delimiter.current;
const delimiter = this.provider.opts.delimiter.current;
// Check if delimiter was typed
if (value.includes(delimiter)) {
this.root.opts.inputValue.current = value;
this.root.addFromInput();
this.provider.opts.inputValue.current = value;
this.provider.addFromInput();
} else {
this.root.opts.inputValue.current = value;
this.provider.opts.inputValue.current = value;
}
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLInputElement>) => {
const input = e.currentTarget;
const caretAtStart = input.selectionStart === 0 && input.selectionEnd === 0;
const isEmpty = !this.root.opts.inputValue.current;
const isEmpty = !this.provider.opts.inputValue.current;
if (e.key === KEYS.ENTER) {
e.preventDefault();
this.root.addFromInput();
this.provider.addFromInput();
} else if (e.key === KEYS.ESCAPE) {
e.preventDefault();
this.root.opts.inputValue.current = '';
this.root.clearHighlight();
this.provider.opts.inputValue.current = '';
this.provider.clearHighlight();
} else if (e.key === 'Backspace' && isEmpty) {
e.preventDefault();
const count = this.root.opts.value.current.length;
const count = this.provider.opts.value.current.length;
if (count > 0) {
this.root.removeTag(count - 1);
this.provider.removeTag(count - 1);
}
} else if (e.key === KEYS.ARROW_LEFT && caretAtStart) {
e.preventDefault();
this.root.highlightLast();
this.provider.highlightLast();
}
};
readonly onpaste = (e: ClipboardEvent) => {
if (!this.root.opts.addOnPaste.current) return;
if (!this.provider.opts.addOnPaste.current) return;
e.preventDefault();
const text = e.clipboardData?.getData('text') ?? '';
this.root.handlePaste(text);
this.provider.handlePaste(text);
};
readonly onblur = () => {
const behavior = this.root.opts.blurBehavior.current;
const behavior = this.provider.opts.blurBehavior.current;
if (behavior === 'add') {
this.root.addFromInput();
this.provider.addFromInput();
} else {
this.root.opts.inputValue.current = '';
this.provider.opts.inputValue.current = '';
}
this.root.clearHighlight();
this.provider.clearHighlight();
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'text' as const,
value: this.root.opts.inputValue.current,
value: this.provider.opts.inputValue.current,
placeholder:
this.root.isEmpty ? this.root.opts.placeholder.current || undefined : undefined,
disabled: this.root.opts.disabled.current || undefined,
readonly: this.root.opts.readonly.current || undefined,
'aria-invalid': this.root.opts.invalid.current || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.root.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.root.opts.readonly.current),
'data-invalid': boolToEmptyStrOrUndef(this.root.opts.invalid.current),
this.provider.isEmpty ? this.provider.opts.placeholder.current || undefined : undefined,
disabled: this.provider.opts.disabled.current || undefined,
readonly: this.provider.opts.readonly.current || undefined,
'aria-invalid': this.provider.opts.invalid.current || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.provider.opts.readonly.current),
'data-invalid': boolToEmptyStrOrUndef(this.provider.opts.invalid.current),
oninput: this.oninput,
onkeydown: this.onkeydown,
onpaste: this.onpaste,
@ -333,19 +330,19 @@ export class TagsInputItemProvider extends Provider<TagsInputItemOpts> {
return new TagsInputItemProvider(opts);
}
readonly root: TagsInputProvider;
readonly provider: TagsInputProvider;
private constructor(opts: TagsInputItemOpts) {
super(opts, 'TagsInput', 'item', attrs.item, TagsInputItemProvider.ctx);
this.root = TagsInputProvider.ctx.get();
this.provider = TagsInputProvider.ctx.get();
}
readonly isHighlighted = $derived.by(
() => this.root.highlightedIndex === this.opts.index.current,
() => this.provider.highlightedIndex === this.opts.index.current,
);
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || this.root.opts.disabled.current,
() => this.opts.disabled.current || this.provider.opts.disabled.current,
);
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
@ -353,28 +350,28 @@ export class TagsInputItemProvider extends Provider<TagsInputItemOpts> {
if (e.key === KEYS.ARROW_LEFT) {
e.preventDefault();
this.root.highlightPrev();
this.provider.highlightPrev();
} else if (e.key === KEYS.ARROW_RIGHT) {
e.preventDefault();
this.root.highlightNext();
this.provider.highlightNext();
} else if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
this.root.removeTag(this.opts.index.current);
this.provider.removeTag(this.opts.index.current);
} else if (e.key === KEYS.HOME) {
e.preventDefault();
this.root.highlightFirst();
this.provider.highlightFirst();
} else if (e.key === KEYS.END) {
e.preventDefault();
this.root.highlightLast();
this.provider.highlightLast();
} else if (e.key === KEYS.ESCAPE) {
e.preventDefault();
this.root.clearHighlight();
this.root.inputRef.current?.focus();
this.provider.clearHighlight();
this.provider.inputRef.current?.focus();
}
};
readonly onclick = () => {
this.root.highlightedIndex = this.opts.index.current;
this.provider.highlightedIndex = this.opts.index.current;
};
readonly props = $derived.by(() =>
@ -426,19 +423,19 @@ export class TagsInputItemDeleteTriggerProvider extends Provider<TagsInputItemDe
}
readonly item: TagsInputItemProvider;
readonly root: TagsInputProvider;
readonly provider: TagsInputProvider;
private constructor(opts: TagsInputItemDeleteTriggerOpts) {
super(opts, 'TagsInput', 'item-delete-trigger', attrs['item-delete-trigger']);
this.item = TagsInputItemProvider.ctx.get();
this.root = this.item.root;
this.provider = this.item.provider;
}
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (this.item.isDisabled || this.root.opts.readonly.current) return;
this.root.removeTag(this.item.opts.index.current);
this.root.inputRef.current?.focus();
if (this.item.isDisabled || this.provider.opts.readonly.current) return;
this.provider.removeTag(this.item.opts.index.current);
this.provider.inputRef.current?.focus();
};
readonly props = $derived.by(() =>
@ -447,8 +444,8 @@ export class TagsInputItemDeleteTriggerProvider extends Provider<TagsInputItemDe
type: 'button' as const,
tabindex: -1,
'aria-label':
this.root.soma?.translate('tags-input.remove', 'Remove') ?? 'Remove',
disabled: this.item.isDisabled || this.root.opts.readonly.current || undefined,
resolveSomaTranslationPath('soma.tags-input.remove', this.provider.config.translator.current, 'Remove'),
disabled: this.item.isDisabled || this.provider.opts.readonly.current || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.item.isDisabled),
onclick: this.onclick,
} as const),
@ -464,16 +461,16 @@ export class TagsInputClearTriggerProvider extends Provider<TagsInputClearTrigge
return new TagsInputClearTriggerProvider(opts);
}
readonly root: TagsInputProvider;
readonly provider: TagsInputProvider;
private constructor(opts: TagsInputClearTriggerOpts) {
super(opts, 'TagsInput', 'clear-trigger', attrs['clear-trigger']);
this.root = TagsInputProvider.ctx.get();
this.provider = TagsInputProvider.ctx.get();
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.root.clearAll();
this.root.inputRef.current?.focus();
this.provider.clearAll();
this.provider.inputRef.current?.focus();
};
readonly props = $derived.by(() =>
@ -481,9 +478,9 @@ export class TagsInputClearTriggerProvider extends Provider<TagsInputClearTrigge
...this.baseProps,
type: 'button' as const,
'aria-label':
this.root.soma?.translate('tags-input.clear', 'Clear all') ?? 'Clear all',
hidden: this.root.isEmpty || this.root.opts.disabled.current || this.root.opts.readonly.current || undefined,
disabled: this.root.opts.disabled.current || this.root.opts.readonly.current || undefined,
resolveSomaTranslationPath('soma.tags-input.clear', this.provider.config.translator.current, 'Clear all'),
hidden: this.provider.isEmpty || this.provider.opts.disabled.current || this.provider.opts.readonly.current || undefined,
disabled: this.provider.opts.disabled.current || this.provider.opts.readonly.current || undefined,
onclick: this.onclick,
} as const),
);

@ -2,7 +2,8 @@ import { Provider, context, type ProviderOpts, type WithRefOpts } from '$soma/pr
import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
import { readableActive, state, type Active, type ActiveProps } from '$soma/reactive';
import type { SomaMouseEvent } from '$soma/types';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { resolveSomaTranslationPath } from '$soma/core/translator.svelte';
import { isBrowser } from '$soma/dom';
import type { Toaster, ToastData, SwipeDirection } from './toaster.svelte';
@ -27,11 +28,11 @@ export class ToastProvider extends Provider<ToastProviderOpts> {
return new ToastProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
private constructor(opts: ToastProviderOpts) {
super(opts, 'Toast', 'provider', attrs.provider, ToastProvider.ctx);
this.soma = Soma.getOr();
}
get toaster(): Toaster {
@ -48,16 +49,16 @@ export class ToastViewportProvider extends Provider<ToastViewportOpts> {
return new ToastViewportProvider(opts);
}
readonly root: ToastProvider;
readonly provider: ToastProvider;
private cleanupHotkey: (() => void) | undefined;
private constructor(opts: ToastViewportOpts) {
super(opts, 'Toast', 'viewport', attrs.viewport);
this.root = ToastProvider.ctx.get();
this.provider = ToastProvider.ctx.get();
// Register global hotkey to focus viewport
if (isBrowser) {
const hotkey = this.root.toaster.hotkey;
const hotkey = this.provider.toaster.hotkey;
const handler = (e: KeyboardEvent) => {
const matches = hotkey.every(
(key) => e.code === key || e.key === key,
@ -79,7 +80,7 @@ export class ToastViewportProvider extends Provider<ToastViewportOpts> {
this.assertProps({
...this.baseProps,
role: 'region' as const,
'aria-label': this.root.opts.label.current,
'aria-label': this.provider.opts.label.current,
tabindex: -1,
} as const),
);
@ -100,7 +101,7 @@ export class ToastRootProvider extends Provider<ToastRootOpts> {
return new ToastRootProvider(opts);
}
readonly root: ToastProvider;
readonly provider: ToastProvider;
// tracked IDs for ARIA
titleId = state('');
@ -122,10 +123,10 @@ export class ToastRootProvider extends Provider<ToastRootOpts> {
private constructor(opts: ToastRootOpts) {
super(opts, 'Toast', 'root', attrs.root, ToastRootProvider.ctx);
this.root = ToastProvider.ctx.get();
this.provider = ToastProvider.ctx.get();
const toast = opts.toast.current;
this.remaining = toast.duration ?? this.root.toaster.duration;
this.remaining = toast.duration ?? this.provider.toaster.duration;
// Start auto-dismiss timer if duration > 0
if (this.remaining > 0) {
@ -136,7 +137,7 @@ export class ToastRootProvider extends Provider<ToastRootOpts> {
private startTimer() {
this.startTime = Date.now();
this.timerId = setTimeout(() => {
this.root.toaster.dismiss(this.opts.toast.current.id);
this.provider.toaster.dismiss(this.opts.toast.current.id);
}, this.remaining);
}
@ -181,11 +182,11 @@ export class ToastRootProvider extends Provider<ToastRootOpts> {
// ── Swipe ────────────────────────────────────────────────────────────────
private get swipeDirection(): SwipeDirection {
return this.root.toaster.swipeDirection;
return this.provider.toaster.swipeDirection;
}
private get swipeThreshold(): number {
return this.root.toaster.swipeThreshold;
return this.provider.toaster.swipeThreshold;
}
private get isHorizontalSwipe(): boolean {
@ -223,7 +224,7 @@ export class ToastRootProvider extends Provider<ToastRootOpts> {
if (isCorrectDirection && Math.abs(delta) >= this.swipeThreshold) {
this.swipeState = 'end';
this.root.toaster.dismiss(this.opts.toast.current.id);
this.provider.toaster.dismiss(this.opts.toast.current.id);
} else {
this.swipeState = 'cancel';
this.swipeDeltaX = 0;
@ -248,7 +249,7 @@ export class ToastRootProvider extends Provider<ToastRootOpts> {
'aria-describedby': this.descriptionId.current || undefined,
'data-type': this.opts.toast.current.type,
'data-swipe': this.swipeState === 'idle' ? undefined : this.swipeState,
'data-swipe-direction': this.root.toaster.swipeDirection,
'data-swipe-direction': this.provider.toaster.swipeDirection,
tabindex: 0,
style: {
'--soma-toast-swipe-move-x': `${this.swipeDeltaX}px`,
@ -362,7 +363,7 @@ export class ToastCloseProvider extends Provider<ToastCloseOpts> {
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.toastRoot.root.toaster.dismiss(this.toastRoot.opts.toast.current.id);
this.toastRoot.provider.toaster.dismiss(this.toastRoot.opts.toast.current.id);
};
readonly props = $derived.by(() =>
@ -370,7 +371,7 @@ export class ToastCloseProvider extends Provider<ToastCloseOpts> {
...this.baseProps,
type: 'button' as const,
'aria-label':
this.toastRoot.root.soma?.translate('toast.close', 'Close') ?? 'Close',
resolveSomaTranslationPath('soma.toast.close', this.toastRoot.provider.config.translator.current, 'Close'),
onclick: this.onclick,
} as const),
);

@ -2,13 +2,13 @@
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { ToggleGroupProvider } from '../toggle-group-provider.svelte';
import type { ToggleGroupProps } from '../types';
import { noop } from '$lib/util/funcs';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
ref = $bindable(null),
@ -19,7 +19,7 @@
disabled = false,
orientation = 'horizontal',
loop = true,
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
children,
child,
...restProps
@ -34,9 +34,11 @@
type: readableActive(() => type),
value: writableActive(
() => value,
(v) => (value = v),
(v) => {
value = v;
onValueChange(v);
},
),
onValueChange: readableActive(() => onValueChange),
disabled: readableActive(() => disabled),
orientation: readableActive(() => orientation),
loop: readableActive(() => loop),

@ -1,9 +1,9 @@
import { Provider, context, type WithRefOpts } from '$soma/provider';
import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
import type { ActiveProps, StateProps } from '$soma/reactive';
import type { OnChangeFn, Orientation, Direction, SomaMouseEvent, SomaKeyboardEvent } from '$soma/types';
import type { Orientation, Direction, SomaMouseEvent, SomaKeyboardEvent } from '$soma/types';
import { KEYS, getDirectionalKeys } from '$soma/keyboard';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
const attrs = createAttrs({
component: 'toggle-group',
@ -23,7 +23,6 @@ interface ToggleGroupOpts
orientation: Orientation;
loop: boolean;
dir: Direction;
onValueChange: OnChangeFn<string[]>;
}> {}
export class ToggleGroupProvider extends Provider<ToggleGroupOpts> {
@ -33,11 +32,11 @@ export class ToggleGroupProvider extends Provider<ToggleGroupOpts> {
return new ToggleGroupProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
private constructor(opts: ToggleGroupOpts) {
super(opts, 'ToggleGroup', 'root', attrs.root, ToggleGroupProvider.ctx);
this.soma = Soma.getOr();
}
isItemPressed(value: string): boolean {
@ -58,7 +57,6 @@ export class ToggleGroupProvider extends Provider<ToggleGroupOpts> {
}
this.opts.value.current = next;
this.opts.onValueChange.current(next);
}
/** Get all enabled item elements for keyboard navigation. */
@ -94,35 +92,35 @@ export class ToggleGroupItemProvider extends Provider<ToggleGroupItemOpts> {
return new ToggleGroupItemProvider(opts);
}
readonly root: ToggleGroupProvider;
readonly provider: ToggleGroupProvider;
private constructor(opts: ToggleGroupItemOpts) {
super(opts, 'ToggleGroup', 'item', attrs.item);
this.root = ToggleGroupProvider.ctx.get();
this.provider = ToggleGroupProvider.ctx.get();
}
readonly isPressed = $derived.by(() =>
this.root.isItemPressed(this.opts.value.current),
this.provider.isItemPressed(this.opts.value.current),
);
readonly isDisabled = $derived.by(() =>
this.opts.disabled.current || this.root.opts.disabled.current,
this.opts.disabled.current || this.provider.opts.disabled.current,
);
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.isDisabled) return;
this.root.toggleItem(this.opts.value.current);
this.provider.toggleItem(this.opts.value.current);
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
if (this.isDisabled) return;
const orientation = this.root.opts.orientation.current;
const dir = this.root.opts.dir.current;
const orientation = this.provider.opts.orientation.current;
const dir = this.provider.opts.dir.current;
const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);
const items = this.root.getItems();
const items = this.provider.getItems();
const currentIndex = items.indexOf(e.currentTarget as HTMLElement);
const loop = this.root.opts.loop.current;
const loop = this.provider.opts.loop.current;
let targetIndex = -1;
@ -157,7 +155,7 @@ export class ToggleGroupItemProvider extends Provider<ToggleGroupItemOpts> {
tabindex: this.isPressed ? 0 : -1,
'data-state': this.isPressed ? 'on' : 'off',
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-orientation': this.root.opts.orientation.current,
'data-orientation': this.provider.opts.orientation.current,
'data-value': this.opts.value.current,
disabled: this.isDisabled || undefined,
onclick: this.onclick,

@ -29,9 +29,11 @@
type: readableActive(() => type),
value: writableActive(
() => value,
(v) => (value = v),
(v) => {
value = v;
onValueChange(v);
},
),
onValueChange: readableActive(() => onValueChange),
disabled: readableActive(() => disabled),
});

@ -2,19 +2,19 @@
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { ToolbarProvider } from '../toolbar-provider.svelte';
import type { ToolbarProps } from '../types';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
ref = $bindable(null),
id = createId(uid, 'toolbar'),
orientation = 'horizontal',
loop = true,
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
children,
child,
...restProps

@ -1,9 +1,9 @@
import { Provider, context, type WithRefOpts } from '$soma/provider';
import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
import { type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, Orientation, Direction, SomaKeyboardEvent, SomaMouseEvent } from '$soma/types';
import type { Orientation, Direction, SomaKeyboardEvent, SomaMouseEvent } from '$soma/types';
import { KEYS, getDirectionalKeys } from '$soma/keyboard';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
const attrs = createAttrs({
component: 'toolbar',
@ -29,14 +29,14 @@ export class ToolbarProvider extends Provider<ToolbarOpts> {
return new ToolbarProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
// Track last focused item for focus restoration
lastFocusedIndex = $state(0);
private constructor(opts: ToolbarOpts) {
super(opts, 'Toolbar', 'root', attrs.root, ToolbarProvider.ctx);
this.soma = Soma.getOr();
}
/** Get all focusable items in the toolbar. */
@ -119,15 +119,15 @@ export class ToolbarButtonProvider extends Provider<ToolbarButtonOpts> {
return new ToolbarButtonProvider(opts);
}
readonly root: ToolbarProvider;
readonly provider: ToolbarProvider;
private constructor(opts: ToolbarButtonOpts) {
super(opts, 'Toolbar', 'button', attrs.button);
this.root = ToolbarProvider.ctx.get();
this.provider = ToolbarProvider.ctx.get();
}
private get itemIndex(): number {
const items = this.root.getItems();
const items = this.provider.getItems();
return items.indexOf(this.opts.ref.current as HTMLElement);
}
@ -135,9 +135,9 @@ export class ToolbarButtonProvider extends Provider<ToolbarButtonOpts> {
this.assertProps({
...this.baseProps,
type: 'button' as const,
tabindex: this.root.lastFocusedIndex === this.itemIndex ? 0 : -1,
tabindex: this.provider.lastFocusedIndex === this.itemIndex ? 0 : -1,
disabled: this.opts.disabled.current || undefined,
'data-orientation': this.root.opts.orientation.current,
'data-orientation': this.provider.opts.orientation.current,
} as const),
);
}
@ -151,23 +151,23 @@ export class ToolbarLinkProvider extends Provider<ToolbarLinkOpts> {
return new ToolbarLinkProvider(opts);
}
readonly root: ToolbarProvider;
readonly provider: ToolbarProvider;
private constructor(opts: ToolbarLinkOpts) {
super(opts, 'Toolbar', 'link', attrs.link);
this.root = ToolbarProvider.ctx.get();
this.provider = ToolbarProvider.ctx.get();
}
private get itemIndex(): number {
const items = this.root.getItems();
const items = this.provider.getItems();
return items.indexOf(this.opts.ref.current as HTMLElement);
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
tabindex: this.root.lastFocusedIndex === this.itemIndex ? 0 : -1,
'data-orientation': this.root.opts.orientation.current,
tabindex: this.provider.lastFocusedIndex === this.itemIndex ? 0 : -1,
'data-orientation': this.provider.opts.orientation.current,
} as const),
);
}
@ -180,7 +180,6 @@ interface ToolbarGroupOpts
ActiveProps<{
type: ToolbarGroupType;
disabled: boolean;
onValueChange: OnChangeFn<string[]>;
}> {}
export class ToolbarGroupProvider extends Provider<ToolbarGroupOpts> {
@ -190,11 +189,11 @@ export class ToolbarGroupProvider extends Provider<ToolbarGroupOpts> {
return new ToolbarGroupProvider(opts);
}
readonly root: ToolbarProvider;
readonly provider: ToolbarProvider;
private constructor(opts: ToolbarGroupOpts) {
super(opts, 'Toolbar', 'group', attrs.group, ToolbarGroupProvider.ctx);
this.root = ToolbarProvider.ctx.get();
this.provider = ToolbarProvider.ctx.get();
}
isItemPressed(value: string): boolean {
@ -215,14 +214,13 @@ export class ToolbarGroupProvider extends Provider<ToolbarGroupOpts> {
}
this.opts.value.current = next;
this.opts.onValueChange.current(next);
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'group' as const,
'data-orientation': this.root.opts.orientation.current,
'data-orientation': this.provider.opts.orientation.current,
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
} as const),
);
@ -243,12 +241,12 @@ export class ToolbarGroupItemProvider extends Provider<ToolbarGroupItemOpts> {
}
readonly group: ToolbarGroupProvider;
readonly root: ToolbarProvider;
readonly provider: ToolbarProvider;
private constructor(opts: ToolbarGroupItemOpts) {
super(opts, 'Toolbar', 'group-item', attrs['group-item']);
this.group = ToolbarGroupProvider.ctx.get();
this.root = this.group.root;
this.provider = this.group.provider;
}
readonly isPressed = $derived.by(
@ -265,7 +263,7 @@ export class ToolbarGroupItemProvider extends Provider<ToolbarGroupItemOpts> {
};
private get itemIndex(): number {
const items = this.root.getItems();
const items = this.provider.getItems();
return items.indexOf(this.opts.ref.current as HTMLElement);
}
@ -274,10 +272,10 @@ export class ToolbarGroupItemProvider extends Provider<ToolbarGroupItemOpts> {
...this.baseProps,
type: 'button' as const,
'aria-pressed': this.isPressed,
tabindex: this.root.lastFocusedIndex === this.itemIndex ? 0 : -1,
tabindex: this.provider.lastFocusedIndex === this.itemIndex ? 0 : -1,
'data-state': this.isPressed ? 'on' : 'off',
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-orientation': this.root.opts.orientation.current,
'data-orientation': this.provider.opts.orientation.current,
'data-value': this.opts.value.current,
disabled: this.isDisabled || undefined,
onclick: this.onclick,
@ -294,16 +292,16 @@ export class ToolbarSeparatorProvider extends Provider<ToolbarSeparatorOpts> {
return new ToolbarSeparatorProvider(opts);
}
readonly root: ToolbarProvider;
readonly provider: ToolbarProvider;
private constructor(opts: ToolbarSeparatorOpts) {
super(opts, 'Toolbar', 'separator', attrs.separator);
this.root = ToolbarProvider.ctx.get();
this.provider = ToolbarProvider.ctx.get();
}
/** Separator orientation is perpendicular to toolbar orientation. */
private get separatorOrientation(): Orientation {
return this.root.opts.orientation.current === 'horizontal' ? 'vertical' : 'horizontal';
return this.provider.opts.orientation.current === 'horizontal' ? 'vertical' : 'horizontal';
}
readonly props = $derived.by(() =>

@ -3,13 +3,13 @@
import { mergeProps } from '$soma/props';
import { styleToString } from '$soma/css';
import { createId } from '$soma/id';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { TooltipContentProvider } from '../tooltip-provider.svelte';
import type { TooltipContentProps } from '../types';
import { noop } from '$lib/util/funcs';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
ref = $bindable(null),
@ -28,7 +28,7 @@
hideWhenDetached = true,
updatePositionStrategy = 'optimized',
strategy = 'fixed',
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
style = null,
onEscapeKeydown = noop,
escapeKeydownBehavior = 'close',
@ -74,10 +74,10 @@
{#if state.isPresent || forceMount}
<div {...state.wrapperProps} style={wrapperStyleStr}>
{#if child}
{@render child({ open: state.root.opts.open.current, props: mergedProps })}
{@render child({ open: state.provider.opts.open.current, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.({ open: state.root.opts.open.current })}
{@render children?.({ open: state.provider.opts.open.current })}
</div>
{/if}
</div>

@ -23,9 +23,11 @@
id: readableActive(() => id),
open: writableActive(
() => open,
(v) => (open = v),
(v) => {
open = v;
onOpenChange(v);
},
),
onOpenChange: readableActive(() => onOpenChange),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
delayDuration: readableActive(() => delayDuration),
closeDelay: readableActive(() => closeDelay),

@ -3,7 +3,7 @@ import { createAttrs, getDataOpenClosed } from '$soma/attrs';
import { readableActive, state, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, SomaKeyboardEvent, Direction, Arrayable, StyleProperties } from '$soma/types';
import { KEYS } from '$soma/keyboard';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { on } from 'svelte/events';
import { watch } from 'runed';
@ -71,7 +71,6 @@ interface TooltipOpts
closeDelay: number;
disableHoverableContent: boolean | undefined;
disabled: boolean | undefined;
onOpenChange: OnChangeFn<boolean>;
onOpenChangeComplete: OnChangeFn<boolean>;
}> {}
@ -82,7 +81,7 @@ export class TooltipProvider extends Provider<TooltipOpts> {
return new TooltipProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
readonly group: TooltipGroupProvider;
readonly floatingProvider: FloatingProvider;
readonly contentPresence: Presence;
@ -98,7 +97,7 @@ export class TooltipProvider extends Provider<TooltipOpts> {
private constructor(opts: TooltipOpts) {
super(opts, 'Tooltip', 'root', attrs.root, TooltipProvider.ctx);
this.soma = Soma.getOr();
this.group = TooltipGroupProvider.ctx.get();
this.floatingProvider = FloatingProvider.create();
@ -131,13 +130,11 @@ export class TooltipProvider extends Provider<TooltipOpts> {
if (instant || this.group.isWithinSkipDelay()) {
this.tooltipState = 'instant-open';
this.opts.open.current = true;
this.opts.onOpenChange.current(true);
} else {
this.tooltipState = 'delayed-open';
this.openTimeout = setTimeout(() => {
this.tooltipState = 'instant-open';
this.opts.open.current = true;
this.opts.onOpenChange.current(true);
}, this.delay);
}
}
@ -158,7 +155,6 @@ export class TooltipProvider extends Provider<TooltipOpts> {
private doClose() {
this.tooltipState = 'closed';
this.opts.open.current = false;
this.opts.onOpenChange.current(false);
this.group.markClosed();
}
@ -195,12 +191,12 @@ export class TooltipTriggerProvider extends Provider<TooltipTriggerOpts> {
return new TooltipTriggerProvider(opts);
}
readonly root: TooltipProvider;
readonly provider: TooltipProvider;
private constructor(opts: TooltipTriggerOpts) {
super(opts, 'Tooltip', 'trigger', attrs.trigger);
this.root = TooltipProvider.ctx.get();
this.root.triggerId.current = opts.id.current;
this.provider = TooltipProvider.ctx.get();
this.provider.triggerId.current = opts.id.current;
FloatingAnchor.create({
id: opts.id,
@ -209,34 +205,34 @@ export class TooltipTriggerProvider extends Provider<TooltipTriggerOpts> {
}
readonly onpointerenter = () => {
this.root.handleOpen();
this.provider.handleOpen();
};
readonly onpointerleave = () => {
this.root.handleClose();
this.provider.handleClose();
};
readonly onfocus = () => {
this.root.handleOpen(true);
this.provider.handleOpen(true);
};
readonly onblur = () => {
this.root.handleClose();
this.provider.handleClose();
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
if (e.key === KEYS.ESCAPE) {
this.root.handleClose();
this.provider.handleClose();
}
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'aria-describedby': this.root.opts.open.current
? this.root.contentId.current || undefined
'aria-describedby': this.provider.opts.open.current
? this.provider.contentId.current || undefined
: undefined,
'data-state': this.root.tooltipState,
'data-state': this.provider.tooltipState,
onpointerenter: this.onpointerenter,
onpointerleave: this.onpointerleave,
onfocus: this.onfocus,
@ -275,16 +271,16 @@ export class TooltipContentProvider extends Provider<TooltipContentOpts> {
return new TooltipContentProvider(opts);
}
readonly root: TooltipProvider;
readonly provider: TooltipProvider;
readonly floating: FloatingContent;
readonly dismissal: Dismissal;
private constructor(opts: TooltipContentOpts) {
super(opts, 'Tooltip', 'content', attrs.content, undefined, (el) => {
this.root.setContentRef(el);
this.provider.setContentRef(el);
});
this.root = TooltipProvider.ctx.get();
this.root.contentId.current = opts.id.current;
this.provider = TooltipProvider.ctx.get();
this.provider.contentId.current = opts.id.current;
// Floating
this.floating = FloatingContent.create({
@ -305,17 +301,17 @@ export class TooltipContentProvider extends Provider<TooltipContentOpts> {
onPlaced: readableActive(() => () => {}),
dir: opts.dir,
style: opts.style,
enabled: readableActive(() => this.root.opts.open.current),
enabled: readableActive(() => this.provider.opts.open.current),
customAnchor: readableActive(() => null),
});
// Dismissal (escape only, no interact-outside for tooltip)
this.dismissal = Dismissal.use({
ref: readableActive(() => opts.ref.current),
enabled: readableActive(() => this.root.opts.open.current),
enabled: readableActive(() => this.provider.opts.open.current),
onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => {
opts.onEscapeKeydown.current(e);
if (!e.defaultPrevented) this.root.handleClose();
if (!e.defaultPrevented) this.provider.handleClose();
}),
escapeKeydownBehavior: opts.escapeKeydownBehavior,
onInteractOutside: readableActive(() => () => {}),
@ -325,24 +321,24 @@ export class TooltipContentProvider extends Provider<TooltipContentOpts> {
});
}
readonly isPresent = $derived.by(() => this.root.contentPresence.isPresent);
readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent);
readonly wrapperProps = $derived.by(() => ({
...this.floating.wrapperProps,
style: {
...(typeof this.floating.wrapperProps.style === 'object' ? this.floating.wrapperProps.style : {}),
'pointer-events': this.root.hoverableDisabled ? 'none' : 'auto',
'pointer-events': this.provider.hoverableDisabled ? 'none' : 'auto',
},
}));
readonly onpointerenter = () => {
if (!this.root.hoverableDisabled) {
this.root.cancelClose();
if (!this.provider.hoverableDisabled) {
this.provider.cancelClose();
}
};
readonly onpointerleave = () => {
this.root.handleClose();
this.provider.handleClose();
};
readonly props = $derived.by(() =>
@ -350,9 +346,9 @@ export class TooltipContentProvider extends Provider<TooltipContentOpts> {
...this.baseProps,
role: 'tooltip' as const,
'aria-label': this.opts.ariaLabel.current || undefined,
'data-state': this.root.tooltipState,
'data-state': this.provider.tooltipState,
...this.floating.props,
...this.root.contentPresence.transitionAttrs,
...this.provider.contentPresence.transitionAttrs,
...this.dismissal.props,
onpointerenter: this.onpointerenter,
onpointerleave: this.onpointerleave,

@ -28,7 +28,7 @@
const onclick = (e: MouseEvent) => {
e.stopPropagation();
if (branch.isDisabled) return;
branch.root.toggleExpand(branch.opts.value.current);
branch.provider.toggleExpand(branch.opts.value.current);
};
const mergedProps = $derived(mergeProps(restProps, state.props, { onclick }));

@ -2,13 +2,13 @@
import { readableActive, writableActive } from '$soma/reactive';
import { mergeProps } from '$soma/props';
import { createId } from '$soma/id';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { TreeViewProvider } from '../tree-view-provider.svelte';
import type { TreeViewProps } from '../types';
import { noop } from '$lib/util/funcs';
const uid = $props.id();
const soma = Soma.getOr();
const soma = getSoma();
let {
ref = $bindable(null),
@ -16,7 +16,7 @@
expandedValue = $bindable([]),
selectedValue = $bindable([]),
selectionMode = 'single',
dir = soma?.dir.current ?? 'ltr',
dir = soma.presentation.current?.dir ?? 'ltr',
expandOnClick = true,
typeahead = true,
onExpandedChange = noop,
@ -34,18 +34,22 @@
),
expandedValue: writableActive(
() => expandedValue,
(v) => (expandedValue = v),
(v) => {
expandedValue = v;
onExpandedChange(v);
},
),
selectedValue: writableActive(
() => selectedValue,
(v) => (selectedValue = v),
(v) => {
selectedValue = v;
onSelectionChange(v);
},
),
selectionMode: readableActive(() => selectionMode),
dir: readableActive(() => dir),
expandOnClick: readableActive(() => expandOnClick),
typeahead: readableActive(() => typeahead),
onExpandedChange: readableActive(() => onExpandedChange),
onSelectionChange: readableActive(() => onSelectionChange),
});
const mergedProps = $derived(mergeProps(restProps, state.props));

@ -1,9 +1,10 @@
import { Provider, context, type WithRefOpts } from '$soma/provider';
import { createAttrs, boolToEmptyStrOrUndef } from '$soma/attrs';
import { state, type Active, type ActiveProps, type StateProps } from '$soma/reactive';
import type { OnChangeFn, Direction, SomaKeyboardEvent, SomaMouseEvent } from '$soma/types';
import type { Direction, SomaKeyboardEvent, SomaMouseEvent } from '$soma/types';
import { KEYS } from '$soma/keyboard';
import { Soma } from '$soma/core/soma.svelte';
import { getSoma } from '$soma/core/soma.svelte';
import { resolveSomaTranslationPath } from '$soma/core/translator.svelte';
const attrs = createAttrs({
component: 'tree-view',
@ -25,8 +26,6 @@ interface TreeViewOpts
dir: Direction;
expandOnClick: boolean;
typeahead: boolean;
onExpandedChange: OnChangeFn<string[]>;
onSelectionChange: OnChangeFn<string[]>;
}> {}
export class TreeViewProvider extends Provider<TreeViewOpts> {
@ -36,7 +35,7 @@ export class TreeViewProvider extends Provider<TreeViewOpts> {
return new TreeViewProvider(opts);
}
readonly soma: Soma | undefined;
readonly config = getSoma();
// Focus tracking
focusedValue = $state<string | null>(null);
@ -47,7 +46,7 @@ export class TreeViewProvider extends Provider<TreeViewOpts> {
private constructor(opts: TreeViewOpts) {
super(opts, 'TreeView', 'root', attrs.root, TreeViewProvider.ctx);
this.soma = Soma.getOr();
}
// ── Expand ───────────────────────────────────────────────────────────────
@ -62,7 +61,6 @@ export class TreeViewProvider extends Provider<TreeViewOpts> {
? current.filter((v) => v !== value)
: [...current, value];
this.opts.expandedValue.current = next;
this.opts.onExpandedChange.current(next);
}
expand(value: string) {
@ -90,7 +88,6 @@ export class TreeViewProvider extends Provider<TreeViewOpts> {
const current = this.opts.expandedValue.current;
const next = [...new Set([...current, ...values])];
this.opts.expandedValue.current = next;
this.opts.onExpandedChange.current(next);
}
// ── Select ───────────────────────────────────────────────────────────────
@ -109,7 +106,6 @@ export class TreeViewProvider extends Provider<TreeViewOpts> {
: [...this.opts.selectedValue.current, value];
}
this.opts.selectedValue.current = next;
this.opts.onSelectionChange.current(next);
}
// ── Focus & navigation ───────────────────────────────────────────────────
@ -239,7 +235,7 @@ export class TreeViewProvider extends Provider<TreeViewOpts> {
...this.baseProps,
role: 'tree' as const,
'aria-multiselectable': this.opts.selectionMode.current === 'multiple' || undefined,
'aria-label': this.soma?.translate('tree-view.label', 'Tree') ?? 'Tree',
'aria-label': resolveSomaTranslationPath('soma.tree-view.label', this.config.translator.current, 'Tree'),
onkeydown: this.onkeydown,
} as const),
);
@ -262,19 +258,19 @@ export class TreeViewBranchProvider extends Provider<TreeViewBranchOpts> {
return new TreeViewBranchProvider(opts);
}
readonly root: TreeViewProvider;
readonly provider: TreeViewProvider;
private constructor(opts: TreeViewBranchOpts) {
super(opts, 'TreeView', 'branch', attrs.branch, TreeViewBranchProvider.ctx);
this.root = TreeViewProvider.ctx.get();
this.provider = TreeViewProvider.ctx.get();
}
readonly isExpanded = $derived.by(
() => this.root.isExpanded(this.opts.value.current),
() => this.provider.isExpanded(this.opts.value.current),
);
readonly isSelected = $derived.by(
() => this.root.isSelected(this.opts.value.current),
() => this.provider.isSelected(this.opts.value.current),
);
readonly isDisabled = $derived.by(() => this.opts.disabled.current);
@ -304,20 +300,20 @@ export class TreeViewBranchControlProvider extends Provider<TreeViewBranchContro
}
readonly branch: TreeViewBranchProvider;
readonly root: TreeViewProvider;
readonly provider: TreeViewProvider;
private constructor(opts: TreeViewBranchControlOpts) {
super(opts, 'TreeView', 'branch-control', attrs['branch-control']);
this.branch = TreeViewBranchProvider.ctx.get();
this.root = this.branch.root;
this.provider = this.branch.provider;
}
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
if (this.branch.isDisabled) return;
if (this.root.opts.expandOnClick.current) {
this.root.toggleExpand(this.branch.opts.value.current);
if (this.provider.opts.expandOnClick.current) {
this.provider.toggleExpand(this.branch.opts.value.current);
}
this.root.select(this.branch.opts.value.current);
this.provider.select(this.branch.opts.value.current);
};
readonly props = $derived.by(() =>
@ -328,7 +324,7 @@ export class TreeViewBranchControlProvider extends Provider<TreeViewBranchContro
'aria-selected': this.branch.isSelected,
'aria-level': this.branch.opts.depth.current + 1,
'aria-disabled': this.branch.isDisabled || undefined,
tabindex: this.root.focusedValue === this.branch.opts.value.current ? 0 : -1,
tabindex: this.provider.focusedValue === this.branch.opts.value.current ? 0 : -1,
'data-value': this.branch.opts.value.current,
'data-state': this.branch.isExpanded ? 'open' : 'closed',
'data-selected': boolToEmptyStrOrUndef(this.branch.isSelected),
@ -430,22 +426,22 @@ export class TreeViewItemProvider extends Provider<TreeViewItemOpts> {
return new TreeViewItemProvider(opts);
}
readonly root: TreeViewProvider;
readonly provider: TreeViewProvider;
private constructor(opts: TreeViewItemOpts) {
super(opts, 'TreeView', 'item', attrs.item);
this.root = TreeViewProvider.ctx.get();
this.provider = TreeViewProvider.ctx.get();
}
readonly isSelected = $derived.by(
() => this.root.isSelected(this.opts.value.current),
() => this.provider.isSelected(this.opts.value.current),
);
readonly isDisabled = $derived.by(() => this.opts.disabled.current);
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
if (this.isDisabled) return;
this.root.select(this.opts.value.current);
this.provider.select(this.opts.value.current);
};
readonly props = $derived.by(() =>
@ -455,7 +451,7 @@ export class TreeViewItemProvider extends Provider<TreeViewItemOpts> {
'aria-selected': this.isSelected,
'aria-level': this.opts.depth.current + 1,
'aria-disabled': this.isDisabled || undefined,
tabindex: this.root.focusedValue === this.opts.value.current ? 0 : -1,
tabindex: this.provider.focusedValue === this.opts.value.current ? 0 : -1,
'data-value': this.opts.value.current,
'data-selected': boolToEmptyStrOrUndef(this.isSelected),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),

@ -0,0 +1,10 @@
import type { SomaCurrencyFormatter } from '$soma/types';
import { createSomaFormatter, type SomaFormatterSource } from './formatter.svelte';
export type SomaCurrencyFormatterSource = SomaFormatterSource;
export function createSomaCurrencyFormatter(
source: SomaCurrencyFormatterSource,
): SomaCurrencyFormatter {
return createSomaFormatter(source);
}

@ -0,0 +1,21 @@
import type { Getter } from '$soma/reactive';
import type { SomaDateTimeFormatter } from '$soma/types';
import type { DateOrder, HourCycle } from '$soma/external/dates';
export type SomaDateTimeFormatterSource = {
dateOrder?: Getter<DateOrder | undefined>;
hourCycle?: Getter<HourCycle | undefined>;
};
export function createSomaDateTimeFormatter(
source: SomaDateTimeFormatterSource,
): SomaDateTimeFormatter {
return {
getDateOrder() {
return source.dateOrder?.();
},
getHourCycle() {
return source.hourCycle?.();
},
};
}

@ -0,0 +1,14 @@
import type { Getter } from '$soma/reactive';
import type { SomaFormatter } from '$soma/types';
export type SomaFormatterSource = {
format: Getter<(...args: unknown[]) => string>;
};
export function createSomaFormatter(source: SomaFormatterSource): SomaFormatter {
return {
format(...args: unknown[]) {
return source.format()(...args);
},
};
}

@ -0,0 +1,10 @@
import type { SomaNumberFormatter } from '$soma/types';
import { createSomaFormatter, type SomaFormatterSource } from './formatter.svelte';
export type SomaNumberFormatterSource = SomaFormatterSource;
export function createSomaNumberFormatter(
source: SomaNumberFormatterSource,
): SomaNumberFormatter {
return createSomaFormatter(source);
}

@ -0,0 +1,65 @@
import { readableActive, type Active, type Getter } from '$soma/reactive';
import type { Direction } from '$soma/types';
import {
DEFAULT_DATE_ORDER,
DEFAULT_TIME_FORMAT,
timeFormatToHourCycle,
type HourCycle
} from '$soma/external/dates';
import { getSoma } from './soma.svelte';
type SomaOpts = ReturnType<typeof getSoma>;
function createPropResolver<T>(configOption: (config: SomaOpts) => T | undefined, fallback: T) {
return (getProp: Getter<T | undefined>): Active<T> => {
const config = getSoma();
return readableActive(() => {
const propValue = getProp();
if (propValue !== undefined) return propValue;
const option = configOption(config);
if (option !== undefined) return option;
return fallback;
});
};
}
export const resolveLocaleProp = createPropResolver((config) => {
const locale = config.translator.current.getLocale();
return locale || undefined;
}, '');
export const resolveDirectionProp = createPropResolver(
(config) => config.presentation.current?.dir,
'ltr' as Direction
);
export const resolvePortalToProp = createPropResolver(
(config) => config.portalTo.current as string | undefined,
'body'
);
export const resolveDateOrderProp = createPropResolver(
(config) => config.presentation.current?.dateTimeFormatter?.getDateOrder(),
DEFAULT_DATE_ORDER
);
export function resolveHourCycleProp(
getTimeFormat: Getter<HourCycle | undefined>,
getHourCycle: Getter<HourCycle | undefined>
): Active<HourCycle> {
const config = getSoma();
return readableActive(() => {
const timeFormat = getTimeFormat();
if (timeFormat !== undefined) return timeFormatToHourCycle(timeFormat);
const hourCycle = getHourCycle();
if (hourCycle !== undefined) return hourCycle;
const configTimeFormat = config.presentation.current?.dateTimeFormatter?.getHourCycle();
if (configTimeFormat !== undefined) return configTimeFormat;
return timeFormatToHourCycle(DEFAULT_TIME_FORMAT);
});
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.