Morfo (src/uix/morfo/): machine-readable contract of a component's public DOM surface, shared between soma/sema/eidos. Shape v5 consolidated across three independent AI reviews (Gemini, Grok, ChatGPT) plus Sema alignment. Types, sium-based validator with cross-field invariants, dialog.ts as the first canonical morfo with data-last-action for Sema-causal exits, 7 passing unit tests. createAttrs(morfo) and registerContract(morfo) now consume a Morfo — legacy inline signature removed. Dialog provider refactored to derive parts + data contract from dialogMorfo. The other 65 soma components need porting (svelte-check lists them) — tracked work. Tooling: scripts/smoke-check.mjs — Playwright smoke over 65 soma demo routes scripts/morfo-check.ts — validates emitted DOM vs morfo scripts/morfo-vocabulary-check.ts — canonical vocabulary consistency npm run smoke / morfo:check / morfo:vocabulary WAI-ARIA APG components added (100% APG coverage now): Announce, Avatar, Clipboard, DragDrop, Feed, GridList, Meter, Progress, SearchField, TagGroup, TreeGrid + Table.RowDetail/Trigger (detail panel pattern; hierarchical Table rows deprecated — use TreeGrid instead). air: Link, Banner. Sema prep: src/uix/sema/sema_pre.md documents the semantic layer's DOM requirements. Morfo already provides everything Sema needs (data-last-action patterns, transition markers, enumerable state values, cross-component consistency). Sema implementation deferred. COMPONENT_GUIDE updated: items 37-39 (translation namespace grep, DOM topology vs .require() audit, npm run smoke is part of done) + A34 rule with incident log. WAI-ARIA pattern listed as soma membership criterion. Pagination translation bug fixed (soma.pagination.page -> idlangref PAGINATION_LANGS.PAGE). Table demo namespace fix (soma.table.* -> components.table.*). Avatar demo now offline-safe (SVG data URL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>semantuix
parent
3b704286ba
commit
8c16ffac81
@ -1,84 +1,91 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(npx svelte-check:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(grep -v \"^$\")",
|
||||
"Bash(grep -n export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/utils.ts)",
|
||||
"Bash(grep -rn export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/ --include=*.ts)",
|
||||
"Bash(wc -l \"g:/dev/svelte/vicen/src/uix/uisv/lib/bits/calendar/components/\"*.svelte)",
|
||||
"Bash(grep -n \"from \"\"@internationalized\\\\|from ''.*shared/date\\\\|from \"\".*shared/date\\\\|getDocument\\\\|isBrowser\\\\|styleToString\\\\|afterTick\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/utils.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/formatter.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/announcer.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/calendar-helpers.svelte.ts\")",
|
||||
"Bash(grep -r from.*[''].*internal/ g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte -l)",
|
||||
"Bash(grep -r from.*[''].*shared/ g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte -l)",
|
||||
"Bash(grep -rh \"from.*[''''].*\\\\\\(internal\\\\|shared\\\\\\)/\" g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte)",
|
||||
"Bash(grep -v \"^//\")",
|
||||
"Bash(grep -r \"from.*[''''].*\\\\\\(internal\\\\|shared\\\\\\)/\" g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte)",
|
||||
"Bash(grep -v '^\\\\s*//')",
|
||||
"Bash(git -C g:/dev/svelte/vicen status --short -- src/uix/acel/date-field/)",
|
||||
"Bash(npx tsx:*)",
|
||||
"Bash(for file:*)",
|
||||
"Bash(sed -i 's/: \\\\\\([^.]*\\\\\\) ? \"\"\"\" : undefined,/: boolToEmptyStrOrUndef\\(\\\\1\\),/g' \"$file\")",
|
||||
"Bash(node -e \":*)",
|
||||
"Bash(while read:*)",
|
||||
"Bash(do if:*)",
|
||||
"Bash(! grep:*)",
|
||||
"Bash(sed -i 's/DatePickerRootContext/DatePickerRootState.ctx/g' \"$file\")",
|
||||
"Bash(sed -i 's/DateRangePickerRootContext/DateRangePickerRootState.ctx/g' \"$file\")",
|
||||
"Bash(sed -i 's/MenuCheckboxGroupContext/MenuCheckboxGroupState.ctx/g' \"$file\")",
|
||||
"Bash(sed -i 's/ScrollAreaRootContext/ScrollAreaRootState.ctx/g' \"$file\")",
|
||||
"Bash(echo \"Fixed: $file\")",
|
||||
"Read(//g/dev/svelte/vicen - copia/**)",
|
||||
"Bash(bash /tmp/fix-imports.sh)",
|
||||
"Bash(sed -i 's/CalendarRootContext/CalendarRootState.ctx/g' src/uix/terra/range-calendar/range-calendar.svelte.ts)",
|
||||
"Bash(sed -i 's/CalendarRootState\\\\.ctx\\\\.get/CalendarRootState.ctx.get/g' src/uix/terra/range-calendar/range-calendar.svelte.ts)",
|
||||
"Bash(sed -i \"s/data-switch-root/data-switch/g\" contracts.ts)",
|
||||
"Bash(sed -i \"s/data-checkbox-root/data-checkbox/g\" contracts.ts)",
|
||||
"Bash(sed -i \"s/data-toggle-root/data-toggle/g\" contracts.ts)",
|
||||
"Bash(sed -i \"s/data-toggle-group-root/data-toggle-group/g\" contracts.ts)",
|
||||
"Bash(sed -i \"s/data-slider-root/data-slider/g\" contracts.ts)",
|
||||
"Bash(xxd \"G:/dev/svelte/vicen - copia/src/uix/terra/date-picker/components/date-picker-calendar.svelte\")",
|
||||
"Bash(awk 'NR==40 {print \"\\\\t\\\\t *\"} NR==41 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==42 {print \"\\\\t\\\\t * ```\"} NR==43 {print \"\\\\t\\\\t * [data-tags-input] \\\\u2192 Elemento contenedor del componente\"} NR==44 {print \"\\\\t\\\\t * [data-disabled] \\\\u2192 Estado cuando el campo est\\\\u00e1 deshabilitado\"} NR==45 {print \"\\\\t\\\\t * ```\"} NR==46 {print \"\\\\t\\\\t *\"} {print}' tags-input-root.svelte)",
|
||||
"Bash(awk 'NR==25 {print \"\\\\t\\\\t *\"} NR==26 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==27 {print \"\\\\t\\\\t * ```\"} NR==28 {print \"\\\\t\\\\t * [data-tags-input-tag] \\\\u2192 Elemento contenedor del tag\"} NR==29 {print \"\\\\t\\\\t * [data-readonly] \\\\u2192 Estado cuando el tag es de solo lectura\"} NR==30 {print \"\\\\t\\\\t * [data-disabled] \\\\u2192 Estado cuando el tag est\\\\u00e1 deshabilitado\"} NR==31 {print \"\\\\t\\\\t * ```\"} NR==32 {print \"\\\\t\\\\t *\"} {print}' tags-input-tag.svelte)",
|
||||
"Bash(awk 'NR==40 {print \"\\\\t\\\\t *\"} NR==41 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==42 {print \"\\\\t\\\\t * ```\"} NR==43 {print \"\\\\t\\\\t * [data-tags-input] -> Elemento contenedor del componente\"} NR==44 {print \"\\\\t\\\\t * [data-disabled] -> Estado cuando el campo esta deshabilitado\"} NR==45 {print \"\\\\t\\\\t * ```\"} NR==46 {print \"\\\\t\\\\t *\"} {print}' tags-input-root.svelte)",
|
||||
"Bash(awk 'NR==25 {print \"\\\\t\\\\t *\"} NR==26 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==27 {print \"\\\\t\\\\t * ```\"} NR==28 {print \"\\\\t\\\\t * [data-tags-input-tag] -> Elemento contenedor del tag\"} NR==29 {print \"\\\\t\\\\t * [data-readonly] -> Estado cuando el tag es de solo lectura\"} NR==30 {print \"\\\\t\\\\t * [data-disabled] -> Estado cuando el tag esta deshabilitado\"} NR==31 {print \"\\\\t\\\\t * ```\"} NR==32 {print \"\\\\t\\\\t *\"} {print}' tags-input-tag.svelte)",
|
||||
"Bash(sed -i '43s/->/\\\\u2192/; 44s/->/\\\\u2192/' tags-input-root.svelte)",
|
||||
"Bash(sed -i '28s/->/\\\\u2192/; 29s/->/\\\\u2192/; 30s/->/\\\\u2192/' tags-input-tag.svelte)",
|
||||
"Bash(sed -i 's/data-terra-dialog-content/data-dialog-content/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-terra-dialog-trigger/data-dialog-trigger/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-terra-dialog-overlay/data-dialog-overlay/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-terra-dialog-title/data-dialog-title/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-terra-dialog-description/data-dialog-description/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-alert-dialog-content/data-dialog-content/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-alert-dialog-trigger/data-dialog-trigger/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-alert-dialog-overlay/data-dialog-overlay/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-alert-dialog-title/data-dialog-title/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-alert-dialog-description/data-dialog-description/g' contracts.ts)",
|
||||
"Bash(sed -i 's/AlertDialog usa el mismo contrato que Dialog con variant=\"alert-dialog\"/AlertDialog es una variante de Dialog, usa los mismos attrs data-dialog-*/g' contracts.ts)",
|
||||
"Bash(sed -i '/Los attrs se generan con data-alert-dialog-/d' contracts.ts)",
|
||||
"Bash(python3)",
|
||||
"Bash(pnpm run:*)",
|
||||
"Bash(sed -i 's/<div {...mergedProps}>/<button {...mergedProps}>/g' \"g:/dev/svelte/vicen - copia/src/uix/terra/splitter/components/splitter-resize-trigger.svelte\")",
|
||||
"Bash(sed -i 's/<\\\\/div>/<\\\\/button>/g' \"g:/dev/svelte/vicen - copia/src/uix/terra/splitter/components/splitter-resize-trigger.svelte\")",
|
||||
"Bash(sed -n '106p' \"g:/dev/svelte/vicen - copia/src/routes/test/air/spinner/+page.svelte\")",
|
||||
"Bash(sed -n '123p' \"g:/dev/svelte/vicen - copia/src/routes/test/editable/+page.svelte\")",
|
||||
"Bash(sed -n '151p' \"g:/dev/svelte/vicen - copia/src/routes/test/editable/+page.svelte\")",
|
||||
"Bash(sed -n '93p' \"g:/dev/svelte/vicen - copia/src/routes/test/file-upload/+page.svelte\")",
|
||||
"Bash(sed -n '151p' \"g:/dev/svelte/vicen - copia/src/routes/test/file-upload/+page.svelte\")",
|
||||
"Bash(perl -i -pe 's/\\\\.\"\\\\$$\"/\"/g' air/spinner/+page.svelte editable/+page.svelte file-upload/+page.svelte link-preview/+page.svelte stepper/+page.svelte)",
|
||||
"Bash(mkdir -p external/dates)",
|
||||
"Bash(cp -r adapters/dates/* external/dates/)",
|
||||
"Bash(find src/uix/terra -type f '\\(' -name '*.ts' -o -name '*.svelte' -o -name '*.md' '\\)' -exec sed -i 's|from ['\\\\'']\\\\$terra/adapters/dates['\\\\'']|from '\\\\''$terra/external/dates'\\\\''|g' '{}' +)",
|
||||
"Bash(find src/uix/terra -type f '\\(' -name '*.ts' -o -name '*.svelte' '\\)' -exec perl -pi -e 's|from [\"\\\\x27]\\\\$terra/adapters/dates[\"\\\\x27]|from \"$terra/external/dates\"|g' '{}' +)",
|
||||
"Bash(find src/uix/terra -type f '\\(' -name '*.ts' -o -name '*.svelte' '\\)' -exec sed -i 's|from \"/external/dates\"|from \"$terra/external/dates\"|g' '{}' ';')",
|
||||
"Bash(xargs -n1 basename)",
|
||||
"Bash(find \"g:/dev/svelte/vicen - copia/src/uix/air/icons/lib\" -name \"*.svelte\" -exec basename {} \\\\;)",
|
||||
"Bash(find \"g:/dev/svelte/vicen - copia/src/uix/air/icons/lib\" -name \"*.svelte\" -exec basename {} .svelte \\\\;)",
|
||||
"Read(//tmp/**)",
|
||||
"Read(//g/dev/svelte/**)",
|
||||
"Bash(sed -i -e 's/\\\\bairReveal\\\\b/reveal/g' -e 's/\\\\bairDismiss\\\\b/dismiss/g' -e 's/export function airContext\\\\b/export function contextIn/g' -e 's/export function airContextOut\\\\b/export function contextOut/g' -e 's/\\\\bairExpand\\\\b/expand/g' -e 's/\\\\bairExpandOut\\\\b/expandOut/g' g:/dev/svelte/vicen/src/uix/air/internal/motion/transitions.ts)",
|
||||
"Bash(sed -i -e 's/\\\\bairFeedback\\\\b/feedback/g' -e 's/\\\\bairAttention\\\\b/attention/g' -e 's/\\\\bairEmphasis\\\\b/emphasis/g' -e 's/\\\\bairPersistence\\\\b/persistence/g' -e 's/\\\\bairSelection\\\\b/selection/g' -e 's/\\\\bairCompletion\\\\b/completion/g' g:/dev/svelte/vicen/src/uix/air/internal/behavior/behaviors.svelte.ts)"
|
||||
]
|
||||
}
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(npx svelte-check:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(grep -v \"^$\")",
|
||||
"Bash(grep -n export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/utils.ts)",
|
||||
"Bash(grep -rn export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/ --include=*.ts)",
|
||||
"Bash(wc -l \"g:/dev/svelte/vicen/src/uix/uisv/lib/bits/calendar/components/\"*.svelte)",
|
||||
"Bash(grep -n \"from \"\"@internationalized\\\\|from ''.*shared/date\\\\|from \"\".*shared/date\\\\|getDocument\\\\|isBrowser\\\\|styleToString\\\\|afterTick\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/utils.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/formatter.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/announcer.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/calendar-helpers.svelte.ts\")",
|
||||
"Bash(grep -r from.*[''].*internal/ g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte -l)",
|
||||
"Bash(grep -r from.*[''].*shared/ g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte -l)",
|
||||
"Bash(grep -rh \"from.*[''''].*\\\\\\(internal\\\\|shared\\\\\\)/\" g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte)",
|
||||
"Bash(grep -v \"^//\")",
|
||||
"Bash(grep -r \"from.*[''''].*\\\\\\(internal\\\\|shared\\\\\\)/\" g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte)",
|
||||
"Bash(grep -v '^\\\\s*//')",
|
||||
"Bash(git -C g:/dev/svelte/vicen status --short -- src/uix/acel/date-field/)",
|
||||
"Bash(npx tsx:*)",
|
||||
"Bash(for file:*)",
|
||||
"Bash(sed -i 's/: \\\\\\([^.]*\\\\\\) ? \"\"\"\" : undefined,/: boolToEmptyStrOrUndef\\(\\\\1\\),/g' \"$file\")",
|
||||
"Bash(node -e \":*)",
|
||||
"Bash(while read:*)",
|
||||
"Bash(do if:*)",
|
||||
"Bash(! grep:*)",
|
||||
"Bash(sed -i 's/DatePickerRootContext/DatePickerRootState.ctx/g' \"$file\")",
|
||||
"Bash(sed -i 's/DateRangePickerRootContext/DateRangePickerRootState.ctx/g' \"$file\")",
|
||||
"Bash(sed -i 's/MenuCheckboxGroupContext/MenuCheckboxGroupState.ctx/g' \"$file\")",
|
||||
"Bash(sed -i 's/ScrollAreaRootContext/ScrollAreaRootState.ctx/g' \"$file\")",
|
||||
"Bash(echo \"Fixed: $file\")",
|
||||
"Read(//g/dev/svelte/vicen - copia/**)",
|
||||
"Bash(bash /tmp/fix-imports.sh)",
|
||||
"Bash(sed -i 's/CalendarRootContext/CalendarRootState.ctx/g' src/uix/terra/range-calendar/range-calendar.svelte.ts)",
|
||||
"Bash(sed -i 's/CalendarRootState\\\\.ctx\\\\.get/CalendarRootState.ctx.get/g' src/uix/terra/range-calendar/range-calendar.svelte.ts)",
|
||||
"Bash(sed -i \"s/data-switch-root/data-switch/g\" contracts.ts)",
|
||||
"Bash(sed -i \"s/data-checkbox-root/data-checkbox/g\" contracts.ts)",
|
||||
"Bash(sed -i \"s/data-toggle-root/data-toggle/g\" contracts.ts)",
|
||||
"Bash(sed -i \"s/data-toggle-group-root/data-toggle-group/g\" contracts.ts)",
|
||||
"Bash(sed -i \"s/data-slider-root/data-slider/g\" contracts.ts)",
|
||||
"Bash(xxd \"G:/dev/svelte/vicen - copia/src/uix/terra/date-picker/components/date-picker-calendar.svelte\")",
|
||||
"Bash(awk 'NR==40 {print \"\\\\t\\\\t *\"} NR==41 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==42 {print \"\\\\t\\\\t * ```\"} NR==43 {print \"\\\\t\\\\t * [data-tags-input] \\\\u2192 Elemento contenedor del componente\"} NR==44 {print \"\\\\t\\\\t * [data-disabled] \\\\u2192 Estado cuando el campo est\\\\u00e1 deshabilitado\"} NR==45 {print \"\\\\t\\\\t * ```\"} NR==46 {print \"\\\\t\\\\t *\"} {print}' tags-input-root.svelte)",
|
||||
"Bash(awk 'NR==25 {print \"\\\\t\\\\t *\"} NR==26 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==27 {print \"\\\\t\\\\t * ```\"} NR==28 {print \"\\\\t\\\\t * [data-tags-input-tag] \\\\u2192 Elemento contenedor del tag\"} NR==29 {print \"\\\\t\\\\t * [data-readonly] \\\\u2192 Estado cuando el tag es de solo lectura\"} NR==30 {print \"\\\\t\\\\t * [data-disabled] \\\\u2192 Estado cuando el tag est\\\\u00e1 deshabilitado\"} NR==31 {print \"\\\\t\\\\t * ```\"} NR==32 {print \"\\\\t\\\\t *\"} {print}' tags-input-tag.svelte)",
|
||||
"Bash(awk 'NR==40 {print \"\\\\t\\\\t *\"} NR==41 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==42 {print \"\\\\t\\\\t * ```\"} NR==43 {print \"\\\\t\\\\t * [data-tags-input] -> Elemento contenedor del componente\"} NR==44 {print \"\\\\t\\\\t * [data-disabled] -> Estado cuando el campo esta deshabilitado\"} NR==45 {print \"\\\\t\\\\t * ```\"} NR==46 {print \"\\\\t\\\\t *\"} {print}' tags-input-root.svelte)",
|
||||
"Bash(awk 'NR==25 {print \"\\\\t\\\\t *\"} NR==26 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==27 {print \"\\\\t\\\\t * ```\"} NR==28 {print \"\\\\t\\\\t * [data-tags-input-tag] -> Elemento contenedor del tag\"} NR==29 {print \"\\\\t\\\\t * [data-readonly] -> Estado cuando el tag es de solo lectura\"} NR==30 {print \"\\\\t\\\\t * [data-disabled] -> Estado cuando el tag esta deshabilitado\"} NR==31 {print \"\\\\t\\\\t * ```\"} NR==32 {print \"\\\\t\\\\t *\"} {print}' tags-input-tag.svelte)",
|
||||
"Bash(sed -i '43s/->/\\\\u2192/; 44s/->/\\\\u2192/' tags-input-root.svelte)",
|
||||
"Bash(sed -i '28s/->/\\\\u2192/; 29s/->/\\\\u2192/; 30s/->/\\\\u2192/' tags-input-tag.svelte)",
|
||||
"Bash(sed -i 's/data-terra-dialog-content/data-dialog-content/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-terra-dialog-trigger/data-dialog-trigger/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-terra-dialog-overlay/data-dialog-overlay/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-terra-dialog-title/data-dialog-title/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-terra-dialog-description/data-dialog-description/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-alert-dialog-content/data-dialog-content/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-alert-dialog-trigger/data-dialog-trigger/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-alert-dialog-overlay/data-dialog-overlay/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-alert-dialog-title/data-dialog-title/g' contracts.ts)",
|
||||
"Bash(sed -i 's/data-alert-dialog-description/data-dialog-description/g' contracts.ts)",
|
||||
"Bash(sed -i 's/AlertDialog usa el mismo contrato que Dialog con variant=\"alert-dialog\"/AlertDialog es una variante de Dialog, usa los mismos attrs data-dialog-*/g' contracts.ts)",
|
||||
"Bash(sed -i '/Los attrs se generan con data-alert-dialog-/d' contracts.ts)",
|
||||
"Bash(python3)",
|
||||
"Bash(pnpm run:*)",
|
||||
"Bash(sed -i 's/<div {...mergedProps}>/<button {...mergedProps}>/g' \"g:/dev/svelte/vicen - copia/src/uix/terra/splitter/components/splitter-resize-trigger.svelte\")",
|
||||
"Bash(sed -i 's/<\\\\/div>/<\\\\/button>/g' \"g:/dev/svelte/vicen - copia/src/uix/terra/splitter/components/splitter-resize-trigger.svelte\")",
|
||||
"Bash(sed -n '106p' \"g:/dev/svelte/vicen - copia/src/routes/test/air/spinner/+page.svelte\")",
|
||||
"Bash(sed -n '123p' \"g:/dev/svelte/vicen - copia/src/routes/test/editable/+page.svelte\")",
|
||||
"Bash(sed -n '151p' \"g:/dev/svelte/vicen - copia/src/routes/test/editable/+page.svelte\")",
|
||||
"Bash(sed -n '93p' \"g:/dev/svelte/vicen - copia/src/routes/test/file-upload/+page.svelte\")",
|
||||
"Bash(sed -n '151p' \"g:/dev/svelte/vicen - copia/src/routes/test/file-upload/+page.svelte\")",
|
||||
"Bash(perl -i -pe 's/\\\\.\"\\\\$$\"/\"/g' air/spinner/+page.svelte editable/+page.svelte file-upload/+page.svelte link-preview/+page.svelte stepper/+page.svelte)",
|
||||
"Bash(mkdir -p external/dates)",
|
||||
"Bash(cp -r adapters/dates/* external/dates/)",
|
||||
"Bash(find src/uix/terra -type f '\\(' -name '*.ts' -o -name '*.svelte' -o -name '*.md' '\\)' -exec sed -i 's|from ['\\\\'']\\\\$terra/adapters/dates['\\\\'']|from '\\\\''$terra/external/dates'\\\\''|g' '{}' +)",
|
||||
"Bash(find src/uix/terra -type f '\\(' -name '*.ts' -o -name '*.svelte' '\\)' -exec perl -pi -e 's|from [\"\\\\x27]\\\\$terra/adapters/dates[\"\\\\x27]|from \"$terra/external/dates\"|g' '{}' +)",
|
||||
"Bash(find src/uix/terra -type f '\\(' -name '*.ts' -o -name '*.svelte' '\\)' -exec sed -i 's|from \"/external/dates\"|from \"$terra/external/dates\"|g' '{}' ';')",
|
||||
"Bash(xargs -n1 basename)",
|
||||
"Bash(find \"g:/dev/svelte/vicen - copia/src/uix/air/icons/lib\" -name \"*.svelte\" -exec basename {} \\\\;)",
|
||||
"Bash(find \"g:/dev/svelte/vicen - copia/src/uix/air/icons/lib\" -name \"*.svelte\" -exec basename {} .svelte \\\\;)",
|
||||
"Read(//tmp/**)",
|
||||
"Read(//g/dev/svelte/**)",
|
||||
"Bash(sed -i -e 's/\\\\bairReveal\\\\b/reveal/g' -e 's/\\\\bairDismiss\\\\b/dismiss/g' -e 's/export function airContext\\\\b/export function contextIn/g' -e 's/export function airContextOut\\\\b/export function contextOut/g' -e 's/\\\\bairExpand\\\\b/expand/g' -e 's/\\\\bairExpandOut\\\\b/expandOut/g' g:/dev/svelte/vicen/src/uix/air/internal/motion/transitions.ts)",
|
||||
"Bash(sed -i -e 's/\\\\bairFeedback\\\\b/feedback/g' -e 's/\\\\bairAttention\\\\b/attention/g' -e 's/\\\\bairEmphasis\\\\b/emphasis/g' -e 's/\\\\bairPersistence\\\\b/persistence/g' -e 's/\\\\bairSelection\\\\b/selection/g' -e 's/\\\\bairCompletion\\\\b/completion/g' g:/dev/svelte/vicen/src/uix/air/internal/behavior/behaviors.svelte.ts)",
|
||||
"Read(//c/Users/dev/.claude/projects/g--dev-svelte-vicen/memory/**)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(echo \"dev pid: $!\")",
|
||||
"Bash(pkill -f \"vite dev\")"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"g:\\dev\\svelte\\vicen\\src\\uix\\soma\\components\\search-field"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Morfo DOM-validation check.
|
||||
*
|
||||
* For each morfo at `src/uix/morfo/components/*.ts`:
|
||||
* 1. Navigate to the matching demo route at `/test/soma/{kebab}`.
|
||||
* 2. For every `public` part declared in the morfo, query the DOM for
|
||||
* `[data-{component}-{part}]` (or `[data-{component}]` for root).
|
||||
* 3. For each matching element, verify:
|
||||
* - Every data-attr the morfo declares with `severity: 'required'`
|
||||
* is present.
|
||||
* - Every data-attr with `values: [...]` has a value in that set.
|
||||
* - No `data-{component}-*` attr is present that isn't declared in
|
||||
* the morfo (strict mode — excludes `data-_*` privates).
|
||||
*
|
||||
* Runs after `npm run smoke` passes. Requires `npm run dev` running.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — all morfos validate against their rendered DOM.
|
||||
* 1 — at least one morfo-vs-DOM discrepancy.
|
||||
* 2 — no dev server / couldn't load morfos.
|
||||
*/
|
||||
|
||||
import { chromium, type Page } from 'playwright';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { Morfo, MorfoPart, MorfoData } from '../src/uix/morfo/types';
|
||||
import { validateMorfo } from '../src/uix/morfo/schema';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components');
|
||||
|
||||
async function probePort(start: number, end: number): Promise<string | null> {
|
||||
for (let port = start; port <= end; port++) {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${port}/`, {
|
||||
signal: AbortSignal.timeout(500)
|
||||
});
|
||||
if (res.ok || res.status === 404 || res.status === 500) {
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadMorfos(): Promise<Morfo[]> {
|
||||
const files = readdirSync(MORFOS_DIR).filter(
|
||||
(f) => f.endsWith('.ts') && !f.endsWith('.test.ts')
|
||||
);
|
||||
const out: Morfo[] = [];
|
||||
for (const f of files) {
|
||||
// Use file:// URL on Windows — absolute paths starting with "g:" are
|
||||
// rejected by Node's ESM loader.
|
||||
const url = pathToFileURL(join(MORFOS_DIR, f)).href;
|
||||
const mod = (await import(url)) as Record<string, unknown>;
|
||||
for (const v of Object.values(mod)) {
|
||||
if (typeof v === 'object' && v !== null && 'kebab' in v && 'parts' in v) {
|
||||
out.push(validateMorfo(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type Issue = { kind: 'missing' | 'bad-value' | 'undeclared'; message: string };
|
||||
|
||||
/** Flatten morfo parts into a flat array for validation lookup. */
|
||||
function flatParts(parts: readonly MorfoPart[]): MorfoPart[] {
|
||||
const out: MorfoPart[] = [];
|
||||
for (const p of parts) {
|
||||
out.push(p);
|
||||
if (p.parts && p.parts.length > 0) out.push(...flatParts(p.parts));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function dataAttrFor(kebab: string, part: MorfoPart): string {
|
||||
return part.kebab === 'root' ? `data-${kebab}` : `data-${kebab}-${part.kebab}`;
|
||||
}
|
||||
|
||||
async function validateMorfoAgainstDom(page: Page, morfo: Morfo): Promise<Issue[]> {
|
||||
const issues: Issue[] = [];
|
||||
const parts = flatParts(morfo.parts).filter((p) => p.kind === 'public');
|
||||
|
||||
for (const part of parts) {
|
||||
const partAttr = dataAttrFor(morfo.kebab, part);
|
||||
|
||||
// All elements on the page that carry this part's identifying attr.
|
||||
const elementsAttrs = await page.$$eval(`[${partAttr}]`, (nodes) =>
|
||||
nodes.map((el) =>
|
||||
Array.from(el.attributes)
|
||||
.filter((a) => a.name.startsWith('data-'))
|
||||
.map((a) => ({ name: a.name, value: a.value }))
|
||||
)
|
||||
);
|
||||
|
||||
if (elementsAttrs.length === 0) {
|
||||
// Not on the demo page — skip validation entirely. Morfo "optional"
|
||||
// vs "required" is about composition validity (whether a consumer
|
||||
// MAY omit the part), not about demo-page presence. Dialog.Content
|
||||
// is required in a valid composition but only mounts when
|
||||
// `open=true`, which the static demo page may not exercise.
|
||||
//
|
||||
// A dedicated permutation-matrix script (future) will exercise
|
||||
// each component through its state space and validate part
|
||||
// presence per permutation. For now, demo presence is advisory.
|
||||
continue;
|
||||
}
|
||||
|
||||
const declared = new Set(part.data.map((d) => d.attr));
|
||||
declared.add(partAttr); // the part-identifying attr itself
|
||||
const declaredByAttr = new Map<string, MorfoData>(
|
||||
part.data.map((d) => [d.attr, d])
|
||||
);
|
||||
|
||||
for (const elAttrs of elementsAttrs) {
|
||||
// (a) required attrs present
|
||||
for (const data of part.data) {
|
||||
if ((data.severity ?? 'required') !== 'required') continue;
|
||||
const found = elAttrs.find((a) => a.name === data.attr);
|
||||
if (!found) {
|
||||
issues.push({
|
||||
kind: 'missing',
|
||||
message: `${morfo.kebab}.${part.kebab}: required attr "${data.attr}" not emitted`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// (b) enum-valued attrs have valid values
|
||||
for (const { name, value } of elAttrs) {
|
||||
const decl = declaredByAttr.get(name);
|
||||
if (decl?.values && !decl.values.includes(value)) {
|
||||
issues.push({
|
||||
kind: 'bad-value',
|
||||
message: `${morfo.kebab}.${part.kebab}: "${name}" has value "${value}", morfo declares [${decl.values.join(', ')}]`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// (c) no undeclared public data-{component}-* attrs
|
||||
for (const { name } of elAttrs) {
|
||||
if (!name.startsWith(`data-${morfo.kebab}`) && !name.startsWith('data-')) continue;
|
||||
if (name.startsWith(`data-_`)) continue; // private escape hatch
|
||||
if (declared.has(name)) continue;
|
||||
// Allow other components' part attrs (nested composition).
|
||||
if (!name.startsWith(`data-${morfo.kebab}`)) continue;
|
||||
issues.push({
|
||||
kind: 'undeclared',
|
||||
message: `${morfo.kebab}.${part.kebab}: undeclared attr "${name}" emitted on element (not in morfo)`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const BASE = process.argv[2] ?? (await probePort(5173, 5180));
|
||||
if (!BASE) {
|
||||
console.error('Could not find a running dev server on 5173-5180.');
|
||||
console.error('Start it with `npm run dev` in another terminal.');
|
||||
process.exit(2);
|
||||
}
|
||||
console.error(`Using dev server at ${BASE}`);
|
||||
|
||||
const morfos = await loadMorfos();
|
||||
if (morfos.length === 0) {
|
||||
console.error('No morfos found under src/uix/morfo/components/');
|
||||
process.exit(2);
|
||||
}
|
||||
console.error(`Loaded ${morfos.length} morfo${morfos.length === 1 ? '' : 's'}`);
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext();
|
||||
const failures: Array<{ morfo: string; issues: Issue[] }> = [];
|
||||
|
||||
for (const morfo of morfos) {
|
||||
const route = `/test/soma/${morfo.kebab}`;
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await page.goto(BASE + route, { waitUntil: 'networkidle', timeout: 20000 });
|
||||
await page.waitForTimeout(500);
|
||||
const issues = await validateMorfoAgainstDom(page, morfo);
|
||||
if (issues.length === 0) {
|
||||
console.log(`PASS ${morfo.kebab.padEnd(24)} ${route}`);
|
||||
} else {
|
||||
console.log(`FAIL ${morfo.kebab.padEnd(24)} ${route}`);
|
||||
issues.forEach((i) => console.log(` [${i.kind}] ${i.message}`));
|
||||
failures.push({ morfo: morfo.kebab, issues });
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`ERROR ${morfo.kebab.padEnd(24)} ${(e as Error).message}`);
|
||||
failures.push({
|
||||
morfo: morfo.kebab,
|
||||
issues: [{ kind: 'missing', message: `navigation error: ${(e as Error).message}` }]
|
||||
});
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log('');
|
||||
if (failures.length === 0) {
|
||||
console.log(`All ${morfos.length} morfo${morfos.length === 1 ? '' : 's'} validate against their demo DOM.`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
const totalIssues = failures.reduce((sum, f) => sum + f.issues.length, 0);
|
||||
console.log(
|
||||
`${failures.length}/${morfos.length} morfos failed (${totalIssues} total issues): ${failures.map((f) => f.morfo).join(', ')}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Morfo vocabulary consistency check.
|
||||
*
|
||||
* Walks every morfo's `data` declarations and reports any attr with
|
||||
* `values: string[]` whose set is NOT one of the canonical vocabularies
|
||||
* declared in `CANONICAL_VOCABULARIES` (exported from morfo/schema.ts).
|
||||
*
|
||||
* Rationale (see sema_pre.md §5 + study.md §10):
|
||||
* Sema selectors rely on consistent state vocabularies across components.
|
||||
* If Accordion uses `data-state: ['open', 'closed']` then Dialog and Drawer
|
||||
* must use the same tokens — not `visible|hidden` or `expanded|collapsed`.
|
||||
*
|
||||
* Strategy: a whitelist of known vocabularies. Any enum that doesn't exactly
|
||||
* match one of them is flagged as a WARNING, not a hard error. Promote to
|
||||
* error only when the vocabulary registry is mature enough that divergence
|
||||
* is always a bug.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — all enums match a canonical vocabulary (or are novel but acceptable).
|
||||
* 1 — at least one enum diverges and should be investigated.
|
||||
*/
|
||||
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { Morfo, MorfoPart } from '../src/uix/morfo/types';
|
||||
import { validateMorfo, CANONICAL_VOCABULARIES } from '../src/uix/morfo/schema';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components');
|
||||
|
||||
async function loadMorfos(): Promise<Morfo[]> {
|
||||
const files = readdirSync(MORFOS_DIR).filter(
|
||||
(f) => f.endsWith('.ts') && !f.endsWith('.test.ts')
|
||||
);
|
||||
const out: Morfo[] = [];
|
||||
for (const f of files) {
|
||||
const url = pathToFileURL(join(MORFOS_DIR, f)).href;
|
||||
const mod = (await import(url)) as Record<string, unknown>;
|
||||
for (const v of Object.values(mod)) {
|
||||
if (typeof v === 'object' && v !== null && 'kebab' in v && 'parts' in v) {
|
||||
out.push(validateMorfo(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function flatParts(parts: readonly MorfoPart[]): MorfoPart[] {
|
||||
const out: MorfoPart[] = [];
|
||||
for (const p of parts) {
|
||||
out.push(p);
|
||||
if (p.parts && p.parts.length > 0) out.push(...flatParts(p.parts));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type Classification =
|
||||
| { kind: 'match'; vocabulary: string }
|
||||
| { kind: 'subset'; vocabulary: string; extra: string[] }
|
||||
| { kind: 'superset'; vocabulary: string; missing: string[] }
|
||||
| { kind: 'unknown' };
|
||||
|
||||
function classify(values: readonly string[]): Classification {
|
||||
const valueSet = new Set(values);
|
||||
for (const [name, canonical] of Object.entries(CANONICAL_VOCABULARIES)) {
|
||||
const canonicalSet = new Set(canonical);
|
||||
if (
|
||||
canonicalSet.size === valueSet.size &&
|
||||
[...valueSet].every((v) => canonicalSet.has(v))
|
||||
) {
|
||||
return { kind: 'match', vocabulary: name };
|
||||
}
|
||||
// Enum extends a canonical (added values)
|
||||
if ([...canonicalSet].every((v) => valueSet.has(v))) {
|
||||
const extra = [...valueSet].filter((v) => !canonicalSet.has(v));
|
||||
if (extra.length > 0 && extra.length <= 3) {
|
||||
return { kind: 'subset', vocabulary: name, extra };
|
||||
}
|
||||
}
|
||||
// Enum shrinks a canonical (removed values)
|
||||
if ([...valueSet].every((v) => canonicalSet.has(v))) {
|
||||
const missing = [...canonicalSet].filter((v) => !valueSet.has(v));
|
||||
if (missing.length > 0 && missing.length <= 3) {
|
||||
return { kind: 'superset', vocabulary: name, missing };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const morfos = await loadMorfos();
|
||||
console.error(`Scanning ${morfos.length} morfo${morfos.length === 1 ? '' : 's'} for vocabulary divergence...`);
|
||||
|
||||
type Finding = {
|
||||
morfo: string;
|
||||
part: string;
|
||||
attr: string;
|
||||
values: readonly string[];
|
||||
classification: Classification;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attrs whose value sets are per-component by design, not shared vocabulary.
|
||||
* Skipped by the consistency check — Dialog's `saved|cancelled|…` is correctly
|
||||
* different from Toast's `dismissed|auto-timeout|action`.
|
||||
*/
|
||||
const PER_COMPONENT_ATTRS = new Set(['data-last-action']);
|
||||
|
||||
const findings: Finding[] = [];
|
||||
|
||||
for (const morfo of morfos) {
|
||||
for (const part of flatParts(morfo.parts)) {
|
||||
for (const data of part.data) {
|
||||
if (!data.values) continue;
|
||||
if (PER_COMPONENT_ATTRS.has(data.attr)) continue;
|
||||
const classification = classify(data.values);
|
||||
if (classification.kind !== 'match') {
|
||||
findings.push({
|
||||
morfo: morfo.kebab,
|
||||
part: part.kebab,
|
||||
attr: data.attr,
|
||||
values: data.values,
|
||||
classification
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (findings.length === 0) {
|
||||
console.log(`All enum values match canonical vocabularies.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`Found ${findings.length} divergent enum${findings.length === 1 ? '' : 's'}:`);
|
||||
console.log('');
|
||||
|
||||
for (const f of findings) {
|
||||
const loc = `${f.morfo}.${f.part}.${f.attr}`;
|
||||
const values = `[${f.values.join(', ')}]`;
|
||||
switch (f.classification.kind) {
|
||||
case 'subset':
|
||||
console.log(
|
||||
`WARN ${loc} ${values} extends "${f.classification.vocabulary}" with: ${f.classification.extra.join(', ')}`
|
||||
);
|
||||
break;
|
||||
case 'superset':
|
||||
console.log(
|
||||
`WARN ${loc} ${values} shrinks "${f.classification.vocabulary}" missing: ${f.classification.missing.join(', ')}`
|
||||
);
|
||||
break;
|
||||
case 'unknown':
|
||||
console.log(
|
||||
`WARN ${loc} ${values} no canonical vocabulary matches. Consider if this should use one of: ${Object.keys(CANONICAL_VOCABULARIES).join(', ')}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(
|
||||
`These are WARNINGS — novel vocabularies may be legitimate. Review each above: if the value set SHOULD match a canonical vocabulary, align it. If the component introduces a NEW canonical vocabulary, add it to CANONICAL_VOCABULARIES in src/uix/morfo/schema.ts.`
|
||||
);
|
||||
|
||||
process.exit(0); // WARN only — don't fail CI yet (until vocabulary registry is mature)
|
||||
@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Playwright smoke check for the /test/soma/* demo pages.
|
||||
*
|
||||
* What it verifies per route (beyond HTTP 200):
|
||||
* - No uncaught `pageerror` (thrown during component init / hydration).
|
||||
* - No `console.error` (runtime exceptions that didn't throw synchronously).
|
||||
* - No translation key missing warnings (`[lang] Translation key not found`).
|
||||
* - No soma context-not-found warnings (`Context "X" not found`).
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/smoke-check.mjs [base-url]
|
||||
* npm run smoke
|
||||
*
|
||||
* Requires `npm run dev` running in another terminal (or passes an explicit
|
||||
* base URL). The script tries 5173-5180 if no URL is given.
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { readdirSync, statSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROUTES_DIR = join(__dirname, '..', 'src', 'routes', 'test', 'soma');
|
||||
|
||||
/** Find every `{route}/+page.svelte` under /test/soma recursively. */
|
||||
function discoverRoutes(dir, prefix) {
|
||||
const routes = [];
|
||||
const entries = readdirSync(dir);
|
||||
for (const name of entries) {
|
||||
const full = join(dir, name);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
routes.push(...discoverRoutes(full, `${prefix}/${name}`));
|
||||
} else if (name === '+page.svelte') {
|
||||
routes.push(prefix || '/test/soma');
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
async function probePort(startPort, endPort) {
|
||||
for (let port = startPort; port <= endPort; port++) {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${port}/`, {
|
||||
signal: AbortSignal.timeout(500)
|
||||
});
|
||||
if (res.ok || res.status === 404 || res.status === 500) {
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
} catch {
|
||||
// connection refused — try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const BASE = process.argv[2] ?? (await probePort(5173, 5180));
|
||||
if (!BASE) {
|
||||
console.error('Could not find a running dev server on 5173-5180.');
|
||||
console.error('Start it with `npm run dev` in another terminal.');
|
||||
process.exit(2);
|
||||
}
|
||||
console.error(`Using dev server at ${BASE}`);
|
||||
|
||||
const routes = discoverRoutes(ROUTES_DIR, '/test/soma').sort();
|
||||
if (routes.length === 0) {
|
||||
console.error('No +page.svelte files found under /test/soma.');
|
||||
process.exit(2);
|
||||
}
|
||||
console.error(`Checking ${routes.length} routes...`);
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext();
|
||||
const failures = [];
|
||||
const MAX_PARALLEL = 4;
|
||||
|
||||
async function checkRoute(route) {
|
||||
const page = await ctx.newPage();
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
page.on('pageerror', (e) => errors.push(` pageerror: ${e.message}`));
|
||||
page.on('console', (m) => {
|
||||
const t = m.text();
|
||||
if (m.type() === 'error') {
|
||||
// Filter out some noisy devtools messages unrelated to correctness.
|
||||
if (t.includes('Download the React DevTools')) return;
|
||||
errors.push(` console.error: ${t}`);
|
||||
}
|
||||
if (m.type() === 'warning' || m.type() === 'warn') {
|
||||
if (
|
||||
t.includes('Translation key not found') ||
|
||||
t.includes('Context') ||
|
||||
t.includes('[soma]') ||
|
||||
t.includes('soma context')
|
||||
) {
|
||||
warnings.push(` console.warning: ${t}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
const res = await page.goto(BASE + route, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 20000
|
||||
});
|
||||
const status = res?.status() ?? 0;
|
||||
// Give reactive effects + lazy demos a moment to settle.
|
||||
await page.waitForTimeout(500);
|
||||
if (status !== 200 || errors.length > 0 || warnings.length > 0) {
|
||||
const lines = [`FAIL ${route} (HTTP ${status})`];
|
||||
errors.forEach((e) => lines.push(e));
|
||||
warnings.forEach((w) => lines.push(w));
|
||||
return { ok: false, lines };
|
||||
}
|
||||
return { ok: true, lines: [`PASS ${route}`] };
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
lines: [`FAIL ${route} (navigation error: ${e.message})`]
|
||||
};
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Run checks with bounded concurrency so the dev server doesn't get hammered.
|
||||
let cursor = 0;
|
||||
async function worker() {
|
||||
while (cursor < routes.length) {
|
||||
const idx = cursor++;
|
||||
const route = routes[idx];
|
||||
const result = await checkRoute(route);
|
||||
result.lines.forEach((l) => console.log(l));
|
||||
if (!result.ok) failures.push(route);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: MAX_PARALLEL }, () => worker()));
|
||||
|
||||
await browser.close();
|
||||
console.log('');
|
||||
console.log(
|
||||
failures.length === 0
|
||||
? `All ${routes.length} routes passed.`
|
||||
: `${failures.length}/${routes.length} routes failed: ${failures.join(', ')}`
|
||||
);
|
||||
process.exit(failures.length === 0 ? 0 : 1);
|
||||
@ -0,0 +1,326 @@
|
||||
<script lang="ts">
|
||||
import * as Announce from '$soma/components/announce';
|
||||
import { createAnnouncer } from '$soma/components/announce';
|
||||
|
||||
let defaultTimeout = $state(1000);
|
||||
let message = $state('File saved successfully.');
|
||||
let priority = $state<'polite' | 'assertive'>('polite');
|
||||
let customRegionMessage = $state('Ready');
|
||||
let log = $state<string[]>([]);
|
||||
let globalLog = $state<string[]>([]);
|
||||
|
||||
function logAnnounce(m: string, p: 'polite' | 'assertive') {
|
||||
log = [...log, `[${p}] ${m}`].slice(-8);
|
||||
}
|
||||
|
||||
// Global announcer — lives outside any Provider; creates DOM nodes lazily.
|
||||
const global = createAnnouncer(1200);
|
||||
function fireGlobal(m: string, p: 'polite' | 'assertive') {
|
||||
global.announce(m, p);
|
||||
globalLog = [...globalLog, `[${p}] ${m}`].slice(-5);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Announce · Soma</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<h1>Announce</h1>
|
||||
<p>
|
||||
Primitivo de live region — <code>role=status</code> / <code>role=alert</code> +
|
||||
<code>aria-live</code> para anuncios a lectores de pantalla. API imperativa
|
||||
<code>announce(msg, priority, timeout)</code> y regiones declarativas.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>1. Imperative API</h2>
|
||||
<p class="desc">
|
||||
Provider monta 2 regiones internas ocultas (polite + assertive) y expone
|
||||
<code>announce()</code> via snippet. Abre DevTools accessibility tree para ver las
|
||||
regiones.
|
||||
</p>
|
||||
<div class="controls">
|
||||
<label class="full">
|
||||
<span>message</span>
|
||||
<input type="text" bind:value={message} />
|
||||
</label>
|
||||
<label>
|
||||
<span>priority</span>
|
||||
<select bind:value={priority}>
|
||||
<option value="polite">polite</option>
|
||||
<option value="assertive">assertive</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>defaultTimeout ({defaultTimeout}ms)</span>
|
||||
<input type="range" min="300" max="3000" step="100" bind:value={defaultTimeout} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Announce.Provider {defaultTimeout}>
|
||||
{#snippet children({ announce, clear })}
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
announce(message, priority);
|
||||
logAnnounce(message, priority);
|
||||
}}>Announce</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
clear();
|
||||
log = [];
|
||||
}}>Clear</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
announce('Saved!', 'polite');
|
||||
logAnnounce('Saved!', 'polite');
|
||||
setTimeout(() => {
|
||||
announce('Saved!', 'polite');
|
||||
logAnnounce('Saved! (repeat)', 'polite');
|
||||
}, 100);
|
||||
}}>Announce same twice</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
announce('Error: connection lost', 'assertive');
|
||||
logAnnounce('Error: connection lost', 'assertive');
|
||||
}}>Fire error</button
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Announce.Provider>
|
||||
|
||||
<dl class="state">
|
||||
<dt>history (last 8)</dt>
|
||||
<dd>
|
||||
{#if log.length === 0}
|
||||
<code>—</code>
|
||||
{:else}
|
||||
<ul class="log">
|
||||
{#each log as entry (entry + Math.random())}
|
||||
<li><code>{entry}</code></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Declarative Region (<code>bind:message</code>)</h2>
|
||||
<p class="desc">
|
||||
Region visible — útil cuando el anuncio también es UI. Cambia el texto y escucha con un
|
||||
lector de pantalla.
|
||||
</p>
|
||||
<div class="controls">
|
||||
<label class="full">
|
||||
<span>current status</span>
|
||||
<input type="text" bind:value={customRegionMessage} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Announce.Region
|
||||
role="status"
|
||||
message={customRegionMessage}
|
||||
visuallyHidden={false}
|
||||
class="status-pill"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Global API (<code>createAnnouncer()</code>)</h2>
|
||||
<p class="desc">
|
||||
Sin Provider — monta regiones en <code>document.body</code> la primera vez. Úsalo desde
|
||||
módulos utilitarios o cuando no quieras cargar Provider en la raíz.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button type="button" onclick={() => fireGlobal('Global polite announcement', 'polite')}>
|
||||
Polite (global)
|
||||
</button>
|
||||
<button type="button" onclick={() => fireGlobal('Global assertive alert', 'assertive')}>
|
||||
Assertive (global)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
global.clear();
|
||||
globalLog = [];
|
||||
}}>Clear global</button
|
||||
>
|
||||
</div>
|
||||
<dl class="state">
|
||||
<dt>global history</dt>
|
||||
<dd>
|
||||
{#if globalLog.length === 0}
|
||||
<code>—</code>
|
||||
{:else}
|
||||
<ul class="log">
|
||||
{#each globalLog as entry, i (entry + i)}
|
||||
<li><code>{entry}</code></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. Role variants</h2>
|
||||
<p class="desc">
|
||||
Cada <code>role</code> tiene semántica distinta. Abre el accessibility tree:
|
||||
</p>
|
||||
<div class="grid">
|
||||
<Announce.Region role="status" message="Idle" visuallyHidden={false} class="demo-region">
|
||||
</Announce.Region>
|
||||
<Announce.Region
|
||||
role="alert"
|
||||
message="Critical!"
|
||||
visuallyHidden={false}
|
||||
class="demo-region alert"
|
||||
/>
|
||||
<Announce.Region
|
||||
role="log"
|
||||
message="Turn 4: moved 3"
|
||||
visuallyHidden={false}
|
||||
class="demo-region log"
|
||||
/>
|
||||
<Announce.Region
|
||||
role="timer"
|
||||
message="02:30"
|
||||
visuallyHidden={false}
|
||||
class="demo-region timer"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
section {
|
||||
margin-block: 2rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
h1,
|
||||
h2 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.desc {
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
}
|
||||
code {
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 3px;
|
||||
background: #eee;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding-block: 0.5rem 0.75rem;
|
||||
}
|
||||
.controls label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.controls .full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.controls input,
|
||||
.controls select {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.actions button {
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.actions button:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
dl.state {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.2rem 0.8rem;
|
||||
margin-block: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fbfbfb;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.log {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.log li {
|
||||
margin: 0.1rem 0;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
:global(.status-pill) {
|
||||
display: inline-block;
|
||||
padding: 0.35rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
:global(.demo-region) {
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 4px;
|
||||
background: #f1f5f9;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
:global(.demo-region.alert) {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
:global(.demo-region.log) {
|
||||
background: #e0f2fe;
|
||||
color: #075985;
|
||||
}
|
||||
:global(.demo-region.timer) {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,240 @@
|
||||
<script lang="ts">
|
||||
import * as Avatar from '$soma/components/avatar';
|
||||
import type { AvatarLoadingStatus } from '$soma/components/avatar';
|
||||
|
||||
// Inline SVG data URLs — work offline, no external dependency.
|
||||
const SAMPLE_SRC =
|
||||
'data:image/svg+xml;utf8,' +
|
||||
encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="%230ea5e9"/><stop offset="1" stop-color="%238b5cf6"/></linearGradient></defs><rect width="96" height="96" fill="url(%23g)"/><circle cx="48" cy="40" r="16" fill="white" opacity=".9"/><path d="M16 96c0-18 14-32 32-32s32 14 32 32" fill="white" opacity=".9"/></svg>`
|
||||
);
|
||||
const BROKEN_SRC = 'data:image/png;base64,INVALID';
|
||||
|
||||
let src = $state<string>(SAMPLE_SRC);
|
||||
let delayMs = $state(0);
|
||||
let status = $state<AvatarLoadingStatus>('idle');
|
||||
let statusLog = $state<AvatarLoadingStatus[]>([]);
|
||||
|
||||
function resetSample() {
|
||||
src = SAMPLE_SRC;
|
||||
}
|
||||
function triggerError() {
|
||||
src = BROKEN_SRC;
|
||||
}
|
||||
function clearSrc() {
|
||||
src = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Avatar · Soma</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<h1>Avatar</h1>
|
||||
<p>
|
||||
Preload <code><img></code> en background; muestra fallback hasta que el estado sea
|
||||
<code>loaded</code>. <code>delayMs</code> evita flash en cargas rápidas.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>1. Interactive testbed</h2>
|
||||
<div class="controls">
|
||||
<label>
|
||||
<span>src</span>
|
||||
<input type="text" bind:value={src} placeholder="Image URL…" />
|
||||
</label>
|
||||
<label>
|
||||
<span>delayMs ({delayMs})</span>
|
||||
<input type="range" min="0" max="1500" step="50" bind:value={delayMs} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" onclick={resetSample}>Reset to sample</button>
|
||||
<button type="button" onclick={triggerError}>Force error</button>
|
||||
<button type="button" onclick={clearSrc}>Clear src</button>
|
||||
</div>
|
||||
|
||||
<Avatar.Provider
|
||||
{delayMs}
|
||||
onStatusChange={(s) => {
|
||||
status = s;
|
||||
statusLog = [...statusLog, s].slice(-8);
|
||||
}}
|
||||
class="avatar"
|
||||
>
|
||||
<Avatar.Image {src} alt="Demo avatar" class="avatar-img" />
|
||||
<Avatar.Fallback class="avatar-fallback">JP</Avatar.Fallback>
|
||||
</Avatar.Provider>
|
||||
|
||||
<dl class="state">
|
||||
<dt>status</dt>
|
||||
<dd><code data-status={status}>{status}</code></dd>
|
||||
<dt>transitions</dt>
|
||||
<dd><code>{statusLog.join(' → ') || '—'}</code></dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Grid (variations)</h2>
|
||||
<div class="grid">
|
||||
<Avatar.Provider class="avatar">
|
||||
<Avatar.Image src={SAMPLE_SRC} alt="Ok" class="avatar-img" />
|
||||
<Avatar.Fallback class="avatar-fallback">JP</Avatar.Fallback>
|
||||
</Avatar.Provider>
|
||||
|
||||
<Avatar.Provider class="avatar">
|
||||
<Avatar.Image src={BROKEN_SRC} alt="Broken" class="avatar-img" />
|
||||
<Avatar.Fallback class="avatar-fallback">MX</Avatar.Fallback>
|
||||
</Avatar.Provider>
|
||||
|
||||
<Avatar.Provider class="avatar">
|
||||
<Avatar.Image src="" alt="Empty" class="avatar-img" />
|
||||
<Avatar.Fallback class="avatar-fallback">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true">
|
||||
<path
|
||||
d="M12 12c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5Zm0 2c-3.31 0-10 1.67-10 5v3h20v-3c0-3.33-6.69-5-10-5Z"
|
||||
/>
|
||||
</svg>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Provider>
|
||||
|
||||
<Avatar.Provider delayMs={800} class="avatar">
|
||||
<Avatar.Image
|
||||
src={SAMPLE_SRC + '&bust=' + Math.random()}
|
||||
alt="Delayed"
|
||||
class="avatar-img"
|
||||
/>
|
||||
<Avatar.Fallback class="avatar-fallback">800</Avatar.Fallback>
|
||||
</Avatar.Provider>
|
||||
</div>
|
||||
<p class="desc">
|
||||
De izquierda a derecha: carga OK, error, <code>src=""</code> (fuerza fallback), y
|
||||
<code>delayMs=800</code>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
section {
|
||||
margin-block: 2rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
h1,
|
||||
h2 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.desc {
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
}
|
||||
code {
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 3px;
|
||||
background: #eee;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
code[data-status='loaded'] {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
code[data-status='loading'] {
|
||||
background: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
code[data-status='error'] {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
padding-block: 0.5rem 0.75rem;
|
||||
}
|
||||
.controls label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.controls input[type='text'] {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.actions button {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.actions button:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
dl.state {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.2rem 0.8rem;
|
||||
margin-block: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fbfbfb;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.grid {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding-block: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
:global(.avatar) {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
:global(.avatar-img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
:global(.avatar-fallback) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,198 @@
|
||||
<script lang="ts">
|
||||
import * as Clipboard from '$soma/components/clipboard';
|
||||
|
||||
let value = $state('https://soma.dev/clipboard-demo');
|
||||
let timeout = $state(2000);
|
||||
let copyCount = $state(0);
|
||||
let lastError = $state<string | null>(null);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Clipboard · Soma</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<h1>Clipboard</h1>
|
||||
<p>
|
||||
Wrapper alrededor de <code>navigator.clipboard.writeText</code>. Flag
|
||||
<code>copied</code> se auto-resetea en <code>timeout</code> ms. <code>data-copied</code> lo
|
||||
expone en root/trigger/indicator.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>1. Interactive testbed</h2>
|
||||
<div class="controls">
|
||||
<label class="full">
|
||||
<span>value</span>
|
||||
<input type="text" bind:value />
|
||||
</label>
|
||||
<label>
|
||||
<span>timeout ({timeout}ms)</span>
|
||||
<input type="range" min="500" max="5000" step="100" bind:value={timeout} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Clipboard.Provider
|
||||
{value}
|
||||
{timeout}
|
||||
onCopy={() => (copyCount += 1)}
|
||||
onError={(err) => (lastError = String(err))}
|
||||
class="cb"
|
||||
>
|
||||
{#snippet children({ copied })}
|
||||
<Clipboard.Trigger class="cb-trigger">
|
||||
{copied ? '✓ Copied!' : 'Copy'}
|
||||
</Clipboard.Trigger>
|
||||
<Clipboard.Indicator class="cb-indicator">Copied to clipboard</Clipboard.Indicator>
|
||||
{/snippet}
|
||||
</Clipboard.Provider>
|
||||
|
||||
<dl class="state">
|
||||
<dt>copies fired</dt>
|
||||
<dd><code>{copyCount}x</code></dd>
|
||||
<dt>last error</dt>
|
||||
<dd><code>{lastError ?? '—'}</code></dd>
|
||||
</dl>
|
||||
|
||||
<p class="desc">
|
||||
<kbd>Enter</kbd> / <kbd>Space</kbd> sobre el botón copia.
|
||||
<code>data-copied</code> persiste durante <code>{timeout}ms</code>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Minimal (children as snippet)</h2>
|
||||
<p class="desc">El mismo valor, pero sin Indicator separado — consumer lee <code>copied</code> del snippet.</p>
|
||||
<Clipboard.Provider value="npm install soma" class="cb">
|
||||
{#snippet children({ copied, copy })}
|
||||
<code class="inline-code">npm install soma</code>
|
||||
<button type="button" onclick={() => copy()} class="cb-trigger">
|
||||
{copied ? '✓' : 'Copy'}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Clipboard.Provider>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
section {
|
||||
margin-block: 2rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
h1,
|
||||
h2 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.desc {
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
}
|
||||
code {
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 3px;
|
||||
background: #eee;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
kbd {
|
||||
padding: 0 0.3rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
font: inherit;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding-block: 0.5rem 1rem;
|
||||
}
|
||||
.controls label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.controls .full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.controls input {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
dl.state {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.2rem 0.8rem;
|
||||
margin-block: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fbfbfb;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
:global(.cb) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.inline-code {
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
:global(.cb-trigger) {
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
transition: background 150ms;
|
||||
}
|
||||
:global(.cb-trigger:hover) {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
:global(.cb-trigger[data-copied]) {
|
||||
background: #dcfce7;
|
||||
border-color: #16a34a;
|
||||
color: #166534;
|
||||
}
|
||||
:global(.cb-indicator) {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
font-size: 0.8rem;
|
||||
animation: cb-pop 200ms ease-out;
|
||||
}
|
||||
@keyframes cb-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,300 @@
|
||||
<script lang="ts">
|
||||
import * as DragDrop from '$soma/components/drag-drop';
|
||||
|
||||
type Task = { id: string; title: string; kind: 'task' | 'doc' };
|
||||
type Column = { id: string; title: string; accepts: Array<'task' | 'doc'>; tasks: Task[] };
|
||||
|
||||
let columns = $state<Column[]>([
|
||||
{
|
||||
id: 'todo',
|
||||
title: 'To Do',
|
||||
accepts: ['task'],
|
||||
tasks: [
|
||||
{ id: 't1', title: 'Design mockups', kind: 'task' },
|
||||
{ id: 't2', title: 'Review PR #42', kind: 'task' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'doing',
|
||||
title: 'In Progress',
|
||||
accepts: ['task'],
|
||||
tasks: [{ id: 't3', title: 'Implement DnD', kind: 'task' }]
|
||||
},
|
||||
{
|
||||
id: 'done',
|
||||
title: 'Done',
|
||||
accepts: ['task', 'doc'],
|
||||
tasks: [
|
||||
{ id: 't4', title: 'Spec signed', kind: 'task' },
|
||||
{ id: 'd1', title: 'ADR: treegrid', kind: 'doc' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'trash',
|
||||
title: 'Trash (accepts nothing)',
|
||||
accepts: [],
|
||||
tasks: []
|
||||
}
|
||||
]);
|
||||
|
||||
let dropLog = $state<string[]>([]);
|
||||
|
||||
function handleDrop(e: import('$soma/components/drag-drop').DropEvent) {
|
||||
// Find source column + task.
|
||||
let srcCol: Column | undefined;
|
||||
let task: Task | undefined;
|
||||
for (const col of columns) {
|
||||
const found = col.tasks.find((t) => t.id === e.value);
|
||||
if (found) {
|
||||
srcCol = col;
|
||||
task = found;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!srcCol || !task) return;
|
||||
// Find target column.
|
||||
const targetCol = columns.find((c) => c.title === e.targetLabel || c.id === e.targetLabel);
|
||||
if (!targetCol || targetCol === srcCol) return;
|
||||
srcCol.tasks = srcCol.tasks.filter((t) => t.id !== task!.id);
|
||||
targetCol.tasks = [...targetCol.tasks, task];
|
||||
columns = [...columns];
|
||||
dropLog = [`${task.title} → ${targetCol.title}`, ...dropLog].slice(0, 6);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>DragDrop · Soma</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<h1>DragDrop</h1>
|
||||
<p>
|
||||
Sistema headless de drag & drop — pointer + teclado + screen reader. <kbd>Space</kbd> /
|
||||
<kbd>Enter</kbd> sobre un <code>Draggable</code> inicia el drag por teclado, <kbd>Tab</kbd>
|
||||
/ flechas navegan entre <code>Droppable</code>s, <kbd>Space</kbd>/<kbd>Enter</kbd> suelta,
|
||||
<kbd>Esc</kbd> cancela. Los anuncios de screen reader se emiten vía el live region global
|
||||
de Announce.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>1. Kanban — drag tasks between columns</h2>
|
||||
<p class="desc">
|
||||
El "Trash" column rechaza todo (<code>accept=() => false</code>) — no se resalta al
|
||||
hacer drag. La columna "Done" acepta <code>task</code> y <code>doc</code>; las demás solo
|
||||
<code>task</code>.
|
||||
</p>
|
||||
|
||||
<DragDrop.Provider onDrop={handleDrop} class="board">
|
||||
{#snippet children()}
|
||||
{#each columns as col (col.id)}
|
||||
<DragDrop.Droppable
|
||||
accept={(data) => col.accepts.includes((data as { kind: 'task' | 'doc' }).kind)}
|
||||
textValue={col.title}
|
||||
class="col"
|
||||
>
|
||||
<header class="col-head">
|
||||
<strong>{col.title}</strong>
|
||||
<span class="col-count">{col.tasks.length}</span>
|
||||
</header>
|
||||
<div class="col-body">
|
||||
{#each col.tasks as task (task.id)}
|
||||
<DragDrop.Draggable
|
||||
value={task.id}
|
||||
data={{ kind: task.kind }}
|
||||
textValue={task.title}
|
||||
class="task {task.kind}"
|
||||
>
|
||||
<span class="task-kind">{task.kind === 'doc' ? '📄' : '•'}</span>
|
||||
{task.title}
|
||||
</DragDrop.Draggable>
|
||||
{/each}
|
||||
{#if col.tasks.length === 0}
|
||||
<span class="col-empty">—</span>
|
||||
{/if}
|
||||
</div>
|
||||
</DragDrop.Droppable>
|
||||
{/each}
|
||||
|
||||
<DragDrop.Preview class="preview">
|
||||
{#snippet children({ active })}
|
||||
<div class="preview-chip">{active.label}</div>
|
||||
{/snippet}
|
||||
</DragDrop.Preview>
|
||||
{/snippet}
|
||||
</DragDrop.Provider>
|
||||
|
||||
<dl class="state">
|
||||
<dt>recent drops</dt>
|
||||
<dd>
|
||||
{#if dropLog.length === 0}
|
||||
<code>—</code>
|
||||
{:else}
|
||||
<ul class="log">
|
||||
{#each dropLog as entry, i (entry + i)}
|
||||
<li><code>{entry}</code></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<p class="desc">
|
||||
Con el teclado: enfoca cualquier task (<kbd>Tab</kbd>), pulsa <kbd>Space</kbd> para
|
||||
agarrarla, flechas/<kbd>Tab</kbd> para moverte entre columnas que aceptan,
|
||||
<kbd>Space</kbd>/<kbd>Enter</kbd> para soltar. El <code>Trash</code> es saltado durante
|
||||
navegación por teclado.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 56rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
section {
|
||||
margin-block: 2rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
h1,
|
||||
h2 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.desc {
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
}
|
||||
code {
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 3px;
|
||||
background: #eee;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
kbd {
|
||||
padding: 0 0.3rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
font: inherit;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
dl.state {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.2rem 0.8rem;
|
||||
margin-block: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fbfbfb;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.log {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.log li {
|
||||
margin: 0.1rem 0;
|
||||
}
|
||||
|
||||
:global(.board) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0 1rem;
|
||||
}
|
||||
:global(.col) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
min-height: 200px;
|
||||
outline: none;
|
||||
}
|
||||
:global(.col[data-accepting]) {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
:global(.col[data-accepting][data-dragover]) {
|
||||
background: #dcfce7;
|
||||
border-color: #16a34a;
|
||||
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.3);
|
||||
}
|
||||
:global(.col[data-disabled]) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.col-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: #475569;
|
||||
}
|
||||
.col-count {
|
||||
background: #e2e8f0;
|
||||
border-radius: 999px;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.col-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.col-empty {
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
:global(.task) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 0.85rem;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
}
|
||||
:global(.task:focus) {
|
||||
border-color: #0070f3;
|
||||
box-shadow: 0 0 0 2px rgba(0, 112, 243, 0.2);
|
||||
}
|
||||
:global(.task[data-dragging]) {
|
||||
opacity: 0.4;
|
||||
cursor: grabbing;
|
||||
}
|
||||
:global(.task.doc) {
|
||||
background: #fef9c3;
|
||||
}
|
||||
.task-kind {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
:global(.preview) {
|
||||
pointer-events: none;
|
||||
}
|
||||
.preview-chip {
|
||||
padding: 0.4rem 0.7rem;
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,294 @@
|
||||
<script lang="ts">
|
||||
import * as Feed from '$soma/components/feed';
|
||||
|
||||
type Post = {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
author: string;
|
||||
replies?: { id: number; author: string; body: string }[];
|
||||
};
|
||||
|
||||
let items = $state<Post[]>(
|
||||
Array.from({ length: 6 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
title: `Post #${i + 1}`,
|
||||
body: `This is the body of post ${i + 1}. A feed article can have any content.`,
|
||||
author: ['Ada', 'Alan', 'Grace'][i % 3],
|
||||
replies:
|
||||
i === 0
|
||||
? [
|
||||
{ id: 101, author: 'Alan', body: 'Great point on this one.' },
|
||||
{ id: 102, author: 'Grace', body: 'Agreed, let me expand:' }
|
||||
]
|
||||
: undefined
|
||||
}))
|
||||
);
|
||||
let busy = $state(false);
|
||||
let totalItems = $state<number | undefined>(undefined);
|
||||
let loadCount = $state(0);
|
||||
let autoLoad = $state(false);
|
||||
let sentinelHits = $state(0);
|
||||
|
||||
async function loadMore() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
loadCount += 1;
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
const start = items.length;
|
||||
items = [
|
||||
...items,
|
||||
...Array.from({ length: 3 }, (_, i) => ({
|
||||
id: start + i + 1,
|
||||
title: `Post #${start + i + 1}`,
|
||||
body: `Loaded by onLoadMore (#${loadCount}).`,
|
||||
author: ['Linus', 'Dennis'][i % 2]
|
||||
}))
|
||||
];
|
||||
busy = false;
|
||||
}
|
||||
|
||||
function handleSentinel() {
|
||||
sentinelHits += 1;
|
||||
if (autoLoad) loadMore();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Feed · Soma</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<h1>Feed</h1>
|
||||
<p>
|
||||
Stream infinito con semántica <code>role="feed"</code>. <kbd>PageDown</kbd> avanza al siguiente
|
||||
artículo, <kbd>PageUp</kbd> retrocede. En el último artículo, <kbd>PageDown</kbd> dispara
|
||||
<code>onLoadMore</code>. Cada Article tiene <code>aria-posinset</code> / <code>aria-setsize</code>.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>1. Interactive testbed</h2>
|
||||
<div class="controls">
|
||||
<label>
|
||||
<span>totalItems (optional)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={totalItems}
|
||||
placeholder="unknown"
|
||||
min="0"
|
||||
/>
|
||||
</label>
|
||||
<label><input type="checkbox" bind:checked={busy} /><span>busy</span></label>
|
||||
<label
|
||||
><input type="checkbox" bind:checked={autoLoad} /><span>auto-load on scroll</span></label
|
||||
>
|
||||
<button type="button" onclick={loadMore}>Load 3 more</button>
|
||||
</div>
|
||||
|
||||
<Feed.Provider
|
||||
{totalItems}
|
||||
{busy}
|
||||
onLoadMore={loadMore}
|
||||
aria-label="Team activity"
|
||||
class="feed"
|
||||
>
|
||||
{#each items as post (post.id)}
|
||||
<Feed.Article class="feed-article">
|
||||
{#snippet children({ index, total })}
|
||||
<Feed.ArticleTitle class="feed-title">
|
||||
{post.title}
|
||||
</Feed.ArticleTitle>
|
||||
<p class="feed-meta">
|
||||
by {post.author} · <code>{index + 1} / {total ?? 'unknown'}</code>
|
||||
</p>
|
||||
<Feed.ArticleDescription class="feed-body">
|
||||
{post.body}
|
||||
</Feed.ArticleDescription>
|
||||
|
||||
{#if post.replies && post.replies.length > 0}
|
||||
<Feed.Thread class="feed-thread">
|
||||
{#each post.replies as reply (reply.id)}
|
||||
<Feed.Article class="feed-article nested">
|
||||
<Feed.ArticleTitle class="feed-title">
|
||||
Reply from {reply.author}
|
||||
</Feed.ArticleTitle>
|
||||
<Feed.ArticleDescription class="feed-body">
|
||||
{reply.body}
|
||||
</Feed.ArticleDescription>
|
||||
</Feed.Article>
|
||||
{/each}
|
||||
</Feed.Thread>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Feed.Article>
|
||||
{/each}
|
||||
{#if busy}
|
||||
<div class="feed-loading" role="presentation">Loading more…</div>
|
||||
{/if}
|
||||
<Feed.Sentinel
|
||||
onIntersect={handleSentinel}
|
||||
rootMargin="50px"
|
||||
disabled={busy}
|
||||
class="feed-sentinel"
|
||||
/>
|
||||
</Feed.Provider>
|
||||
|
||||
<dl class="state">
|
||||
<dt>articles</dt>
|
||||
<dd><code>{items.length}</code></dd>
|
||||
<dt>load count</dt>
|
||||
<dd><code>{loadCount}</code></dd>
|
||||
<dt>sentinel hits</dt>
|
||||
<dd><code>{sentinelHits}</code></dd>
|
||||
<dt>busy</dt>
|
||||
<dd><code>{busy}</code></dd>
|
||||
</dl>
|
||||
|
||||
<p class="desc">
|
||||
Enfoca el primer artículo (<kbd>Tab</kbd>) y usa <kbd>PageUp</kbd>/<kbd>PageDown</kbd>
|
||||
para navegar. <kbd>Ctrl+Home</kbd>/<kbd>Ctrl+End</kbd> saltan al primero/último. El
|
||||
primer post tiene un <code>Feed.Thread</code> anidado — sus artículos reciben
|
||||
<code>aria-level={'{'}h+1{'}'}</code> automáticamente. El <code>Feed.Sentinel</code>
|
||||
dispara <code>onLoadMore</code> cuando aparece en el viewport (activa
|
||||
<em>auto-load</em> para ver).
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
section {
|
||||
margin-block: 2rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
h1,
|
||||
h2 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.desc {
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
}
|
||||
code {
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 3px;
|
||||
background: #eee;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
kbd {
|
||||
padding: 0 0.3rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
font: inherit;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding-block: 0.5rem 1rem;
|
||||
}
|
||||
.controls label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.controls input {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.controls button {
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
dl.state {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.2rem 0.8rem;
|
||||
margin-block: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fbfbfb;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
:global(.feed) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
:global(.feed-article) {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
}
|
||||
:global(.feed-article:focus) {
|
||||
border-color: #0070f3;
|
||||
box-shadow: 0 0 0 2px rgba(0, 112, 243, 0.15);
|
||||
}
|
||||
:global(.feed-title) {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.feed-meta {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
:global(.feed-body) {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: #334155;
|
||||
}
|
||||
.feed-loading {
|
||||
padding: 0.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
:global(.feed-article.nested) {
|
||||
margin-left: 1.5rem;
|
||||
background: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
border-left: 3px solid #94a3b8;
|
||||
}
|
||||
:global(.feed-thread) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
:global(.feed-sentinel) {
|
||||
height: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,332 @@
|
||||
<script lang="ts">
|
||||
import * as GridList from '$soma/components/grid-list';
|
||||
import * as Field from '$soma/components/field';
|
||||
|
||||
type Row = { value: string; name: string; role: string; email: string };
|
||||
|
||||
const rows: Row[] = [
|
||||
{ value: 'r1', name: 'Ada Lovelace', role: 'Admin', email: 'ada@example.com' },
|
||||
{ value: 'r2', name: 'Alan Turing', role: 'Owner', email: 'alan@example.com' },
|
||||
{ value: 'r3', name: 'Grace Hopper', role: 'Member', email: 'grace@example.com' },
|
||||
{ value: 'r4', name: 'Dennis Ritchie', role: 'Member', email: 'dennis@example.com' },
|
||||
{ value: 'r5', name: 'Linus Torvalds', role: 'Admin', email: 'linus@example.com' }
|
||||
];
|
||||
|
||||
let value = $state<string[]>([]);
|
||||
let selectionMode = $state<'none' | 'single' | 'multiple'>('multiple');
|
||||
let loop = $state(false);
|
||||
let typeahead = $state(true);
|
||||
let disabled = $state(false);
|
||||
let readonly = $state(false);
|
||||
let invalid = $state(false);
|
||||
let required = $state(false);
|
||||
let disableRow = $state('r4');
|
||||
let lastAction = $state<string | null>(null);
|
||||
|
||||
let fieldValue = $state<string[]>(['r1']);
|
||||
let fieldDisabled = $state(false);
|
||||
let fieldInvalid = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>GridList · Soma</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<h1>GridList</h1>
|
||||
<p>
|
||||
Lista seleccionable con <code>role="grid"</code>. Arriba/Abajo navega filas,
|
||||
<kbd>Space</kbd> selecciona, <kbd>Shift+Click</kbd> rango,
|
||||
<kbd>Ctrl+Click</kbd> toggle. Typeahead activo.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>1. Interactive testbed</h2>
|
||||
<div class="controls">
|
||||
<label>
|
||||
<span>selectionMode</span>
|
||||
<select bind:value={selectionMode}>
|
||||
<option value="none">none</option>
|
||||
<option value="single">single</option>
|
||||
<option value="multiple">multiple</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><input type="checkbox" bind:checked={loop} /><span>loop</span></label>
|
||||
<label><input type="checkbox" bind:checked={typeahead} /><span>typeahead</span></label>
|
||||
<label><input type="checkbox" bind:checked={disabled} /><span>disabled</span></label>
|
||||
<label><input type="checkbox" bind:checked={readonly} /><span>readonly</span></label>
|
||||
<label><input type="checkbox" bind:checked={required} /><span>required</span></label>
|
||||
<label><input type="checkbox" bind:checked={invalid} /><span>invalid</span></label>
|
||||
<label>
|
||||
<span>disable row value</span>
|
||||
<input type="text" bind:value={disableRow} placeholder="e.g. r4" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<GridList.Provider
|
||||
bind:value
|
||||
{selectionMode}
|
||||
{loop}
|
||||
{typeahead}
|
||||
{disabled}
|
||||
{readonly}
|
||||
{required}
|
||||
{invalid}
|
||||
aria-label="Users"
|
||||
class="gl"
|
||||
>
|
||||
<div class="gl-header" role="presentation">
|
||||
<span>Sel</span>
|
||||
<span>Name</span>
|
||||
<span>Role</span>
|
||||
<span>Email</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{#each rows as row (row.value)}
|
||||
<GridList.Row
|
||||
value={row.value}
|
||||
disabled={row.value === disableRow}
|
||||
textValue={row.name}
|
||||
class="gl-row"
|
||||
>
|
||||
<GridList.Cell class="gl-cell sel">
|
||||
<GridList.SelectionCheckbox class="gl-checkbox" />
|
||||
</GridList.Cell>
|
||||
<GridList.Cell class="gl-cell">{row.name}</GridList.Cell>
|
||||
<GridList.Cell class="gl-cell">{row.role}</GridList.Cell>
|
||||
<GridList.Cell class="gl-cell">{row.email}</GridList.Cell>
|
||||
<GridList.Cell class="gl-cell actions">
|
||||
<button
|
||||
type="button"
|
||||
class="gl-action"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
lastAction = `edit ${row.value}`;
|
||||
}}>Edit</button
|
||||
>
|
||||
<a
|
||||
href="#{row.value}"
|
||||
class="gl-action"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
lastAction = `open ${row.value}`;
|
||||
}}>Open</a
|
||||
>
|
||||
</GridList.Cell>
|
||||
</GridList.Row>
|
||||
{/each}
|
||||
</GridList.Provider>
|
||||
|
||||
<dl class="state">
|
||||
<dt>value</dt>
|
||||
<dd><code>{JSON.stringify(value)}</code></dd>
|
||||
<dt>count</dt>
|
||||
<dd><code>{value.length}</code></dd>
|
||||
<dt>last cell action</dt>
|
||||
<dd><code>{lastAction ?? '—'}</code></dd>
|
||||
</dl>
|
||||
|
||||
<p class="desc">
|
||||
Cell 2D nav: enfoca una fila, pulsa <kbd>ArrowRight</kbd> para entrar a los controles de
|
||||
la fila, navega con <kbd>Arrow</kbd> entre Edit/Open/checkbox, <kbd>ArrowLeft</kbd>
|
||||
desde el primer control vuelve a la fila.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Field integration</h2>
|
||||
<Field.Provider disabled={fieldDisabled} invalid={fieldInvalid}>
|
||||
<Field.Label>Team members</Field.Label>
|
||||
<Field.Control>
|
||||
<GridList.Provider bind:value={fieldValue} selectionMode="multiple" class="gl">
|
||||
{#each rows.slice(0, 3) as row (row.value)}
|
||||
<GridList.Row value={row.value} textValue={row.name} class="gl-row">
|
||||
<GridList.Cell class="gl-cell sel">
|
||||
<GridList.SelectionCheckbox class="gl-checkbox" />
|
||||
</GridList.Cell>
|
||||
<GridList.Cell class="gl-cell">{row.name}</GridList.Cell>
|
||||
<GridList.Cell class="gl-cell">{row.email}</GridList.Cell>
|
||||
</GridList.Row>
|
||||
{/each}
|
||||
</GridList.Provider>
|
||||
</Field.Control>
|
||||
<Field.HelperText>Selecciona uno o más miembros.</Field.HelperText>
|
||||
</Field.Provider>
|
||||
<div class="controls">
|
||||
<label
|
||||
><input type="checkbox" bind:checked={fieldDisabled} /><span>Field disabled</span></label
|
||||
>
|
||||
<label
|
||||
><input type="checkbox" bind:checked={fieldInvalid} /><span>Field invalid</span></label
|
||||
>
|
||||
</div>
|
||||
<p class="result">Field value: <code>{JSON.stringify(fieldValue)}</code></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
section {
|
||||
margin-block: 2rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
h1,
|
||||
h2 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
code {
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 3px;
|
||||
background: #eee;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
kbd {
|
||||
padding: 0 0.3rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
font: inherit;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding-block: 0.5rem 1rem;
|
||||
}
|
||||
.controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.controls label > select,
|
||||
.controls label > input[type='text'] {
|
||||
padding: 0.2rem 0.3rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
dl.state {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.2rem 0.8rem;
|
||||
margin-block: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fbfbfb;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.result {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
:global(.gl) {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.gl-header {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 1fr 1.5fr 120px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: #f1f5f9;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
:global(.gl-row) {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 1fr 1.5fr 120px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
:global(.gl-row:last-child) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
:global(.gl-row[data-highlighted]) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
:global(.gl-row:focus) {
|
||||
background: #e0f2fe;
|
||||
}
|
||||
:global(.gl-row[data-state='selected']) {
|
||||
background: #dbeafe;
|
||||
}
|
||||
:global(.gl-row[data-state='selected'][data-highlighted]) {
|
||||
background: #bfdbfe;
|
||||
}
|
||||
:global(.gl-row[data-disabled]) {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
:global(.gl-cell) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
:global(.gl-cell.sel) {
|
||||
justify-content: center;
|
||||
}
|
||||
:global(.gl-checkbox) {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border: 1px solid #94a3b8;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
:global(.gl-checkbox[data-state='checked']) {
|
||||
background: #0070f3;
|
||||
border-color: #0070f3;
|
||||
}
|
||||
:global(.gl-checkbox[data-state='checked'])::after {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border-right: 2px solid #fff;
|
||||
border-bottom: 2px solid #fff;
|
||||
transform: rotate(45deg) translate(-1px, -1px);
|
||||
}
|
||||
:global(.gl-cell.actions) {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
:global(.gl-action) {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
:global(.gl-action:hover) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
:global(.gl-action:focus) {
|
||||
outline: 2px solid #0070f3;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
import * as Meter from '$soma/components/meter';
|
||||
|
||||
let value = $state(72);
|
||||
let min = $state(0);
|
||||
let max = $state(100);
|
||||
let low = $state(25);
|
||||
let high = $state(75);
|
||||
let optimum = $state(50);
|
||||
let valueText = $state('');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Meter · Soma</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<h1>Meter</h1>
|
||||
<p>
|
||||
<code>role="meter"</code> — valor estático dentro de un rango conocido (disco, batería, score).
|
||||
<code>low</code> / <code>high</code> / <code>optimum</code> definen zonas; el provider emite
|
||||
<code>data-state</code> (<code>below | optimum | above</code>) y
|
||||
<code>--soma-meter-value-pct</code>.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>1. Interactive testbed</h2>
|
||||
<div class="controls">
|
||||
<label>
|
||||
<span>value ({value})</span>
|
||||
<input type="range" {min} {max} bind:value />
|
||||
</label>
|
||||
<label>
|
||||
<span>min</span><input type="number" bind:value={min} />
|
||||
</label>
|
||||
<label>
|
||||
<span>max</span><input type="number" bind:value={max} />
|
||||
</label>
|
||||
<label>
|
||||
<span>low ({low})</span>
|
||||
<input type="range" {min} {max} bind:value={low} />
|
||||
</label>
|
||||
<label>
|
||||
<span>high ({high})</span>
|
||||
<input type="range" {min} {max} bind:value={high} />
|
||||
</label>
|
||||
<label>
|
||||
<span>optimum ({optimum})</span>
|
||||
<input type="range" {min} {max} bind:value={optimum} />
|
||||
</label>
|
||||
<label class="full">
|
||||
<span>valueText override</span>
|
||||
<input type="text" bind:value={valueText} placeholder="72% full" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Meter.Provider
|
||||
{value}
|
||||
{min}
|
||||
{max}
|
||||
{low}
|
||||
{high}
|
||||
{optimum}
|
||||
valueText={valueText || undefined}
|
||||
aria-label="Disk usage"
|
||||
class="meter"
|
||||
>
|
||||
<Meter.Indicator class="meter-indicator" />
|
||||
</Meter.Provider>
|
||||
|
||||
<dl class="state">
|
||||
<dt>state</dt>
|
||||
<dd><code>{value < low ? 'below' : value > high ? 'above' : 'optimum'}</code></dd>
|
||||
<dt>aria-valuetext</dt>
|
||||
<dd><code>{valueText || `${value} / ${max}`}</code></dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Examples</h2>
|
||||
<div class="examples">
|
||||
<div>
|
||||
<strong>Battery — 15%</strong>
|
||||
<Meter.Provider value={15} low={20} high={80} aria-label="Battery" class="meter">
|
||||
<Meter.Indicator class="meter-indicator" />
|
||||
</Meter.Provider>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Score — 62/100</strong>
|
||||
<Meter.Provider value={62} low={40} high={75} aria-label="Score" class="meter">
|
||||
<Meter.Indicator class="meter-indicator" />
|
||||
</Meter.Provider>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Disk usage — 95%</strong>
|
||||
<Meter.Provider value={95} low={30} high={80} aria-label="Disk" class="meter">
|
||||
<Meter.Indicator class="meter-indicator" />
|
||||
</Meter.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
section {
|
||||
margin-block: 2rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
h1,
|
||||
h2 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
code {
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 3px;
|
||||
background: #eee;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding-block: 0.5rem 1rem;
|
||||
}
|
||||
.controls label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.controls .full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.controls input {
|
||||
padding: 0.25rem 0.3rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
dl.state {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.2rem 0.8rem;
|
||||
margin-block: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fbfbfb;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.examples {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.examples div {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
:global(.meter) {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
height: 14px;
|
||||
border-radius: 7px;
|
||||
background: #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
:global(.meter-indicator) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: calc(var(--soma-meter-value-pct, 0) * 1%);
|
||||
transition: width 150ms ease-out;
|
||||
}
|
||||
:global(.meter[data-state='optimum'] .meter-indicator) {
|
||||
background: #16a34a;
|
||||
}
|
||||
:global(.meter[data-state='below'] .meter-indicator) {
|
||||
background: #f59e0b;
|
||||
}
|
||||
:global(.meter[data-state='above'] .meter-indicator) {
|
||||
background: #dc2626;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,167 @@
|
||||
<script lang="ts">
|
||||
import * as Progress from '$soma/components/progress';
|
||||
|
||||
let value = $state<number | null>(60);
|
||||
let max = $state(100);
|
||||
let min = $state(0);
|
||||
let valueText = $state('');
|
||||
let indeterminate = $state(false);
|
||||
|
||||
let ticking = $state(false);
|
||||
$effect(() => {
|
||||
if (!ticking) return;
|
||||
const id = setInterval(() => {
|
||||
if (value === null) value = 0;
|
||||
value = Math.min(max, value + 5);
|
||||
if (value >= max) ticking = false;
|
||||
}, 200);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Progress · Soma</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<h1>Progress</h1>
|
||||
<p>
|
||||
<code>role="progressbar"</code> con aria-valuenow / min / max. El Indicator se escala vía
|
||||
<code>--soma-progress-value-pct</code>. <code>value=null</code> → estado indeterminado.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>1. Interactive testbed</h2>
|
||||
<div class="controls">
|
||||
<label>
|
||||
<span>value</span>
|
||||
<input type="range" min={min} max={max} bind:value disabled={indeterminate} />
|
||||
<code>{indeterminate ? 'null' : value}</code>
|
||||
</label>
|
||||
<label>
|
||||
<span>max</span><input type="number" bind:value={max} />
|
||||
</label>
|
||||
<label>
|
||||
<span>min</span><input type="number" bind:value={min} />
|
||||
</label>
|
||||
<label>
|
||||
<span>valueText override</span>
|
||||
<input type="text" bind:value={valueText} placeholder="75 of 100 MB" />
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={indeterminate}
|
||||
onchange={() => (value = indeterminate ? null : 0)}
|
||||
/>
|
||||
<span>indeterminate</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={ticking} />
|
||||
<span>auto-tick (+5 / 200ms)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Progress.Provider
|
||||
{value}
|
||||
{max}
|
||||
{min}
|
||||
valueText={valueText || undefined}
|
||||
aria-label="Demo progress"
|
||||
class="progress"
|
||||
>
|
||||
<Progress.Indicator class="progress-indicator" />
|
||||
</Progress.Provider>
|
||||
|
||||
<dl class="state">
|
||||
<dt>value</dt>
|
||||
<dd><code>{value === null ? 'null' : value}</code></dd>
|
||||
<dt>aria-valuetext</dt>
|
||||
<dd><code>{valueText || (value === null ? '—' : `${value} / ${max}`)}</code></dd>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
section {
|
||||
margin-block: 2rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
h1,
|
||||
h2 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
code {
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 3px;
|
||||
background: #eee;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding-block: 0.5rem 1rem;
|
||||
}
|
||||
.controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
dl.state {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.2rem 0.8rem;
|
||||
margin-block: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fbfbfb;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
:global(.progress) {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
height: 14px;
|
||||
border-radius: 7px;
|
||||
background: #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
:global(.progress-indicator) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #0070f3;
|
||||
width: calc(var(--soma-progress-value-pct, 0) * 1%);
|
||||
transition: width 150ms ease-out;
|
||||
}
|
||||
:global(.progress[data-state='indeterminate'] .progress-indicator) {
|
||||
width: 40%;
|
||||
animation: progress-indeterminate 1s infinite linear;
|
||||
}
|
||||
:global(.progress[data-state='loaded'] .progress-indicator) {
|
||||
background: #16a34a;
|
||||
}
|
||||
@keyframes progress-indeterminate {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(250%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,222 @@
|
||||
<script lang="ts">
|
||||
import * as SearchField from '$soma/components/search-field';
|
||||
import * as Field from '$soma/components/field';
|
||||
|
||||
let value = $state('');
|
||||
let disabled = $state(false);
|
||||
let readonly = $state(false);
|
||||
let required = $state(false);
|
||||
let invalid = $state(false);
|
||||
let clearOnEscape = $state(true);
|
||||
let lastSubmit = $state<string | null>(null);
|
||||
let lastClear = $state(0);
|
||||
|
||||
let fieldValue = $state('apples');
|
||||
let fieldDisabled = $state(false);
|
||||
let fieldInvalid = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>SearchField · Soma</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<h1>SearchField</h1>
|
||||
<p>
|
||||
<code><input type="search" role="searchbox"></code> con clear button integrado,
|
||||
Escape-to-clear, Enter-to-submit, y OR-merge con <code>Field.Provider</code>.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>1. Interactive testbed</h2>
|
||||
<div class="controls">
|
||||
<label><input type="checkbox" bind:checked={disabled} /><span>disabled</span></label>
|
||||
<label><input type="checkbox" bind:checked={readonly} /><span>readonly</span></label>
|
||||
<label><input type="checkbox" bind:checked={required} /><span>required</span></label>
|
||||
<label><input type="checkbox" bind:checked={invalid} /><span>invalid</span></label>
|
||||
<label
|
||||
><input type="checkbox" bind:checked={clearOnEscape} /><span>clearOnEscape</span></label
|
||||
>
|
||||
</div>
|
||||
|
||||
<SearchField.Provider
|
||||
bind:value
|
||||
{disabled}
|
||||
{readonly}
|
||||
{required}
|
||||
{invalid}
|
||||
{clearOnEscape}
|
||||
placeholder="Search posts…"
|
||||
onSubmit={(v) => (lastSubmit = v)}
|
||||
onClear={() => (lastClear += 1)}
|
||||
class="sf"
|
||||
>
|
||||
<SearchField.Input class="sf-input" />
|
||||
<SearchField.ClearTrigger class="sf-clear" />
|
||||
</SearchField.Provider>
|
||||
|
||||
<dl class="state">
|
||||
<dt>value</dt>
|
||||
<dd><code>{JSON.stringify(value)}</code></dd>
|
||||
<dt>last submit</dt>
|
||||
<dd><code>{lastSubmit ?? '—'}</code></dd>
|
||||
<dt>clear fired</dt>
|
||||
<dd><code>{lastClear}x</code></dd>
|
||||
</dl>
|
||||
|
||||
<p class="desc">Presiona <kbd>Enter</kbd> para submit. <kbd>Escape</kbd> borra el valor.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Field integration</h2>
|
||||
<p class="desc">
|
||||
<code>disabled</code> / <code>invalid</code> OR-merge con Field. <code>Field.Label</code>
|
||||
toma precedencia sobre <code>aria-label</code> del SearchField.
|
||||
</p>
|
||||
<div class="controls">
|
||||
<label
|
||||
><input type="checkbox" bind:checked={fieldDisabled} /><span>Field disabled</span></label
|
||||
>
|
||||
<label
|
||||
><input type="checkbox" bind:checked={fieldInvalid} /><span>Field invalid</span></label
|
||||
>
|
||||
</div>
|
||||
|
||||
<Field.Provider disabled={fieldDisabled} invalid={fieldInvalid}>
|
||||
<Field.Label>Find a product</Field.Label>
|
||||
<Field.Control>
|
||||
<SearchField.Provider bind:value={fieldValue} placeholder="name, SKU, tag…" class="sf">
|
||||
<SearchField.Input class="sf-input" />
|
||||
<SearchField.ClearTrigger class="sf-clear" />
|
||||
</SearchField.Provider>
|
||||
</Field.Control>
|
||||
<Field.HelperText>Enter para buscar, Escape para limpiar.</Field.HelperText>
|
||||
{#if fieldInvalid}
|
||||
<Field.ErrorText>El término de búsqueda no es válido.</Field.ErrorText>
|
||||
{/if}
|
||||
</Field.Provider>
|
||||
|
||||
<p class="result">Field value: <code>{fieldValue || '—'}</code></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
section {
|
||||
margin-block: 2rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
h1,
|
||||
h2 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.desc {
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
}
|
||||
code {
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 3px;
|
||||
background: #eee;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
kbd {
|
||||
padding: 0 0.3rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
font: inherit;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding-block: 0.5rem 1rem;
|
||||
}
|
||||
.controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.result {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
dl.state {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.2rem 0.8rem;
|
||||
margin-block: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fbfbfb;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
:global(.sf) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
max-width: 360px;
|
||||
}
|
||||
:global(.sf[data-focused]) {
|
||||
border-color: #0070f3;
|
||||
box-shadow: 0 0 0 2px rgba(0, 112, 243, 0.15);
|
||||
}
|
||||
:global(.sf[data-invalid]) {
|
||||
border-color: #dc2626;
|
||||
}
|
||||
:global(.sf[data-disabled]) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
:global(.sf-input) {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
:global(.sf-input::-webkit-search-cancel-button),
|
||||
:global(.sf-input::-webkit-search-decoration) {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
:global(.sf-clear) {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
color: #64748b;
|
||||
font: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
:global(.sf-clear:hover:not([disabled])) {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
:global(.sf[data-empty] .sf-clear) {
|
||||
visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,285 @@
|
||||
<script lang="ts">
|
||||
import * as TreeGrid from '$soma/components/tree-grid';
|
||||
|
||||
type Node = {
|
||||
value: string;
|
||||
name: string;
|
||||
size: string;
|
||||
modified: string;
|
||||
children?: Node[];
|
||||
};
|
||||
|
||||
const tree: Node[] = [
|
||||
{
|
||||
value: 'src',
|
||||
name: 'src',
|
||||
size: '—',
|
||||
modified: '2026-04-18',
|
||||
children: [
|
||||
{
|
||||
value: 'src/lib',
|
||||
name: 'lib',
|
||||
size: '—',
|
||||
modified: '2026-04-17',
|
||||
children: [
|
||||
{ value: 'src/lib/util.ts', name: 'util.ts', size: '2.3 kb', modified: '2026-04-15' },
|
||||
{ value: 'src/lib/types.ts', name: 'types.ts', size: '850 b', modified: '2026-04-17' }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'src/routes',
|
||||
name: 'routes',
|
||||
size: '—',
|
||||
modified: '2026-04-18',
|
||||
children: [
|
||||
{ value: 'src/routes/+page.svelte', name: '+page.svelte', size: '4.1 kb', modified: '2026-04-18' },
|
||||
{ value: 'src/routes/+layout.svelte', name: '+layout.svelte', size: '1.2 kb', modified: '2026-04-12' }
|
||||
]
|
||||
},
|
||||
{ value: 'src/app.css', name: 'app.css', size: '512 b', modified: '2026-03-30' }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'docs',
|
||||
name: 'docs',
|
||||
size: '—',
|
||||
modified: '2026-04-10',
|
||||
children: [
|
||||
{ value: 'docs/readme.md', name: 'README.md', size: '3.7 kb', modified: '2026-04-10' }
|
||||
]
|
||||
},
|
||||
{ value: 'package.json', name: 'package.json', size: '1.1 kb', modified: '2026-04-18' }
|
||||
];
|
||||
|
||||
let expanded = $state<string[]>(['src', 'src/lib']);
|
||||
let value = $state<string[]>([]);
|
||||
let selectionMode = $state<'none' | 'single' | 'multiple'>('multiple');
|
||||
let loop = $state(false);
|
||||
let typeahead = $state(true);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>TreeGrid · Soma</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<h1>TreeGrid</h1>
|
||||
<p>
|
||||
<code>role="treegrid"</code> — tabla con filas jerárquicas. <kbd>ArrowDown</kbd>/<kbd>ArrowUp</kbd>
|
||||
entre filas, <kbd>ArrowRight</kbd> expande o entra al hijo, <kbd>ArrowLeft</kbd> colapsa o va al
|
||||
padre, <kbd>Space</kbd> selecciona, typeahead por nombre.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>1. Interactive testbed</h2>
|
||||
<div class="controls">
|
||||
<label>
|
||||
<span>selectionMode</span>
|
||||
<select bind:value={selectionMode}>
|
||||
<option value="none">none</option>
|
||||
<option value="single">single</option>
|
||||
<option value="multiple">multiple</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><input type="checkbox" bind:checked={loop} /><span>loop</span></label>
|
||||
<label><input type="checkbox" bind:checked={typeahead} /><span>typeahead</span></label>
|
||||
</div>
|
||||
|
||||
{#snippet renderNode(nodes: Node[])}
|
||||
{#each nodes as node (node.value)}
|
||||
<TreeGrid.Row
|
||||
value={node.value}
|
||||
textValue={node.name}
|
||||
hasChildren={!!node.children && node.children.length > 0}
|
||||
class="tg-row"
|
||||
>
|
||||
{#snippet children({ expanded: isExpanded, hasChildren, level })}
|
||||
<TreeGrid.Cell class="tg-cell name" style="padding-inline-start:{(level - 1) * 1.25}rem">
|
||||
{#if hasChildren}
|
||||
<TreeGrid.ExpandTrigger class="tg-expand">
|
||||
{isExpanded ? '▾' : '▸'}
|
||||
</TreeGrid.ExpandTrigger>
|
||||
{:else}
|
||||
<span class="tg-expand-spacer"></span>
|
||||
{/if}
|
||||
<span class="tg-name">{node.name}</span>
|
||||
</TreeGrid.Cell>
|
||||
<TreeGrid.Cell class="tg-cell">{node.size}</TreeGrid.Cell>
|
||||
<TreeGrid.Cell class="tg-cell">{node.modified}</TreeGrid.Cell>
|
||||
{#if hasChildren}
|
||||
<TreeGrid.RowChildren>
|
||||
{@render renderNode(node.children ?? [])}
|
||||
</TreeGrid.RowChildren>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</TreeGrid.Row>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<TreeGrid.Provider
|
||||
bind:expanded
|
||||
bind:value
|
||||
{selectionMode}
|
||||
{loop}
|
||||
{typeahead}
|
||||
aria-label="File system"
|
||||
class="tg"
|
||||
>
|
||||
<TreeGrid.Header class="tg-header">
|
||||
<TreeGrid.ColumnHeader class="tg-col">Name</TreeGrid.ColumnHeader>
|
||||
<TreeGrid.ColumnHeader class="tg-col">Size</TreeGrid.ColumnHeader>
|
||||
<TreeGrid.ColumnHeader class="tg-col">Modified</TreeGrid.ColumnHeader>
|
||||
</TreeGrid.Header>
|
||||
|
||||
{@render renderNode(tree)}
|
||||
</TreeGrid.Provider>
|
||||
|
||||
<dl class="state">
|
||||
<dt>expanded</dt>
|
||||
<dd><code>{JSON.stringify(expanded)}</code></dd>
|
||||
<dt>selected</dt>
|
||||
<dd><code>{JSON.stringify(value)}</code></dd>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
section {
|
||||
margin-block: 2rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
h1,
|
||||
h2 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
code {
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 3px;
|
||||
background: #eee;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
kbd {
|
||||
padding: 0 0.3rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
font: inherit;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
padding-block: 0.5rem 1rem;
|
||||
}
|
||||
.controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.controls select {
|
||||
padding: 0.2rem 0.3rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
dl.state {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.2rem 0.8rem;
|
||||
margin-block: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fbfbfb;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
:global(.tg) {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
:global(.tg-header) {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 100px 140px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: #f1f5f9;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
:global(.tg-col) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
:global(.tg-row) {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 100px 140px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
:global(.tg-row:last-child) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
:global(.tg-row[data-highlighted]) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
:global(.tg-row:focus) {
|
||||
background: #e0f2fe;
|
||||
}
|
||||
:global(.tg-row[data-state='selected']) {
|
||||
background: #dbeafe;
|
||||
}
|
||||
:global(.tg-row[data-disabled]) {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
:global(.tg-cell) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
color: #334155;
|
||||
}
|
||||
:global(.tg-cell.name) {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
:global(.tg-expand) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tg-expand-spacer {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
}
|
||||
.tg-name {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,11 @@
|
||||
.air-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--air-banner-gap, 1rem);
|
||||
padding: var(--air-banner-padding, 0.75rem 1rem);
|
||||
background: var(--air-banner-bg, transparent);
|
||||
color: var(--air-banner-color, inherit);
|
||||
border-bottom: var(--air-banner-border, none);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import './banner.css';
|
||||
import type { BannerProps } from './types';
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
children,
|
||||
...restProps
|
||||
}: BannerProps = $props();
|
||||
</script>
|
||||
|
||||
<!-- `<header>` gets implicit `role=banner` ONLY when it's a top-level child of
|
||||
the body. When nested inside main/article/section, the role is lost. The
|
||||
explicit role stabilises the landmark across layouts. -->
|
||||
<!-- svelte-ignore a11y_no_redundant_roles -->
|
||||
<header
|
||||
class={['air-banner', className].filter(Boolean).join(' ')}
|
||||
role="banner"
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</header>
|
||||
@ -0,0 +1,2 @@
|
||||
export { default } from './banner.svelte';
|
||||
export type { BannerProps } from './types';
|
||||
@ -0,0 +1,17 @@
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
/**
|
||||
* Landmark element that renders as `<header role="banner">`. Use once per
|
||||
* page for the site header. For internal section headers use a plain
|
||||
* `<header>` (which gets `role="banner"` only as a top-level child of
|
||||
* `<body>`, per HTML spec). Forcing `role="banner"` manually via this
|
||||
* component expresses the intent explicitly and works even when nested.
|
||||
*/
|
||||
export type BannerProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
|
||||
/** Accessible name. */
|
||||
'aria-label'?: string;
|
||||
/** External label id. */
|
||||
'aria-labelledby'?: string;
|
||||
children?: Snippet;
|
||||
};
|
||||
@ -0,0 +1,2 @@
|
||||
export { default } from './link.svelte';
|
||||
export type { LinkProps, LinkVariant, LinkSize, LinkUnderline } from './types';
|
||||
@ -0,0 +1,88 @@
|
||||
.air-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--air-link-gap, 0.25em);
|
||||
color: var(--air-link-color);
|
||||
text-decoration: none;
|
||||
border-radius: var(--air-link-radius, 2px);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
transition: color 150ms, text-decoration-color 150ms;
|
||||
}
|
||||
|
||||
.air-link[data-size='sm'] {
|
||||
font-size: var(--air-link-size-sm, 0.85em);
|
||||
}
|
||||
.air-link[data-size='md'] {
|
||||
font-size: var(--air-link-size-md, 1em);
|
||||
}
|
||||
.air-link[data-size='lg'] {
|
||||
font-size: var(--air-link-size-lg, 1.15em);
|
||||
}
|
||||
|
||||
.air-link[data-variant='default'] {
|
||||
color: var(--air-link-color, currentColor);
|
||||
}
|
||||
.air-link[data-variant='subtle'] {
|
||||
color: var(--air-link-subtle-color, inherit);
|
||||
}
|
||||
.air-link[data-variant='quiet'] {
|
||||
color: inherit;
|
||||
}
|
||||
.air-link[data-variant='emphasized'] {
|
||||
color: var(--air-link-emphasized-color);
|
||||
font-weight: var(--air-link-emphasized-weight, 600);
|
||||
}
|
||||
|
||||
.air-link[data-underline='always'] {
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: var(--air-link-underline-thickness, 1px);
|
||||
text-underline-offset: var(--air-link-underline-offset, 2px);
|
||||
}
|
||||
.air-link[data-underline='hover']:hover,
|
||||
.air-link[data-underline='hover']:focus-visible {
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: var(--air-link-underline-thickness, 1px);
|
||||
text-underline-offset: var(--air-link-underline-offset, 2px);
|
||||
}
|
||||
.air-link[data-underline='none'] {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.air-link:hover {
|
||||
color: var(--air-link-hover-color, var(--air-link-color));
|
||||
}
|
||||
.air-link:active {
|
||||
color: var(--air-link-active-color, var(--air-link-color));
|
||||
}
|
||||
|
||||
.air-link:focus-visible {
|
||||
outline: 2px solid var(--air-link-focus-ring, currentColor);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.air-link[aria-disabled='true'] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.air-link-external-icon {
|
||||
width: 0.85em;
|
||||
height: 0.85em;
|
||||
fill: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.air-link-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import './link.css';
|
||||
import { getAir } from '$uix/air/system';
|
||||
import type { LinkProps } from './types';
|
||||
|
||||
const air = getAir();
|
||||
|
||||
let {
|
||||
href,
|
||||
external = false,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
underline = 'hover',
|
||||
disabled = false,
|
||||
class: className = '',
|
||||
children,
|
||||
...restProps
|
||||
}: LinkProps = $props();
|
||||
|
||||
const resolvedVariant = $derived(air.dom.resolve(variant) ?? 'default');
|
||||
const resolvedSize = $derived(air.dom.resolve(size) ?? 'md');
|
||||
const resolvedUnderline = $derived(air.dom.resolve(underline) ?? 'hover');
|
||||
|
||||
const externalAttrs = $derived(
|
||||
external ? { target: '_blank', rel: 'noopener noreferrer' } : {}
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if disabled}
|
||||
<span
|
||||
class={['air-link', className].filter(Boolean).join(' ')}
|
||||
aria-disabled="true"
|
||||
data-variant={resolvedVariant}
|
||||
data-size={resolvedSize}
|
||||
data-underline={resolvedUnderline}
|
||||
data-disabled=""
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
{:else}
|
||||
<a
|
||||
{href}
|
||||
class={['air-link', className].filter(Boolean).join(' ')}
|
||||
data-variant={resolvedVariant}
|
||||
data-size={resolvedSize}
|
||||
data-underline={resolvedUnderline}
|
||||
data-external={external ? '' : undefined}
|
||||
{...externalAttrs}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if external}
|
||||
<svg
|
||||
class="air-link-external-icon"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path d="M14 3h7v7h-2V6.41l-9.29 9.3-1.42-1.42L17.59 5H14V3zM5 5h6v2H7v10h10v-4h2v6H5V5z" />
|
||||
</svg>
|
||||
<span class="air-link-sr-only">(opens in new tab)</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
@ -0,0 +1,31 @@
|
||||
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { ResponsiveProp } from '$uix/air/lib';
|
||||
|
||||
/** Visual variant. */
|
||||
export type LinkVariant = 'default' | 'subtle' | 'quiet' | 'emphasized';
|
||||
/** Size scale. */
|
||||
export type LinkSize = 'sm' | 'md' | 'lg';
|
||||
/** Underline behavior. */
|
||||
export type LinkUnderline = 'none' | 'hover' | 'always';
|
||||
|
||||
export type LinkProps = Omit<HTMLAnchorAttributes, 'children' | 'href'> & {
|
||||
/** Target URL. Omit for a plain rendering without href (useful with SvelteKit `<a>` via `child`). */
|
||||
href?: string;
|
||||
/**
|
||||
* When `external` is `true`, adds `target="_blank"` + `rel="noopener noreferrer"`
|
||||
* and a visible "opens in new tab" affordance (screen-reader-only text + optional icon).
|
||||
* @default false
|
||||
*/
|
||||
external?: boolean;
|
||||
/** @default 'default' */
|
||||
variant?: ResponsiveProp<LinkVariant>;
|
||||
/** @default 'md' */
|
||||
size?: ResponsiveProp<LinkSize>;
|
||||
/** @default 'hover' */
|
||||
underline?: ResponsiveProp<LinkUnderline>;
|
||||
/** When `true`, disables the link (renders as `<span>`, no href, aria-disabled). @default false */
|
||||
disabled?: boolean;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
@ -0,0 +1,435 @@
|
||||
# Morfo — propuesta de diseño
|
||||
|
||||
> Documento de diseño para revisión externa. Describe la motivación, el shape propuesto y las decisiones abiertas de `morfo`, un artefacto compartido entre las capas de un framework de componentes Svelte 5.
|
||||
>
|
||||
> Se busca crítica técnica: puntos ciegos, precedentes pasados por alto, consecuencias no previstas, y alternativas razonables al shape propuesto.
|
||||
|
||||
---
|
||||
|
||||
## 1. Contexto del framework
|
||||
|
||||
El framework en cuestión (llamémoslo **Vicen**) organiza componentes en capas independientes que colaboran:
|
||||
|
||||
| Capa | Rol | Estado |
|
||||
|------|-----|--------|
|
||||
| **soma** | Headless. Comportamiento, estado, ARIA, keyboard, A11Y. ~66 componentes hoy. | Estable |
|
||||
| **sema** | Semántica de comportamiento (qué semántica aplica cada componente). | No definido todavía |
|
||||
| **eidos** | Capa visual. Tokens, variants, recipes, estilos. | Planificado |
|
||||
| **air** | Primitives visuales sin behavior (Separator, Link, Banner, layout). | Convive con eidos |
|
||||
|
||||
Regla del framework: **soma nunca importa de eidos**. eidos y sema consumen soma, no al revés.
|
||||
|
||||
Componentes como `Dialog`, `Calendar`, `Table` existirán en **varias capas a la vez**: un `soma/Dialog` (headless) y un `eidos/Dialog` (estilado), ambos refiriéndose a la misma idea de componente.
|
||||
|
||||
## 2. El problema
|
||||
|
||||
Hoy, la información formal de un componente vive repartida en varios sitios dentro de `soma`:
|
||||
|
||||
| Información | Dónde vive hoy |
|
||||
|-------------|----------------|
|
||||
| Nombres de parts (`root`, `trigger`, …) | `createAttrs({ parts: [...] })` en el provider |
|
||||
| Data-attrs + enums válidos | `registerContract({ parts: { trigger: [{ attr: 'data-state', values: ['open','closed'] }]}})` |
|
||||
| Qué ARIA emite cada part | hardcoded en `props = $derived.by(...)` del provider |
|
||||
| Keyboard contract | en los handlers + en el README |
|
||||
| Props | TypeScript en `types.ts` con JSDoc |
|
||||
| Traducciones | `langs.ts` con idlangref constants |
|
||||
| Prosa (summary, when-to-use) | README.md |
|
||||
| Comparativa vs otras libs | README.md |
|
||||
|
||||
Problemas derivados:
|
||||
|
||||
1. **Drift dentro de soma.** Si renombro un part `content` → `panel`, tengo que tocar `createAttrs`, `registerContract`, la emisión en el provider, los selectores del demo, las tablas del README, y los tests. Fácil olvidar alguno.
|
||||
|
||||
2. **Drift futuro entre capas.** Cuando llegue `eidos/Dialog`, su CSS estilará `[data-dialog-content]`. Si `soma/Dialog` emite `[data-dialog-panel]` después de un refactor, los estilos silenciosamente no aplican. Esto es **un clásico** de los design systems con layering físico.
|
||||
|
||||
3. **Sin fuente única para tooling.** Docs autogeneradas, low-code builders, AI assistants que generan código consumer, form designers — todos necesitan un descriptor máquina-legible del componente. Hoy lo único cercano es `registerContract`, que solo cubre data-attrs.
|
||||
|
||||
4. **Versionado impreciso.** Un cambio en nombres de parts debería ser breaking. Hoy se notifica con una entrada en un CHANGELOG manual; no hay chequeo automático.
|
||||
|
||||
## 3. Propuesta: `morfo`
|
||||
|
||||
Un **artefacto declarativo estructural, cross-layer, máquina-legible, por componente**. Define la *forma* que cualquier capa que implemente el componente debe respetar.
|
||||
|
||||
Se llama `morfo` (de μορφή, "forma") y **no** "ADN" ni "anatomy" porque:
|
||||
|
||||
- **Anatomy** (Zag.js tiene `@zag-js/anatomy`) cubre solo la estructura de parts + selectores. Si ampliamos el alcance, el nombre queda estrecho.
|
||||
- **ADN** sugiere "blueprint completo por célula/capa". Implicaría un ADN por capa (soma ADN, eidos ADN). Lo que queremos es **lo compartido entre capas**, no lo exhaustivo de cada una.
|
||||
- **Morfo** es la forma que convergentemente implementan las distintas capas. Análogo a cómo un pez y un delfín comparten morfología pero su ADN es distinto.
|
||||
|
||||
### 3.1 Alcance
|
||||
|
||||
Morfo contiene **solo contrato máquina-legible**:
|
||||
|
||||
- Nombres de parts (árbol recursivo con posible nesting)
|
||||
- Elemento HTML que renderiza cada part
|
||||
- Data-attrs que emite cada part (con valores válidos enumerables)
|
||||
- ARIA contract (qué atributo emite, qué estado semántico expone, condición)
|
||||
- Keyboard contract (teclas por part con acción semántica)
|
||||
- URL del pattern WAI-ARIA APG si aplica (pointer, no prose)
|
||||
- Qué capas implementan el componente (`scope: ('soma' | 'sema' | 'eidos')[]`)
|
||||
|
||||
Morfo **NO** contiene (y por qué):
|
||||
|
||||
| Fuera de morfo | Dónde vive | Por qué |
|
||||
|----------------|------------|---------|
|
||||
| Summary, overview | README.md | Prose editorial, no contrato |
|
||||
| Comparativa vs Radix/BaseUI/Bits | README.md | Drift externo; no ensuciar el contrato |
|
||||
| Ejemplos de uso | README.md | Narrativa |
|
||||
| `whenToUse X vs Y` | README.md | Guía de decisión, prose |
|
||||
| Props (nombre, tipo, default) | `types.ts` con JSDoc | Ya es canónico. Docs parsean JSDoc |
|
||||
| Event handlers | `{name}-provider.svelte.ts` | Código, no datos |
|
||||
| State machine | `{name}-provider.svelte.ts` | Código, no datos |
|
||||
| Traducciones | `langs.ts` (idlangref) | Registry aparte, consumido por el provider |
|
||||
| Recipes (variants visuales) | eidos (cuando exista) | Capa-específico, fuera del contrato compartido |
|
||||
|
||||
### 3.2 Shape propuesto
|
||||
|
||||
```ts
|
||||
// src/uix/morfo/types.ts
|
||||
|
||||
export type Layer = 'soma' | 'sema' | 'eidos';
|
||||
|
||||
export interface Morfo {
|
||||
/** Component display name. */
|
||||
name: string; // "Dialog"
|
||||
/** kebab-case name. Matches createAttrs({component}). */
|
||||
kebab: string; // "dialog"
|
||||
/** Layers that implement this component. */
|
||||
scope: Layer[]; // ['soma'] | ['soma', 'eidos']
|
||||
/** WAI-ARIA APG pattern URL, if the component implements a formal pattern. */
|
||||
apg?: string;
|
||||
/** Top-level parts. Recursive. */
|
||||
parts: MorfoPart[];
|
||||
}
|
||||
|
||||
export interface MorfoPart {
|
||||
/** PascalCase name exposed as a component. */
|
||||
name: string; // "Trigger"
|
||||
/** kebab-case matching createAttrs({parts}). Used for data-attr suffix. */
|
||||
kebab: string; // "trigger"
|
||||
/** HTML element this part renders. "none" for context-only parts. */
|
||||
element: string; // "<button>" | "<div>" | "none"
|
||||
/** Whether the part is required in a valid composition. */
|
||||
optional: boolean;
|
||||
/** Data attributes emitted by this part. */
|
||||
data: MorfoData[];
|
||||
/** ARIA attributes emitted by this part, with semantic description. */
|
||||
aria: MorfoAria[];
|
||||
/** Keyboard contract relevant when focus is on this part. */
|
||||
keyboard?: MorfoKeyboard[];
|
||||
/** Nested parts (e.g. Accordion.Item contains Header, Trigger, Content). */
|
||||
parts?: MorfoPart[];
|
||||
}
|
||||
|
||||
export interface MorfoData {
|
||||
/** Data attribute name, e.g. "data-state". */
|
||||
attr: string;
|
||||
/** If the attribute is enum-valued, the complete set of valid values. */
|
||||
values?: string[];
|
||||
/** If omitted + no values, it's a boolean presence flag (present or absent). */
|
||||
}
|
||||
|
||||
export interface MorfoAria {
|
||||
/** ARIA attribute name, e.g. "aria-expanded". */
|
||||
attr: string;
|
||||
/** Semantic ID of the state exposed, e.g. "open", "selected", "expanded".
|
||||
* Not a literal value; provider decides how to format at runtime. */
|
||||
valueRef: string;
|
||||
/** When the attribute is emitted. "always" or a semantic condition. */
|
||||
condition?: string; // "always" | "when hasChildren" | "role=dialog only"
|
||||
}
|
||||
|
||||
export interface MorfoKeyboard {
|
||||
/** Key name (matches KeyboardEvent.key). */
|
||||
key: string;
|
||||
/** Semantic action description, e.g. "close", "toggle", "next-item". */
|
||||
action: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Ningún campo de prosa.** Solo contrato.
|
||||
|
||||
### 3.3 Ejemplo: `dialogMorfo`
|
||||
|
||||
```ts
|
||||
// src/uix/morfo/components/dialog.ts
|
||||
import type { Morfo } from '../types';
|
||||
|
||||
export const dialogMorfo: Morfo = {
|
||||
name: 'Dialog',
|
||||
kebab: 'dialog',
|
||||
scope: ['soma'],
|
||||
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog/',
|
||||
parts: [
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'root', // special: root emits `data-dialog` (no part suffix)
|
||||
element: 'none',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'Trigger',
|
||||
kebab: 'trigger',
|
||||
element: '<button>',
|
||||
optional: false,
|
||||
data: [
|
||||
{ attr: 'data-state', values: ['open', 'closed'] }
|
||||
],
|
||||
aria: [
|
||||
{ attr: 'aria-haspopup', valueRef: 'dialog', condition: 'always' },
|
||||
{ attr: 'aria-expanded', valueRef: 'open', condition: 'always' },
|
||||
{ attr: 'aria-controls', valueRef: 'contentId', condition: 'always' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Content',
|
||||
kebab: 'content',
|
||||
element: '<div>',
|
||||
optional: false,
|
||||
data: [
|
||||
{ attr: 'data-state', values: ['open', 'closed'] },
|
||||
{ attr: 'data-nested' },
|
||||
{ attr: 'data-starting-style' },
|
||||
{ attr: 'data-ending-style' }
|
||||
],
|
||||
aria: [
|
||||
{ attr: 'role', valueRef: 'dialog', condition: 'modal' },
|
||||
{ attr: 'aria-modal', valueRef: 'true', condition: 'modal' },
|
||||
{ attr: 'aria-labelledby', valueRef: 'titleId', condition: 'when Title present' },
|
||||
{ attr: 'aria-describedby', valueRef: 'descriptionId', condition: 'when Description present' }
|
||||
],
|
||||
keyboard: [
|
||||
{ key: 'Escape', action: 'close' },
|
||||
{ key: 'Tab', action: 'trap-forward' },
|
||||
{ key: 'Shift+Tab', action: 'trap-backward' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Title',
|
||||
kebab: 'title',
|
||||
element: '<div>',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: [
|
||||
{ attr: 'role', valueRef: 'heading', condition: 'always' },
|
||||
{ attr: 'aria-level', valueRef: '2', condition: 'default' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Description',
|
||||
kebab: 'description',
|
||||
element: '<div>',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
kebab: 'close',
|
||||
element: '<button>',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: [
|
||||
{ attr: 'aria-label', valueRef: 'translated(close)', condition: 'always' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Overlay',
|
||||
kebab: 'overlay',
|
||||
element: '<div>',
|
||||
optional: true,
|
||||
data: [
|
||||
{ attr: 'data-state', values: ['open', 'closed'] }
|
||||
],
|
||||
aria: [
|
||||
{ attr: 'aria-hidden', valueRef: 'true', condition: 'always' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
Queda aprox. 100 líneas por componente. Agradable de leer de cabo a rabo.
|
||||
|
||||
### 3.4 Consumo
|
||||
|
||||
**Desde el provider** (soma):
|
||||
|
||||
```ts
|
||||
// dialog-provider.svelte.ts
|
||||
import { dialogMorfo } from '$uix/morfo/components/dialog';
|
||||
import { createAttrs, registerContract } from '../../attrs';
|
||||
|
||||
const attrs = createAttrs(dialogMorfo); // ← lee parts del morfo
|
||||
registerContract(dialogMorfo); // ← lee data+values del morfo
|
||||
|
||||
export class DialogProvider extends Provider<DialogOpts> {
|
||||
// ... comportamiento
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
'aria-haspopup': 'dialog',
|
||||
'aria-expanded': this.opts.open.current,
|
||||
// assertProps valida contra dialogMorfo.parts.trigger.aria: si el provider
|
||||
// no emite uno declarado, dev-mode warning. Si emite uno no declarado, error.
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Desde eidos** (futuro):
|
||||
|
||||
```ts
|
||||
// eidos/Dialog.css
|
||||
// (hipotéticamente) una macro CSS genera los selectores desde dialogMorfo.parts
|
||||
```
|
||||
|
||||
O más realista: un script `npm run generate:eidos-selectors` escribe `dialog.selectors.ts` leyendo `dialogMorfo`:
|
||||
|
||||
```ts
|
||||
export const sel = {
|
||||
content: '[data-dialog-content]',
|
||||
trigger: '[data-dialog-trigger]',
|
||||
title: '[data-dialog-title]',
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
Eidos CSS usa `sel.content`, no strings. Si renombro `content` → `panel` en morfo, el generador cambia `sel`, los estilos siguen aplicando.
|
||||
|
||||
**Desde docs:**
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { dialogMorfo } from '$uix/morfo/components/dialog';
|
||||
import README from '$uix/soma/components/dialog/README.md';
|
||||
</script>
|
||||
|
||||
<MorfoDocs morfo={dialogMorfo} /> <!-- tabla de parts, data, aria, keyboard -->
|
||||
<PropsTable source="soma/dialog/types" /> <!-- genera de JSDoc -->
|
||||
<Prose>{@html README}</Prose> <!-- intro, comparativa, when-to-use -->
|
||||
```
|
||||
|
||||
Tres fuentes, cada una con dueño claro, cero duplicación.
|
||||
|
||||
### 3.5 Modo estricto
|
||||
|
||||
El provider **no puede** emitir data-attrs o ARIA que no estén declarados en morfo. `assertProps` lo chequea en dev:
|
||||
|
||||
```ts
|
||||
// Dentro de assertProps:
|
||||
// - Si el provider emite data-X que no está en morfo → error: "undeclared data attr"
|
||||
// - Si el provider emite aria-Y que no está en morfo → warning: "undeclared aria"
|
||||
// - Si morfo declara aria-Z y el provider no lo emite → warning: "missing declared aria"
|
||||
```
|
||||
|
||||
Escape hatch para data-attrs privados (debug, internal state):
|
||||
|
||||
```ts
|
||||
// Cualquier attr que empieza con `data-_` se considera privado y skipea la validación.
|
||||
// Convención: nunca exponer data-_* como selector público.
|
||||
'data-_cursor': someDebugState
|
||||
```
|
||||
|
||||
Para ARIA no hay escape hatch; ARIA es siempre público (semántico).
|
||||
|
||||
## 4. Precedentes
|
||||
|
||||
| Framework | ¿Qué hace similar? | ¿Qué no? |
|
||||
|-----------|-------------------|----------|
|
||||
| **Zag.js / @zag-js/anatomy** | `createAnatomy(name).parts(...)` — fuente de verdad para nombres de parts + selectores + data-attrs | Solo cubre parts/attrs. ARIA y keyboard los emite la state machine; no hay "contract declarativo" |
|
||||
| **Design Tokens / Style Dictionary** | Tokens como SoT, build genera CSS/JS/Figma | Para tokens, no componentes. Mismo patrón filosófico |
|
||||
| **React Spectrum / React Aria** | Especificaciones formales por componente (Markdown) | Prescriptivo, no runtime data object |
|
||||
| **OpenUI (W3C)** | Propuesta de estandarizar semántica de componentes | Solo spec, sin implementación consumible |
|
||||
| **JSON Schema / OpenAPI** | Contratos machine-readable para APIs | Mismo patrón; distinto dominio |
|
||||
| **Radix UI** | Primitives TS con data-attrs ad-hoc por componente | Sin fuente única. Cada componente declara lo suyo |
|
||||
| **Base UI (MUI)** | Hooks-first, sin SoT estructural | — |
|
||||
| **Ariakit** | Composición con hooks | — |
|
||||
| **shadcn/ui** | Plantillas de código, no framework | No aplica |
|
||||
|
||||
**Nadie** declara ARIA + keyboard contract como datos machine-readable al nivel que se propone aquí. Zag llega más lejos con parts, pero no incluye el resto.
|
||||
|
||||
**Hipótesis de por qué nadie lo ha hecho del todo:**
|
||||
- ARIA es dinámica (`aria-expanded` cambia con state). Declararla pierde matiz.
|
||||
- El contrato puede quedar desfasado frente al código emisor si no hay validación runtime/build.
|
||||
- Añade una capa más al desarrollo del componente.
|
||||
|
||||
**Contra-argumentos:**
|
||||
- Declarar el ARIA **contract** (qué attrs emite, con qué condición) no pierde matiz; el valor literal sigue siendo responsabilidad del emisor. Solo declaramos QUÉ se emite, no el valor concreto.
|
||||
- La validación runtime/build existe (`assertContract` ya lo hace parcialmente).
|
||||
- La capa extra se paga una vez; se amortiza cada vez que un cambio de parts se propaga automáticamente.
|
||||
|
||||
## 5. Decisiones abiertas
|
||||
|
||||
### 5.1 Donde vive morfo físicamente
|
||||
|
||||
Dos opciones:
|
||||
|
||||
**A.** `src/uix/morfo/components/{name}.ts` — cross-layer desde día 1, aunque solo soma lo consuma por ahora.
|
||||
|
||||
**B.** `src/uix/soma/components/{name}/morfo.ts` junto al código consumidor; mover a `src/uix/morfo/` cuando eidos arranque.
|
||||
|
||||
Opción A es arquitecturalmente honesta (morfo **es** cross-layer por intención). Opción B es colocal y más cómoda ahora. Impacto real en el refactor es el mismo; el import path cambia.
|
||||
|
||||
**Inclinación**: A, desde el principio.
|
||||
|
||||
### 5.2 Props en morfo — sí o no
|
||||
|
||||
- **No**: props se quedan en `types.ts` con JSDoc. Docs parsean JSDoc. Morfo no los toca. Clean separation.
|
||||
- **Sí**: morfo incluye `props[]` con `{ name, type, default, bindable }` sin `description`. La description vive en JSDoc. Duplicación mínima pero duplicación al fin.
|
||||
|
||||
**Inclinación**: No. Evitamos duplicación.
|
||||
|
||||
**Contra-inclinación**: si más tarde un tool quiere programáticamente listar props (p. ej. un form-builder que auto-genera UI para props de configuración), le resulta más difícil. Pero ese tool puede parsear `types.ts` con TS AST o TypeDoc.
|
||||
|
||||
### 5.3 Traducciones en morfo — sí o no
|
||||
|
||||
Soma tiene `langs.ts` por componente con idlangref constants (`#?components.dialog.trigger|Open dialog`). Morfo podría declarar qué keys de traducción consume cada part, para que consumers sepan "este part espera una traducción para X".
|
||||
|
||||
**Inclinación**: no por ahora. `langs.ts` ya es canónico para ese mapping.
|
||||
|
||||
### 5.4 Versionado
|
||||
|
||||
Morfo es contrato. Breaking cambios deberían detectarse.
|
||||
|
||||
- Renombrar `content` → `panel` es breaking.
|
||||
- Añadir un part opcional nuevo es minor.
|
||||
- Cambiar `values: ['open','closed']` a `values: ['open','closed','opening','closing']` es minor (ensanchar enum) o breaking (si un consumer hacía exhaustive match).
|
||||
|
||||
Propuesta: morfo tiene un `version: number` (semver integer) y un CI check compara morfo del branch con morfo de main para clasificar el diff.
|
||||
|
||||
### 5.5 Composition constraints
|
||||
|
||||
¿Declara morfo la estructura requerida? Ej: "Dialog.Content debe contener Title si no hay aria-label del Provider".
|
||||
|
||||
- **Sí**: `parts[x].requires: ['title'] | 'if:not-aria-labelledby'` — morfo expresa constraints de composición. Más poder, más complejidad.
|
||||
- **No**: los constraints los valida el provider runtime (como ahora — `A4` exige ARIA relationships). Morfo solo dice "qué parts existen".
|
||||
|
||||
**Inclinación**: no. Menos poder, menos chance de que el lenguaje de morfo se vaya de madre. Los constraints siguen en el provider.
|
||||
|
||||
## 6. Riesgos
|
||||
|
||||
1. **Morfo se queda desfasado respecto al provider.** Mitigación: validación dev-time en `assertProps` (estricto). CI opcional que monta cada componente y valida.
|
||||
|
||||
2. **Añadir un part nuevo es "tocar dos sitios" (morfo + provider).** Mitigación: es lo que toca para que sea SoT real. Hoy ya son tres sitios (createAttrs + registerContract + provider emission). Morfo reduce a dos + un generador.
|
||||
|
||||
3. **El esquema de morfo se queda estrecho y hay que ampliarlo.** Mitigación: empezar minimal. Evolucionar con casos concretos, no adelantarse.
|
||||
|
||||
4. **ARIA runtime-dinámica no se representa bien con `condition` como string.** Mitigación: condition es documental, no ejecutable. El provider sigue siendo dueño de la lógica. Morfo solo dice "existe este ARIA".
|
||||
|
||||
5. **Low-code tools / codegen consumers tendrán que aprender el shape de morfo.** Mitigación: el shape es pequeño y hay un único punto de consumo.
|
||||
|
||||
## 7. Preguntas concretas para revisión
|
||||
|
||||
1. **¿Morfo debería incluir props, aunque sea sin description?** (§5.2)
|
||||
2. **¿Los constraints de composición deberían expresarse en morfo, o quedarse en el provider?** (§5.5)
|
||||
3. **¿El valor de `valueRef` en `MorfoAria` debería estar enumerado (como `data.values`) o string libre?** Ej: `'open' | 'closed' | 'selected' | …` vs cualquier string.
|
||||
4. **¿Hay un precedente que se me escapa donde alguien haya hecho algo parecido?** El parecido más cercano que encontré es Zag anatomy + design tokens, pero ninguno llega a ARIA + keyboard contract declarativo.
|
||||
5. **¿El modo estricto (§3.5) es demasiado restrictivo?** ¿Habría componentes legítimos que necesiten emitir data-attrs no-declarados públicos (no privados con `data-_`)?
|
||||
6. **¿Qué patologías he pasado por alto?** Especialmente sobre drift vs provider, sobre evolutions del schema, y sobre generación de código downstream.
|
||||
|
||||
---
|
||||
|
||||
**Gracias por la revisión.** Feedback concreto sobre cualquiera de los 6 puntos o cualquier parte del shape es bienvenido. Este doc es el boceto; todavía no hay líneas de código.
|
||||
@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateMorfo, MorfoInvariantError } from '../schema';
|
||||
import { dialogMorfo } from './dialog';
|
||||
|
||||
describe('dialogMorfo', () => {
|
||||
it('passes shape + invariant validation', () => {
|
||||
expect(() => validateMorfo(dialogMorfo)).not.toThrow();
|
||||
});
|
||||
|
||||
it('declares the 7 parts the provider emits', () => {
|
||||
const kebabs = dialogMorfo.parts.map((p) => p.kebab).sort();
|
||||
expect(kebabs).toEqual(
|
||||
['close', 'content', 'description', 'overlay', 'root', 'title', 'trigger'].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it('declares data-last-action on Content for Sema causal exits', () => {
|
||||
const content = dialogMorfo.parts.find((p) => p.kebab === 'content')!;
|
||||
const causal = content.data.find((d) => d.attr === 'data-last-action');
|
||||
expect(causal).toBeDefined();
|
||||
expect(causal!.values).toContain('saved');
|
||||
expect(causal!.values).toContain('cancelled');
|
||||
});
|
||||
|
||||
it('fails validation when a partRef targets a non-existent kebab', () => {
|
||||
const broken = structuredClone(dialogMorfo);
|
||||
const trigger = broken.parts.find((p) => p.kebab === 'trigger')!;
|
||||
const controls = trigger.aria.find((a) => a.attr === 'aria-controls')!;
|
||||
// Force a partRef to a non-existent target.
|
||||
(controls.value as { kind: 'partRef'; target: string }).target = 'nonexistent-part';
|
||||
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError);
|
||||
});
|
||||
|
||||
it('fails validation when stateRef refers to a state not declared in the part', () => {
|
||||
const broken = structuredClone(dialogMorfo);
|
||||
const trigger = broken.parts.find((p) => p.kebab === 'trigger')!;
|
||||
const expanded = trigger.aria.find((a) => a.attr === 'aria-expanded')!;
|
||||
(expanded.value as { kind: 'stateRef'; state: string }).state = 'nonexistent-state';
|
||||
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError);
|
||||
});
|
||||
|
||||
it('fails validation when two parts share the same kebab', () => {
|
||||
const broken = structuredClone(dialogMorfo);
|
||||
broken.parts[1].kebab = 'content'; // trigger → content (duplicate)
|
||||
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError);
|
||||
});
|
||||
|
||||
it('fails validation with empty scope', () => {
|
||||
const broken = structuredClone(dialogMorfo);
|
||||
broken.scope = [];
|
||||
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Dialog morfo — canonical first example.
|
||||
*
|
||||
* Authored from the current `dialog-provider.svelte.ts` + `sema_pre.md`
|
||||
* guidelines (data-last-action for asymmetric exits, transition markers).
|
||||
*
|
||||
* When the provider is refactored to consume `createAttrs(dialogMorfo)` +
|
||||
* `registerContract(dialogMorfo)`, this becomes the single source of
|
||||
* truth for parts, data-attrs, ARIA contract, keyboard, and focus.
|
||||
*/
|
||||
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
export const dialogMorfo: Morfo = {
|
||||
name: 'Dialog',
|
||||
kebab: 'dialog',
|
||||
scope: ['soma'],
|
||||
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog/',
|
||||
|
||||
focus: {
|
||||
initial: 'first-focusable',
|
||||
trap: true,
|
||||
return: 'trigger',
|
||||
restore: true
|
||||
},
|
||||
|
||||
parts: [
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'root',
|
||||
kind: 'virtual',
|
||||
defaultElement: 'none',
|
||||
optional: false,
|
||||
states: ['open', 'closed'],
|
||||
data: [
|
||||
{ attr: 'data-state', values: ['open', 'closed'] },
|
||||
{ attr: 'data-disabled', severity: 'optional' }
|
||||
],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'Trigger',
|
||||
kebab: 'trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: false,
|
||||
states: ['open', 'closed'],
|
||||
data: [{ attr: 'data-state', values: ['open', 'closed'] }],
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{ attr: 'aria-haspopup', value: v.literal('dialog') },
|
||||
{ attr: 'aria-expanded', value: v.stateRef('open') },
|
||||
{ attr: 'aria-controls', value: v.partRef('content') }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Content',
|
||||
kebab: 'content',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'dialog',
|
||||
optional: false,
|
||||
supportsNesting: true,
|
||||
states: ['open', 'closed'],
|
||||
data: [
|
||||
{ attr: 'data-state', values: ['open', 'closed'] },
|
||||
{
|
||||
/**
|
||||
* Sema alignment (sema_pre.md §9): causal exit reason. Updated
|
||||
* BEFORE `data-state` flips to `'closed'` so Sema can tint the
|
||||
* exit animation per-action. Ordering is validated by a
|
||||
* dedicated MutationObserver test in the smoke suite.
|
||||
*/
|
||||
attr: 'data-last-action',
|
||||
values: ['saved', 'cancelled', 'dismissed', 'dismissed-outside', 'failed'],
|
||||
severity: 'optional'
|
||||
},
|
||||
{
|
||||
attr: 'data-nested',
|
||||
severity: 'optional'
|
||||
},
|
||||
{
|
||||
attr: 'data-nested-open',
|
||||
severity: 'optional'
|
||||
},
|
||||
{
|
||||
attr: 'data-starting-style',
|
||||
severity: 'optional',
|
||||
condition: { when: 'state-equals', state: 'open', value: 'starting' }
|
||||
},
|
||||
{
|
||||
attr: 'data-ending-style',
|
||||
severity: 'optional',
|
||||
condition: { when: 'state-equals', state: 'closed', value: 'ending' }
|
||||
}
|
||||
],
|
||||
aria: [
|
||||
{ attr: 'aria-modal', value: v.literal('true'), condition: { when: 'prop-truthy', prop: 'modal' } },
|
||||
{
|
||||
attr: 'aria-labelledby',
|
||||
value: v.partRef('title'),
|
||||
condition: { when: 'part-present', part: 'title' },
|
||||
severity: 'recommended'
|
||||
},
|
||||
{
|
||||
/**
|
||||
* APG advises against `aria-describedby` on Dialog with rich
|
||||
* content (lists, tables) — AT announces everything as a
|
||||
* single string. Optional by design.
|
||||
*/
|
||||
attr: 'aria-describedby',
|
||||
value: v.partRef('description'),
|
||||
condition: { when: 'part-present', part: 'description' },
|
||||
severity: 'optional'
|
||||
},
|
||||
{
|
||||
attr: 'aria-roledescription',
|
||||
value: v.translationRef('#?components.dialog.content.roledescription|dialog window'),
|
||||
severity: 'optional'
|
||||
}
|
||||
],
|
||||
keyboard: [
|
||||
{ key: 'Escape', action: 'close' },
|
||||
{ key: 'Tab', action: 'focus-next' },
|
||||
{ key: 'Shift+Tab', action: 'focus-prev' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Overlay',
|
||||
kebab: 'overlay',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
supportsNesting: true,
|
||||
states: ['open', 'closed'],
|
||||
data: [
|
||||
{ attr: 'data-state', values: ['open', 'closed'] },
|
||||
{ attr: 'data-nested', severity: 'optional' },
|
||||
{ attr: 'data-nested-open', severity: 'optional' },
|
||||
{
|
||||
attr: 'data-starting-style',
|
||||
severity: 'optional',
|
||||
condition: { when: 'state-equals', state: 'open', value: 'starting' }
|
||||
},
|
||||
{
|
||||
attr: 'data-ending-style',
|
||||
severity: 'optional',
|
||||
condition: { when: 'state-equals', state: 'closed', value: 'ending' }
|
||||
}
|
||||
],
|
||||
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Title contributes to the accessible name of Content via
|
||||
* `aria-labelledby`. Emitted as heading for AT hierarchy, but
|
||||
* level is consumer-controlled (`propRef('level')`) — not a
|
||||
* hardcoded '2' — so a dialog can be h1 at top level or h3 in
|
||||
* a nested context without overriding prose.
|
||||
*/
|
||||
name: 'Title',
|
||||
kebab: 'title',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'heading',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-level',
|
||||
value: v.propRef('level'),
|
||||
severity: 'recommended'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Description',
|
||||
kebab: 'description',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
kebab: 'close',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{
|
||||
attr: 'aria-label',
|
||||
value: v.propRef('aria-label'),
|
||||
severity: 'recommended',
|
||||
condition: { when: 'prop-truthy', prop: 'aria-label' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Public entry point for the morfo package.
|
||||
*
|
||||
* Consumers (soma providers, eidos generators, docs renderers, sema engine)
|
||||
* import from `$uix/morfo` for types + builder helpers, and from
|
||||
* `$uix/morfo/components/{name}` for individual component morfos.
|
||||
*/
|
||||
|
||||
export type {
|
||||
Layer,
|
||||
MorfoElement,
|
||||
MorfoAriaValue,
|
||||
MorfoCondition,
|
||||
MorfoSeverity,
|
||||
MorfoPartKind,
|
||||
MorfoData,
|
||||
MorfoAriaEntry,
|
||||
MorfoKeyboard,
|
||||
MorfoFocus,
|
||||
MorfoPart,
|
||||
Morfo
|
||||
} from './types';
|
||||
|
||||
export { v } from './types';
|
||||
@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Morfo validation.
|
||||
*
|
||||
* Two layers of validation:
|
||||
*
|
||||
* 1. **Structural** (sium): shape, tagged union discriminants, literal enums.
|
||||
* Catches type errors in the declaration.
|
||||
*
|
||||
* 2. **Invariant** (manual walker): cross-part references.
|
||||
* - `partRef.target` must match another part's `kebab` in the same morfo.
|
||||
* - `stateRef.state` must exist in the containing part's `states[]`.
|
||||
* - `translationRef.key` should exist in the component's `langs.ts`
|
||||
* (validated by a separate tool that has access to the langs registry
|
||||
* — not doable from inside morfo/schema.ts without a soma dependency).
|
||||
* - Every `kebab` unique across the whole morfo.
|
||||
* - `MorfoCondition` discriminator values resolve (e.g. `state-equals`
|
||||
* must refer to a state declared somewhere in the morfo).
|
||||
* - `MorfoFocus.initial`/`return` `partRef` must match a kebab.
|
||||
*
|
||||
* Sium has no `lazy()` today, so recursion in `MorfoPart.parts?` is
|
||||
* handled by a manual walker that calls `morfoPartSchema.decode()` for
|
||||
* every sub-part. Swap to `lazy()` when sium ships it.
|
||||
*/
|
||||
|
||||
import {
|
||||
object,
|
||||
array,
|
||||
string,
|
||||
boolean,
|
||||
literal,
|
||||
union,
|
||||
discriminated,
|
||||
optional
|
||||
} from '$lib/sium/core';
|
||||
import type { Schema } from '$lib/sium/core';
|
||||
import { SiumValidationError } from '$lib/sium/core';
|
||||
import type {
|
||||
Morfo,
|
||||
MorfoPart,
|
||||
MorfoAriaValue,
|
||||
MorfoCondition,
|
||||
MorfoElement
|
||||
} from './types';
|
||||
|
||||
// ── Leaf schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
const layerSchema = union(
|
||||
literal('soma'),
|
||||
literal('sema'),
|
||||
literal('eidos')
|
||||
);
|
||||
|
||||
/**
|
||||
* Literal union of allowed HTML elements. Keep in sync with `MorfoElement`
|
||||
* in `types.ts` — TS catches mismatches at compile time.
|
||||
*/
|
||||
const elementSchema = union(
|
||||
literal('button'),
|
||||
literal('div'),
|
||||
literal('span'),
|
||||
literal('a'),
|
||||
literal('input'),
|
||||
literal('ul'),
|
||||
literal('ol'),
|
||||
literal('li'),
|
||||
literal('tr'),
|
||||
literal('td'),
|
||||
literal('th'),
|
||||
literal('table'),
|
||||
literal('thead'),
|
||||
literal('tbody'),
|
||||
literal('tfoot'),
|
||||
literal('header'),
|
||||
literal('nav'),
|
||||
literal('section'),
|
||||
literal('article'),
|
||||
literal('main'),
|
||||
literal('aside'),
|
||||
literal('footer'),
|
||||
literal('img'),
|
||||
literal('label'),
|
||||
literal('form'),
|
||||
literal('fieldset'),
|
||||
literal('legend'),
|
||||
literal('select'),
|
||||
literal('option'),
|
||||
literal('textarea'),
|
||||
literal('none')
|
||||
) as Schema<MorfoElement, MorfoElement>;
|
||||
|
||||
const severitySchema = union(
|
||||
literal('required'),
|
||||
literal('recommended'),
|
||||
literal('optional')
|
||||
);
|
||||
|
||||
const partKindSchema = union(literal('public'), literal('virtual'));
|
||||
|
||||
// ── MorfoAriaValue (tagged union) ─────────────────────────────────────────
|
||||
|
||||
const ariaValueSchema = discriminated('kind', [
|
||||
object({ kind: literal('literal'), value: string() }),
|
||||
object({ kind: literal('stateRef'), state: string() }),
|
||||
object({ kind: literal('partRef'), target: string() }),
|
||||
object({ kind: literal('propRef'), prop: string() }),
|
||||
object({ kind: literal('translationRef'), key: string() })
|
||||
]) as Schema<MorfoAriaValue, MorfoAriaValue>;
|
||||
|
||||
// ── MorfoCondition (tagged union with 'always' literal + objects) ─────────
|
||||
|
||||
const conditionObjectSchema = discriminated('when', [
|
||||
object({ when: literal('part-present'), part: string() }),
|
||||
object({
|
||||
when: literal('state-equals'),
|
||||
state: string(),
|
||||
value: string()
|
||||
}),
|
||||
object({ when: literal('prop-truthy'), prop: string() }),
|
||||
object({ when: literal('prop-falsy'), prop: string() })
|
||||
]);
|
||||
|
||||
const conditionSchema = union(literal('always'), conditionObjectSchema) as Schema<
|
||||
MorfoCondition,
|
||||
MorfoCondition
|
||||
>;
|
||||
|
||||
// ── MorfoData / MorfoAriaEntry / MorfoKeyboard ────────────────────────────
|
||||
|
||||
const dataSchema = object({
|
||||
attr: string(),
|
||||
values: optional(array(string())),
|
||||
condition: optional(conditionSchema),
|
||||
severity: optional(severitySchema)
|
||||
});
|
||||
|
||||
const ariaEntrySchema = object({
|
||||
attr: string(),
|
||||
value: ariaValueSchema,
|
||||
condition: optional(conditionSchema),
|
||||
severity: optional(severitySchema)
|
||||
});
|
||||
|
||||
const keyboardSchema = object({
|
||||
key: string(),
|
||||
action: string(),
|
||||
condition: optional(conditionSchema)
|
||||
});
|
||||
|
||||
// ── MorfoFocus ────────────────────────────────────────────────────────────
|
||||
|
||||
const focusTargetSchema = union(
|
||||
literal('first-focusable'),
|
||||
literal('trigger'),
|
||||
literal('previous'),
|
||||
object({ partRef: string() })
|
||||
);
|
||||
|
||||
const focusSchema = object({
|
||||
initial: optional(focusTargetSchema),
|
||||
trap: optional(boolean()),
|
||||
return: optional(focusTargetSchema),
|
||||
restore: optional(boolean())
|
||||
});
|
||||
|
||||
// ── MorfoPart (non-recursive — `parts?` handled by manual walker) ─────────
|
||||
//
|
||||
// Sium lacks `lazy()`, so we validate a single part without its `parts?`
|
||||
// children, then the walker recurses.
|
||||
|
||||
const partShallowSchema = object({
|
||||
name: string(),
|
||||
kebab: string(),
|
||||
kind: partKindSchema,
|
||||
defaultElement: elementSchema,
|
||||
role: optional(string()),
|
||||
optional: boolean(),
|
||||
supportsNesting: optional(boolean()),
|
||||
states: optional(array(string())),
|
||||
data: array(dataSchema),
|
||||
aria: array(ariaEntrySchema),
|
||||
keyboard: optional(array(keyboardSchema)),
|
||||
parts: optional(array(object({}, { unknownKeys: 'passthrough' })))
|
||||
// ^ children passed through opaquely — shape is checked by the walker
|
||||
});
|
||||
|
||||
// ── Morfo root ────────────────────────────────────────────────────────────
|
||||
|
||||
const morfoShallowSchema = object({
|
||||
name: string(),
|
||||
kebab: string(),
|
||||
scope: array(layerSchema),
|
||||
apg: optional(string()),
|
||||
focus: optional(focusSchema),
|
||||
parts: array(object({}, { unknownKeys: 'passthrough' }))
|
||||
// ^ parts are opaque here; walker recurses with `partShallowSchema`
|
||||
});
|
||||
|
||||
// ── Manual walker + invariants ────────────────────────────────────────────
|
||||
|
||||
/** Collect every part (recursively) into a flat list with ancestor path. */
|
||||
function flattenParts(
|
||||
parts: MorfoPart[],
|
||||
path: ReadonlyArray<string> = []
|
||||
): Array<{ part: MorfoPart; path: ReadonlyArray<string> }> {
|
||||
const result: Array<{ part: MorfoPart; path: ReadonlyArray<string> }> = [];
|
||||
for (const p of parts) {
|
||||
const nextPath = [...path, p.kebab];
|
||||
result.push({ part: p, path: nextPath });
|
||||
if (p.parts) {
|
||||
result.push(...flattenParts(p.parts, nextPath));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Validate every part (recursively) against `partShallowSchema`. */
|
||||
function validatePartsRecursively(parts: MorfoPart[]): void {
|
||||
for (const { part, path } of flattenParts(parts)) {
|
||||
try {
|
||||
// `decodeSync` throws SiumValidationError synchronously on shape mismatch.
|
||||
partShallowSchema.decodeSync(part as never);
|
||||
} catch (err) {
|
||||
if (err instanceof SiumValidationError) {
|
||||
throw new MorfoInvariantError(
|
||||
`Part at path ${path.join('.')} failed shape validation: ${err.message}`,
|
||||
path as readonly string[]
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify known vocabularies so the external vocabulary-check script can use them. */
|
||||
export const CANONICAL_VOCABULARIES = {
|
||||
disclosure: ['open', 'closed'],
|
||||
active: ['active', 'inactive'],
|
||||
checked: ['checked', 'unchecked', 'indeterminate'],
|
||||
toggle: ['on', 'off'],
|
||||
lifecycle: ['loading', 'idle', 'success', 'error'],
|
||||
selection: ['selected', 'unselected'],
|
||||
orientation: ['horizontal', 'vertical']
|
||||
} as const;
|
||||
|
||||
/** Thrown when a morfo fails invariant validation after shape passes. */
|
||||
export class MorfoInvariantError extends Error {
|
||||
readonly path: ReadonlyArray<string>;
|
||||
constructor(message: string, path: ReadonlyArray<string> = []) {
|
||||
super(message);
|
||||
this.name = 'MorfoInvariantError';
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-reference invariants.
|
||||
*
|
||||
* 1. Every `kebab` unique across the whole morfo (flat namespace).
|
||||
* 2. Every `partRef.target` resolves to a declared part.
|
||||
* 3. Every `stateRef.state` resolves to a state declared in the SAME part.
|
||||
* 4. Every `state-equals` condition's `state` exists somewhere in the part.
|
||||
* 5. `focus.initial.partRef` / `focus.return.partRef` resolve to a part.
|
||||
* 6. Root `scope` non-empty.
|
||||
* 7. Morfo `kebab` is valid kebab-case (a-z0-9-).
|
||||
*
|
||||
* Not validated here (requires external sources):
|
||||
* - `translationRef.key` existence (needs langs.ts registry → separate tool).
|
||||
* - Cross-component vocabulary consistency (needs morfo registry →
|
||||
* scripts/morfo-vocabulary-check.mjs).
|
||||
*/
|
||||
function validateInvariants(morfo: Morfo): void {
|
||||
if (morfo.scope.length === 0) {
|
||||
throw new MorfoInvariantError(
|
||||
`morfo "${morfo.kebab}" has empty scope — must implement at least one layer`
|
||||
);
|
||||
}
|
||||
|
||||
if (!/^[a-z][a-z0-9-]*$/.test(morfo.kebab)) {
|
||||
throw new MorfoInvariantError(
|
||||
`morfo kebab "${morfo.kebab}" must be kebab-case (a-z, 0-9, - only)`
|
||||
);
|
||||
}
|
||||
|
||||
const flat = flattenParts(morfo.parts);
|
||||
const kebabs = new Set<string>();
|
||||
for (const { part, path } of flat) {
|
||||
if (kebabs.has(part.kebab)) {
|
||||
throw new MorfoInvariantError(
|
||||
`duplicate kebab "${part.kebab}" in morfo "${morfo.kebab}" at path ${path.join('.')}`,
|
||||
path
|
||||
);
|
||||
}
|
||||
kebabs.add(part.kebab);
|
||||
}
|
||||
|
||||
for (const { part, path } of flat) {
|
||||
for (const ariaEntry of part.aria) {
|
||||
const v = ariaEntry.value;
|
||||
if (v.kind === 'partRef' && !kebabs.has(v.target)) {
|
||||
throw new MorfoInvariantError(
|
||||
`aria[${ariaEntry.attr}].partRef "${v.target}" does not match any part in "${morfo.kebab}"`,
|
||||
path
|
||||
);
|
||||
}
|
||||
if (v.kind === 'stateRef') {
|
||||
const states = part.states ?? [];
|
||||
if (!states.includes(v.state)) {
|
||||
throw new MorfoInvariantError(
|
||||
`aria[${ariaEntry.attr}].stateRef "${v.state}" is not in part.states of "${part.kebab}" (declared: ${states.join(', ') || '∅'})`,
|
||||
path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkCondition = (c: MorfoCondition | undefined, context: string) => {
|
||||
if (!c || c === 'always') return;
|
||||
if (c.when === 'part-present' && !kebabs.has(c.part)) {
|
||||
throw new MorfoInvariantError(
|
||||
`${context}: condition part-present references unknown part "${c.part}"`,
|
||||
path
|
||||
);
|
||||
}
|
||||
if (c.when === 'state-equals') {
|
||||
const states = part.states ?? [];
|
||||
if (!states.includes(c.state)) {
|
||||
throw new MorfoInvariantError(
|
||||
`${context}: condition state-equals references unknown state "${c.state}" in part "${part.kebab}"`,
|
||||
path
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const d of part.data) {
|
||||
checkCondition(d.condition, `data[${d.attr}]`);
|
||||
}
|
||||
for (const a of part.aria) {
|
||||
checkCondition(a.condition, `aria[${a.attr}]`);
|
||||
}
|
||||
for (const k of part.keyboard ?? []) {
|
||||
checkCondition(k.condition, `keyboard[${k.key}]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (morfo.focus) {
|
||||
const f = morfo.focus;
|
||||
if (typeof f.initial === 'object' && !kebabs.has(f.initial.partRef)) {
|
||||
throw new MorfoInvariantError(
|
||||
`focus.initial.partRef "${f.initial.partRef}" does not match any part`
|
||||
);
|
||||
}
|
||||
if (typeof f.return === 'object' && !kebabs.has(f.return.partRef)) {
|
||||
throw new MorfoInvariantError(
|
||||
`focus.return.partRef "${f.return.partRef}" does not match any part`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate a `Morfo` value. Throws `SiumValidationError` on shape errors
|
||||
* and `MorfoInvariantError` on cross-reference errors.
|
||||
*
|
||||
* Intended for build-time / dev-time. Run once per morfo on first load;
|
||||
* results are cacheable.
|
||||
*/
|
||||
export function validateMorfo(morfo: unknown): Morfo {
|
||||
// 1. Shape: root shell (scope, apg, focus) + opaque parts.
|
||||
morfoShallowSchema.decodeSync(morfo as never);
|
||||
const m = morfo as Morfo;
|
||||
|
||||
// 2. Shape: every part (recursively) — sium has no lazy() today.
|
||||
validatePartsRecursively(m.parts);
|
||||
|
||||
// 3. Invariants: kebab uniqueness, partRef/stateRef/conditions, focus.
|
||||
validateInvariants(m);
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
export { morfoShallowSchema, partShallowSchema };
|
||||
@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Morfo — cross-layer component contract.
|
||||
*
|
||||
* A morfo is the single machine-readable source of truth for a component's
|
||||
* **public DOM surface**: the parts it exposes, the data-attrs it emits, the
|
||||
* ARIA contract it honours, the keyboard contract, and the focus policy.
|
||||
*
|
||||
* Consumers:
|
||||
* - soma providers consume morfo via `createAttrs(morfo)` + `registerContract(morfo)`
|
||||
* - eidos generates CSS selectors from morfo's parts/data
|
||||
* - sema reads the DOM surface that morfo declares (see sema_pre.md)
|
||||
* - docs render morfo as part / data / aria / keyboard tables
|
||||
*
|
||||
* Morfo is the DOM-surface contract. It does NOT contain prose (README),
|
||||
* props (types.ts + JSDoc), provider behaviour (*-provider.svelte.ts), state
|
||||
* machines, translations (langs.ts idlangref), or eidos recipes.
|
||||
*
|
||||
* Shape v5 — consolidated after 3 independent AI reviews (Gemini, Grok,
|
||||
* ChatGPT) + Sema alignment. See ./study.md for the review history and
|
||||
* ./DESIGN.md for the original proposal.
|
||||
*/
|
||||
|
||||
// ── Layer ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The framework layer that implements a component. A single component can
|
||||
* live in multiple layers (soma + eidos + sema).
|
||||
*/
|
||||
export type Layer = 'soma' | 'sema' | 'eidos';
|
||||
|
||||
// ── HTML element ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Default element a part renders. Literal union (not free `string`) so typos
|
||||
* are caught at compile time. Consumer can still override via the `child`
|
||||
* snippet — this is *advisory*. Provider's `role` field is what guarantees
|
||||
* semantic correctness across polymorphic overrides.
|
||||
*
|
||||
* `'none'` = part has no DOM of its own (context-only providers, virtual
|
||||
* coordinators).
|
||||
*/
|
||||
export type MorfoElement =
|
||||
| 'button'
|
||||
| 'div'
|
||||
| 'span'
|
||||
| 'a'
|
||||
| 'input'
|
||||
| 'ul'
|
||||
| 'ol'
|
||||
| 'li'
|
||||
| 'tr'
|
||||
| 'td'
|
||||
| 'th'
|
||||
| 'table'
|
||||
| 'thead'
|
||||
| 'tbody'
|
||||
| 'tfoot'
|
||||
| 'header'
|
||||
| 'nav'
|
||||
| 'section'
|
||||
| 'article'
|
||||
| 'main'
|
||||
| 'aside'
|
||||
| 'footer'
|
||||
| 'img'
|
||||
| 'label'
|
||||
| 'form'
|
||||
| 'fieldset'
|
||||
| 'legend'
|
||||
| 'select'
|
||||
| 'option'
|
||||
| 'textarea'
|
||||
| 'none';
|
||||
|
||||
// ── ARIA value taxonomy ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Source semantics of an ARIA attribute's value. Tagged union so the
|
||||
* validator can enforce cross-references:
|
||||
*
|
||||
* - `literal` → static string ("dialog", "true")
|
||||
* - `stateRef` → must match a state in the containing part's `states[]`
|
||||
* - `partRef` → must match another part's `kebab` in the same morfo
|
||||
* - `propRef` → consumer-controlled via component prop
|
||||
* - `translationRef` → must exist as idlangref key in the component's `langs.ts`
|
||||
*
|
||||
* No `computed` escape hatch — if a value doesn't fit these five shapes,
|
||||
* the declaration is modelling the wrong thing. Revisit the semantics.
|
||||
*/
|
||||
export type MorfoAriaValue =
|
||||
| { kind: 'literal'; value: string }
|
||||
| { kind: 'stateRef'; state: string }
|
||||
| { kind: 'partRef'; target: string }
|
||||
| { kind: 'propRef'; prop: string }
|
||||
| { kind: 'translationRef'; key: string };
|
||||
|
||||
// ── Condition taxonomy ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* When an attribute / keyboard action is emitted or applicable. Small DSL
|
||||
* instead of free-form prose so the validator can cross-check runtime
|
||||
* behaviour in smoke permutations.
|
||||
*
|
||||
* `'always'` — unconditional
|
||||
* `{ when: 'part-present', part }` — emitted iff part is rendered
|
||||
* `{ when: 'state-equals', state, value }` — emitted iff state === value
|
||||
* `{ when: 'prop-truthy', prop }` — emitted iff consumer prop is truthy
|
||||
* `{ when: 'prop-falsy', prop }` — emitted iff consumer prop is falsy
|
||||
*/
|
||||
export type MorfoCondition =
|
||||
| 'always'
|
||||
| { when: 'part-present'; part: string }
|
||||
| { when: 'state-equals'; state: string; value: string }
|
||||
| { when: 'prop-truthy'; prop: string }
|
||||
| { when: 'prop-falsy'; prop: string };
|
||||
|
||||
// ── Severity ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* How the strict-mode validator treats a declared attribute or state.
|
||||
*
|
||||
* `required` — must be emitted when its condition holds. Missing = error.
|
||||
* `recommended` — should be emitted. Missing = warning.
|
||||
* `optional` — may be emitted. Missing = silent.
|
||||
*
|
||||
* Defaults to `required` when omitted.
|
||||
*
|
||||
* Example of `optional`: APG advises against `aria-describedby` on Dialog
|
||||
* when content is rich (lists, tables). The attr exists in the contract but
|
||||
* is deliberately omitted in those cases.
|
||||
*/
|
||||
export type MorfoSeverity = 'required' | 'recommended' | 'optional';
|
||||
|
||||
// ── Part kind ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Distinguishes parts that are public API (consumer composes them,
|
||||
* docs render them, eidos styles them) from parts that are internal
|
||||
* coordinators (provider context, focus guards, portal roots, SafePolygon
|
||||
* trackers). Virtual parts are ignored by the contract validator and
|
||||
* hidden from docs.
|
||||
*/
|
||||
export type MorfoPartKind = 'public' | 'virtual';
|
||||
|
||||
// ── Data attribute ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A data attribute emitted on a part's DOM element. The contract for eidos
|
||||
* selectors and sema semantic channels.
|
||||
*
|
||||
* When `values` is present, the attr is enum-valued. When omitted, the attr
|
||||
* is a presence flag (present or absent, no value).
|
||||
*/
|
||||
export interface MorfoData {
|
||||
/** Attribute name including the `data-` prefix, e.g. `"data-state"`. */
|
||||
attr: string;
|
||||
/** Complete set of valid values. Omit for presence flags. */
|
||||
values?: string[];
|
||||
/** When this attr is emitted. Defaults to `'always'`. */
|
||||
condition?: MorfoCondition;
|
||||
/** Validator severity. Defaults to `'required'`. */
|
||||
severity?: MorfoSeverity;
|
||||
}
|
||||
|
||||
// ── ARIA entry ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single ARIA attribute declaration on a part. The contract for
|
||||
* accessibility tooling and assistive technology expectations.
|
||||
*/
|
||||
export interface MorfoAriaEntry {
|
||||
/** ARIA attribute name, e.g. `"aria-expanded"`. Includes `role` as a pseudo-ARIA attr. */
|
||||
attr: string;
|
||||
/** Source semantic of the value (see `MorfoAriaValue`). */
|
||||
value: MorfoAriaValue;
|
||||
/** When this attr is emitted. Defaults to `'always'`. */
|
||||
condition?: MorfoCondition;
|
||||
/** Validator severity. Defaults to `'required'`. */
|
||||
severity?: MorfoSeverity;
|
||||
}
|
||||
|
||||
// ── Keyboard ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A keyboard interaction applicable to a part.
|
||||
*
|
||||
* `key` is the KeyboardEvent.key value, optionally with modifier prefixes
|
||||
* separated by `+`: `"Escape"`, `"Shift+Tab"`, `"Ctrl+A"`.
|
||||
*
|
||||
* `action` is a semantic identifier: `"close"`, `"activate"`, `"next-item"`.
|
||||
* Not a JS function — just documentation + cross-component consistency.
|
||||
*/
|
||||
export interface MorfoKeyboard {
|
||||
/** KeyboardEvent.key with optional modifier prefix, e.g. `"Escape"`, `"Shift+Tab"`. */
|
||||
key: string;
|
||||
/** Semantic action identifier. Keep consistent across components for the same intent. */
|
||||
action: string;
|
||||
/** When this keyboard handler is active. Defaults to `'always'` (whenever the part has focus). */
|
||||
condition?: MorfoCondition;
|
||||
}
|
||||
|
||||
// ── Focus policy ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Focus coordination for overlay / composite components.
|
||||
*
|
||||
* Not every component has one — a Tooltip trigger doesn't trap focus, a
|
||||
* Dialog does. When omitted, the component has no special focus policy.
|
||||
*
|
||||
* Critical for overlay components per WAI-ARIA APG and React Aria:
|
||||
* keyboard + ARIA alone are incomplete without focus discipline.
|
||||
*/
|
||||
export interface MorfoFocus {
|
||||
/**
|
||||
* What receives focus when the component activates.
|
||||
*
|
||||
* `'first-focusable'` — first focusable descendant of the primary part
|
||||
* `'trigger'` — the component's trigger element
|
||||
* `{ partRef: <kebab> }`— a specific part by kebab name
|
||||
*/
|
||||
initial?: 'first-focusable' | 'trigger' | { partRef: string };
|
||||
/** Whether Tab / Shift+Tab are trapped within the component while active. */
|
||||
trap?: boolean;
|
||||
/**
|
||||
* Where focus goes when the component deactivates.
|
||||
*
|
||||
* `'trigger'` — back to the trigger (standard modal behaviour)
|
||||
* `'previous'` — to whatever had focus before activation
|
||||
* `{ partRef: <kebab> }`— a specific part by kebab name
|
||||
*/
|
||||
return?: 'trigger' | 'previous' | { partRef: string };
|
||||
/** Whether focus is restored on unmount (vs dismissed state). Defaults to `true`. */
|
||||
restore?: boolean;
|
||||
}
|
||||
|
||||
// ── Part ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single part of a component. Parts compose into a tree.
|
||||
*
|
||||
* Every `kebab` must be unique within the enclosing `Morfo` — flat
|
||||
* namespace, not path-scoped. Validator enforces uniqueness at build.
|
||||
*/
|
||||
export interface MorfoPart {
|
||||
/** PascalCase name exposed as a soma component, e.g. `"Trigger"`. */
|
||||
name: string;
|
||||
/**
|
||||
* kebab-case matching `createAttrs({ parts: [...] })` in the provider.
|
||||
* Used as the data-attr suffix: `data-{component}-{kebab}`.
|
||||
* Unique across the whole morfo (no nested path namespacing).
|
||||
*/
|
||||
kebab: string;
|
||||
/** Public API part vs internal coordinator (see `MorfoPartKind`). */
|
||||
kind: MorfoPartKind;
|
||||
/**
|
||||
* Element the provider renders by default. Advisory — consumer can
|
||||
* override via `child` snippet, which is why `role` is emitted
|
||||
* explicitly alongside.
|
||||
*/
|
||||
defaultElement: MorfoElement;
|
||||
/**
|
||||
* Explicit ARIA role the provider emits regardless of `defaultElement`.
|
||||
* Guarantees semantic correctness under polymorphism (if consumer
|
||||
* renders as `<a>` via `child`, the role is still correct).
|
||||
*
|
||||
* Omit for parts whose role is inherent to their element (e.g. `<button>`
|
||||
* naturally has `role=button`) or parts that don't need an explicit role.
|
||||
*/
|
||||
role?: string;
|
||||
/** Whether the part is required in a valid composition. */
|
||||
optional: boolean;
|
||||
/**
|
||||
* Whether the component supports nesting itself (e.g. Dialog inside
|
||||
* Dialog, Menu inside Menu). Documentation flag for consumers + hint to
|
||||
* eidos that CSS selectors may need `:not([data-nested])` scoping.
|
||||
*/
|
||||
supportsNesting?: boolean;
|
||||
/**
|
||||
* States the part can be in. Referenced by `MorfoAriaValue.kind === 'stateRef'`
|
||||
* and by `MorfoCondition` discriminators (`state-equals`).
|
||||
*/
|
||||
states?: string[];
|
||||
/** Data attributes emitted on this part's DOM element. */
|
||||
data: MorfoData[];
|
||||
/** ARIA attributes emitted on this part's DOM element. */
|
||||
aria: MorfoAriaEntry[];
|
||||
/** Keyboard interactions relevant when focus is on (or within) this part. */
|
||||
keyboard?: MorfoKeyboard[];
|
||||
/** Nested parts (e.g. `Accordion.Item` contains `Header`, `Trigger`, `Content`). */
|
||||
parts?: MorfoPart[];
|
||||
}
|
||||
|
||||
// ── Root ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The top-level contract for a component.
|
||||
*
|
||||
* Minimal by design. Editorial content (summary, comparison table,
|
||||
* when-to-use prose) lives in the component's README, not here.
|
||||
* Props live in the component's `types.ts` with JSDoc. Translations
|
||||
* live in `langs.ts`.
|
||||
*/
|
||||
export interface Morfo {
|
||||
/** Component display name, PascalCase. */
|
||||
name: string;
|
||||
/** kebab-case component identifier. Must match `createAttrs({ component })`. */
|
||||
kebab: string;
|
||||
/** Layers that implement this component. At minimum `['soma']`. */
|
||||
scope: Layer[];
|
||||
/** WAI-ARIA APG pattern URL when the component implements a formal pattern. */
|
||||
apg?: string;
|
||||
/**
|
||||
* Focus policy for overlay / composite components. Omit when the
|
||||
* component has no special focus coordination (plain controls).
|
||||
*/
|
||||
focus?: MorfoFocus;
|
||||
/** The component's part tree. */
|
||||
parts: MorfoPart[];
|
||||
}
|
||||
|
||||
// ── Builder helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Builders for `MorfoAriaValue`. Use these instead of the raw object literal
|
||||
* for brevity and to keep the discriminant hidden from the call site.
|
||||
*
|
||||
* ```ts
|
||||
* aria: [
|
||||
* { attr: 'aria-expanded', value: v.stateRef('open') },
|
||||
* { attr: 'aria-labelledby', value: v.partRef('title') },
|
||||
* { attr: 'aria-haspopup', value: v.literal('dialog') }
|
||||
* ]
|
||||
* ```
|
||||
*/
|
||||
export const v = {
|
||||
literal: (value: string): MorfoAriaValue => ({ kind: 'literal', value }),
|
||||
stateRef: (state: string): MorfoAriaValue => ({ kind: 'stateRef', state }),
|
||||
partRef: (target: string): MorfoAriaValue => ({ kind: 'partRef', target }),
|
||||
propRef: (prop: string): MorfoAriaValue => ({ kind: 'propRef', prop }),
|
||||
translationRef: (key: string): MorfoAriaValue => ({ kind: 'translationRef', key })
|
||||
} as const;
|
||||
@ -1,36 +1,51 @@
|
||||
import type { Morfo, MorfoPart } from '../../morfo/types';
|
||||
|
||||
/**
|
||||
* Creates data-attribute strings for a soma component.
|
||||
* Creates data-attribute names for a soma component from its morfo.
|
||||
*
|
||||
* ```ts
|
||||
* import { dialogMorfo } from '$uix/morfo/components/dialog';
|
||||
* const attrs = createAttrs(dialogMorfo);
|
||||
* attrs.root // "data-dialog"
|
||||
* attrs.trigger // "data-dialog-trigger"
|
||||
* attrs.selector('content') // "[data-dialog-content]"
|
||||
* ```
|
||||
*
|
||||
* Usage:
|
||||
* const attrs = createAttrs({ component: 'accordion', parts: ['root', 'item', 'trigger', 'content'] as const });
|
||||
* attrs.root // "data-accordion"
|
||||
* attrs.item // "data-accordion-item"
|
||||
* attrs.trigger // "data-accordion-trigger"
|
||||
* attrs.selector('content') // "[data-accordion-content]"
|
||||
* Morfo is the single cross-layer source of truth — changes to part names
|
||||
* or data-attr structure happen in the morfo, never here.
|
||||
*/
|
||||
export type AttrsReturn<T extends readonly string[]> = {
|
||||
[K in T[number]]: string;
|
||||
} & {
|
||||
selector: (part: T[number]) => string;
|
||||
export type AttrsReturn = {
|
||||
readonly [part: string]: string | ((part: string) => string);
|
||||
selector: (part: string) => string;
|
||||
};
|
||||
|
||||
export function createAttrs<const T extends readonly string[]>(config: {
|
||||
component: string;
|
||||
parts: T;
|
||||
}): AttrsReturn<T> {
|
||||
const { component, parts } = config;
|
||||
/** Walk a morfo's recursive part tree and collect every unique kebab. */
|
||||
function collectKebabs(parts: readonly MorfoPart[]): string[] {
|
||||
const result: string[] = [];
|
||||
for (const p of parts) {
|
||||
result.push(p.kebab);
|
||||
if (p.parts && p.parts.length > 0) {
|
||||
result.push(...collectKebabs(p.parts));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createAttrs(morfo: Morfo): AttrsReturn {
|
||||
const component = morfo.kebab;
|
||||
const parts = collectKebabs(morfo.parts);
|
||||
|
||||
const entries = parts.map((part) => {
|
||||
// 'root' generates data-{component} (no -root suffix)
|
||||
// 'root' generates data-{component} (no -root suffix).
|
||||
const attr = part === 'root' ? `data-${component}` : `data-${component}-${part}`;
|
||||
return [part, attr] as const;
|
||||
});
|
||||
|
||||
const attrs = Object.fromEntries(entries) as Record<T[number], string>;
|
||||
const attrs = Object.fromEntries(entries) as Record<string, string>;
|
||||
|
||||
function selector(part: T[number]): string {
|
||||
function selector(part: string): string {
|
||||
return `[${attrs[part]}]`;
|
||||
}
|
||||
|
||||
return { ...attrs, selector } as AttrsReturn<T>;
|
||||
return { ...attrs, selector } as AttrsReturn;
|
||||
}
|
||||
|
||||
@ -0,0 +1,178 @@
|
||||
# Announce
|
||||
|
||||
A headless primitive for screen reader announcements. The Provider mounts dual internal live regions (polite + assertive) and exposes an imperative `announce(message, priority, timeout)` API. Use for save confirmations, form error summaries, chat messages, and anything that a sighted user sees visually but a screen reader user would otherwise miss.
|
||||
|
||||
Mount `Announce.Provider` **once** near the app root — typically in the root layout. From any descendant, call `announce()` via the snippet props or via `AnnounceProvider.require().announce(...)`.
|
||||
|
||||
## Anatomy
|
||||
|
||||
```svelte
|
||||
<!-- Root layout -->
|
||||
<Announce.Provider>
|
||||
{#snippet children({ announce })}
|
||||
<MyApp {announce} />
|
||||
{/snippet}
|
||||
</Announce.Provider>
|
||||
```
|
||||
|
||||
Inside any descendant:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { AnnounceProvider } from '$soma/components/announce';
|
||||
const announce = AnnounceProvider.require().announce;
|
||||
</script>
|
||||
|
||||
<button onclick={() => announce('Saved!', 'polite')}>Save</button>
|
||||
```
|
||||
|
||||
Or a purpose-specific visible region:
|
||||
|
||||
```svelte
|
||||
<Announce.Region role="log" message={chatMessage} visuallyHidden={false} />
|
||||
```
|
||||
|
||||
## Parts
|
||||
|
||||
| Part | Element | Description |
|
||||
| ---------- | ------- | --------------------------------------------------------------------------- |
|
||||
| `Provider` | `<div>` | Root context. Renders internal dual live regions. Exposes `announce()`. |
|
||||
| `Region` | `<div>` | Declarative live region for persistent, purpose-specific announcements. |
|
||||
|
||||
## Props
|
||||
|
||||
### `Provider`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ---------------- | --------- | ------- | -------------------------------------------------------------------------- |
|
||||
| `id` | `string` | auto | DOM id. |
|
||||
| `defaultTimeout` | `number` | `1000` | Ms before a posted message is cleared. Clearing is what allows the same text to fire twice. |
|
||||
|
||||
Snippet props: `{ announce, clear }`.
|
||||
|
||||
### `Region`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------------- | ---------------------------------------- | ------------------- | ---------------------------------------------------------------------- |
|
||||
| `id` | `string` | auto | DOM id. |
|
||||
| `role` | `'status' \| 'alert' \| 'log' \| 'timer'` | `'status'` | ARIA role. Each implies a default `aria-live`. |
|
||||
| `aria-live` | `'polite' \| 'assertive'` | derived from `role` | Override the implicit urgency. |
|
||||
| `aria-atomic` | `boolean` | `true` | Whether AT reads the full region or the diff. |
|
||||
| `aria-relevant` | `string` | `'additions text'` | What mutations trigger the announcement. |
|
||||
| `message` | `string` | — | Bound reactive text. Changes are announced. |
|
||||
| `visuallyHidden` | `boolean` | `true` | Whether to apply the SR-only CSS clip. Set `false` for visible regions. |
|
||||
|
||||
## ARIA
|
||||
|
||||
| Part | Attribute | Value |
|
||||
| ----------------- | --------------------- | ------------------------------------------------------- |
|
||||
| Provider (inner) | `role` | `status` (polite) + `alert` (assertive) |
|
||||
| Provider (inner) | `aria-live` | `polite` / `assertive` |
|
||||
| Provider (inner) | `aria-atomic` | `true` |
|
||||
| Region | `role` | `status` \| `alert` \| `log` \| `timer` |
|
||||
| Region | `aria-live` | Resolved from `role` unless overridden |
|
||||
| Region | `aria-atomic` | As prop |
|
||||
| Region | `aria-relevant` | As prop |
|
||||
|
||||
### Role semantics
|
||||
|
||||
- **`status`** (polite) — non-critical state changes. Default. Use for "Saved", "Copied".
|
||||
- **`alert`** (assertive) — urgent. Interrupts current speech. Use sparingly — for errors, session expiry.
|
||||
- **`log`** (polite) — running history (chat, activity). New content at the end.
|
||||
- **`timer`** (polite) — time-sensitive. AT decides update cadence (don't ticker every second).
|
||||
|
||||
## Data Attributes
|
||||
|
||||
| Part | Attribute | Values |
|
||||
| ------- | ----------------- | --------------------------- |
|
||||
| Provider | `data-announce` | Always present |
|
||||
| Region | `data-announce-region` | Always present |
|
||||
| Region | `data-role` | `status \| alert \| log \| timer` |
|
||||
| Region | `data-live` | `polite \| assertive` |
|
||||
|
||||
## Dual-region A/B trick
|
||||
|
||||
Screen readers dedupe consecutive identical strings — writing "Saved" twice in a row is silent. The Provider alternates between two internal regions (A/B) on each `announce()` call; the AT sees a change in the "other" region and re-reads the message.
|
||||
|
||||
## Global API (`createAnnouncer()`)
|
||||
|
||||
For utility modules that can't assume a `Provider` ancestor (toast libraries, form validation helpers, worker-response handlers), soma ships a singleton-style global announcer:
|
||||
|
||||
```ts
|
||||
import { createAnnouncer } from '$soma/components/announce';
|
||||
|
||||
const { announce, clear } = createAnnouncer();
|
||||
|
||||
// From any handler, any module:
|
||||
announce('Saved!', 'polite');
|
||||
announce('Connection lost', 'assertive');
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Regions are created lazily on the **first** `announce()` call and attached to `document.body`.
|
||||
- Regions are singletons per document — multiple `createAnnouncer()` calls share the same DOM nodes. No double-announces.
|
||||
- Server-side rendered environments: the function is safe to call; it's a no-op when `document` is undefined.
|
||||
- Independent from `Announce.Provider`. The two do not cross-post — mount the Provider if you want consistent cleanup tied to a subtree; use the global API for cross-cutting utilities.
|
||||
|
||||
A pre-built shared handle is exported as `announcer`:
|
||||
|
||||
```ts
|
||||
import { announcer } from '$soma/components/announce';
|
||||
announcer.announce('Hello', 'polite');
|
||||
```
|
||||
|
||||
## Comparison
|
||||
|
||||
| Feature | Soma | Radix | Ark UI | react-aria | APG |
|
||||
| ------------------------------------- | :--: | :---: | :----: | :--------: | :-: |
|
||||
| Standalone component / API | ✅ | ❌ | ⚠️¹ | ✅ | N/A |
|
||||
| `role=status` / `role=alert` regions | ✅ | — | ✅ | ✅ | ✅ |
|
||||
| A/B toggle (repeat-announce) | ✅ | — | ✅ | ✅ | ✅ |
|
||||
| Priority routing (polite/assertive) | ✅ | — | ✅ | ✅ | ✅ |
|
||||
| Auto-clear timeout | ✅ | — | ✅ | ✅ | — |
|
||||
| Imperative `announce()` via context | ✅ | — | ✅ | ✅ | — |
|
||||
| Singleton-style global announcer | ✅ | — | ❌ | ✅ | — |
|
||||
| Declarative `Region` variant | ✅ | — | ❌ | ❌ | — |
|
||||
| `role=log` / `role=timer` | ✅ | — | ❌ | ❌ | ✅ |
|
||||
| `aria-atomic` / `aria-relevant` opt | ✅ | — | ❌ | ❌ | ✅ |
|
||||
| Translated default `aria-label` | ✅ | — | ❌ | ❌ | — |
|
||||
|
||||
¹ Ark uses an announcer built into ProgressCircle/DragDrop; no standalone API.
|
||||
|
||||
## Usage
|
||||
|
||||
### Save confirmation
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { AnnounceProvider } from '$soma/components/announce';
|
||||
const { announce } = AnnounceProvider.require();
|
||||
|
||||
async function save() {
|
||||
await fetch('/api/save', { method: 'POST' });
|
||||
announce('Changes saved.', 'polite');
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={save}>Save</button>
|
||||
```
|
||||
|
||||
### Form error summary
|
||||
|
||||
```svelte
|
||||
<Announce.Region role="alert" message={errorSummary} visuallyHidden={true} />
|
||||
```
|
||||
|
||||
When `errorSummary` changes (e.g. after form validation), the new text is assertively announced.
|
||||
|
||||
### Chat log
|
||||
|
||||
```svelte
|
||||
<Announce.Region role="log" visuallyHidden={false} class="chat-log">
|
||||
{#each messages as m (m.id)}
|
||||
<p><strong>{m.author}:</strong> {m.text}</p>
|
||||
{/each}
|
||||
</Announce.Region>
|
||||
```
|
||||
|
||||
New messages are read politely in the order they appear.
|
||||
@ -0,0 +1,211 @@
|
||||
import { Provider, context, type WithRefOpts, type ProviderOpts } from '../../provider';
|
||||
import { createAttrs, registerContract, boolToStr } from '../../attrs';
|
||||
import { type ActiveProps } from '../../reactive';
|
||||
import { Soma } from '../../core/soma.svelte';
|
||||
import { ANNOUNCE_LANGS } from './langs';
|
||||
import type { AnnouncePriority, AnnounceRole } from './types';
|
||||
|
||||
const attrs = createAttrs({
|
||||
component: 'announce',
|
||||
parts: ['root', 'region'] as const
|
||||
});
|
||||
|
||||
registerContract({
|
||||
name: 'announce',
|
||||
version: 1,
|
||||
parts: {
|
||||
root: [],
|
||||
region: [
|
||||
{
|
||||
attr: 'data-role',
|
||||
values: ['status', 'alert', 'log', 'timer'],
|
||||
description: 'The ARIA live-region role'
|
||||
},
|
||||
{
|
||||
attr: 'data-live',
|
||||
values: ['polite', 'assertive'],
|
||||
description: 'Resolved aria-live value (may differ from role default)'
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
/** Visually-hidden style kept in-sync with the WAI "sr-only" recipe. */
|
||||
const VISUALLY_HIDDEN_STYLE = {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: '0',
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
'white-space': 'nowrap',
|
||||
border: '0'
|
||||
} as const;
|
||||
|
||||
/** Default `aria-live` value for each role. */
|
||||
const ROLE_LIVE_DEFAULTS: Record<AnnounceRole, AnnouncePriority> = {
|
||||
status: 'polite',
|
||||
alert: 'assertive',
|
||||
log: 'polite',
|
||||
timer: 'polite'
|
||||
};
|
||||
|
||||
// ── Root provider ──────────────────────────────────────────────────────────
|
||||
|
||||
interface AnnounceOpts extends ProviderOpts, ActiveProps<{ defaultTimeout: number }> {}
|
||||
|
||||
export class AnnounceProvider extends Provider<AnnounceOpts> {
|
||||
static readonly ctx = context<AnnounceProvider>('Announce');
|
||||
static get(): AnnounceProvider | undefined {
|
||||
return this.ctx.getOr(undefined) as AnnounceProvider | undefined;
|
||||
}
|
||||
static require(): AnnounceProvider {
|
||||
return this.ctx.get();
|
||||
}
|
||||
|
||||
static create(opts: AnnounceOpts) {
|
||||
return new AnnounceProvider(opts);
|
||||
}
|
||||
|
||||
readonly soma = Soma.get();
|
||||
|
||||
/** Current text for each internal region. Swap alternates so repeated announcements re-fire. */
|
||||
politeA = $state('');
|
||||
politeB = $state('');
|
||||
assertiveA = $state('');
|
||||
assertiveB = $state('');
|
||||
|
||||
private politeToggle = false;
|
||||
private assertiveToggle = false;
|
||||
private politeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private assertiveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private constructor(opts: AnnounceOpts) {
|
||||
super(opts, 'Announce', 'root', attrs.root, AnnounceProvider.ctx);
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (this.politeTimer) clearTimeout(this.politeTimer);
|
||||
if (this.assertiveTimer) clearTimeout(this.assertiveTimer);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
announce = (
|
||||
message: string,
|
||||
priority: AnnouncePriority = 'polite',
|
||||
timeout?: number
|
||||
): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const clearAfter = timeout ?? this.opts.defaultTimeout.current;
|
||||
|
||||
if (priority === 'assertive') {
|
||||
// Alternate between A/B so that identical consecutive messages
|
||||
// still fire the screen reader (writing the same string into the
|
||||
// same region is a no-op for most ATs).
|
||||
this.assertiveToggle = !this.assertiveToggle;
|
||||
if (this.assertiveToggle) {
|
||||
this.assertiveA = message;
|
||||
this.assertiveB = '';
|
||||
} else {
|
||||
this.assertiveA = '';
|
||||
this.assertiveB = message;
|
||||
}
|
||||
if (this.assertiveTimer) clearTimeout(this.assertiveTimer);
|
||||
this.assertiveTimer = setTimeout(() => {
|
||||
this.assertiveA = '';
|
||||
this.assertiveB = '';
|
||||
this.assertiveTimer = null;
|
||||
}, clearAfter);
|
||||
} else {
|
||||
this.politeToggle = !this.politeToggle;
|
||||
if (this.politeToggle) {
|
||||
this.politeA = message;
|
||||
this.politeB = '';
|
||||
} else {
|
||||
this.politeA = '';
|
||||
this.politeB = message;
|
||||
}
|
||||
if (this.politeTimer) clearTimeout(this.politeTimer);
|
||||
this.politeTimer = setTimeout(() => {
|
||||
this.politeA = '';
|
||||
this.politeB = '';
|
||||
this.politeTimer = null;
|
||||
}, clearAfter);
|
||||
}
|
||||
};
|
||||
|
||||
clear = (): void => {
|
||||
this.politeA = '';
|
||||
this.politeB = '';
|
||||
this.assertiveA = '';
|
||||
this.assertiveB = '';
|
||||
if (this.politeTimer) clearTimeout(this.politeTimer);
|
||||
if (this.assertiveTimer) clearTimeout(this.assertiveTimer);
|
||||
this.politeTimer = null;
|
||||
this.assertiveTimer = null;
|
||||
};
|
||||
|
||||
readonly snippetProps = $derived.by(() => ({
|
||||
announce: this.announce,
|
||||
clear: this.clear
|
||||
}));
|
||||
|
||||
readonly internalRegionStyle = VISUALLY_HIDDEN_STYLE;
|
||||
|
||||
readonly politeLabel = $derived.by(() =>
|
||||
this.soma?.langs.ts(ANNOUNCE_LANGS.LABEL) ?? 'Announcements'
|
||||
);
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Region ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AnnounceRegionOpts
|
||||
extends WithRefOpts,
|
||||
ActiveProps<{
|
||||
role: AnnounceRole;
|
||||
ariaLive: AnnouncePriority | undefined;
|
||||
ariaAtomic: boolean;
|
||||
ariaRelevant: string;
|
||||
visuallyHidden: boolean;
|
||||
}> {}
|
||||
|
||||
export class AnnounceRegionProvider extends Provider<AnnounceRegionOpts> {
|
||||
static create(opts: AnnounceRegionOpts) {
|
||||
return new AnnounceRegionProvider(opts);
|
||||
}
|
||||
|
||||
/** Optional — a Region can live outside a Provider (pure declarative use). */
|
||||
readonly provider: AnnounceProvider | undefined;
|
||||
|
||||
private constructor(opts: AnnounceRegionOpts) {
|
||||
super(opts, 'Announce', 'region', attrs.region);
|
||||
this.provider = AnnounceProvider.get();
|
||||
}
|
||||
|
||||
readonly resolvedLive = $derived.by(
|
||||
() => this.opts.ariaLive.current ?? ROLE_LIVE_DEFAULTS[this.opts.role.current]
|
||||
);
|
||||
|
||||
readonly props = $derived.by(() => {
|
||||
const hidden = this.opts.visuallyHidden.current;
|
||||
return this.assertProps({
|
||||
...this.baseProps,
|
||||
role: this.opts.role.current,
|
||||
'aria-live': this.resolvedLive,
|
||||
'aria-atomic': boolToStr(this.opts.ariaAtomic.current),
|
||||
'aria-relevant': this.opts.ariaRelevant.current,
|
||||
'data-role': this.opts.role.current,
|
||||
'data-live': this.resolvedLive,
|
||||
style: hidden ? VISUALLY_HIDDEN_STYLE : undefined
|
||||
} as const);
|
||||
});
|
||||
}
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { AnnounceRegionProvider } from '../announce-provider.svelte';
|
||||
import type { AnnounceRegionProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'announce-region'),
|
||||
role = 'status',
|
||||
'aria-live': ariaLive,
|
||||
'aria-atomic': ariaAtomic = true,
|
||||
'aria-relevant': ariaRelevant = 'additions text',
|
||||
message,
|
||||
visuallyHidden = true,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: AnnounceRegionProps = $props();
|
||||
|
||||
const provider = AnnounceRegionProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
role: readableActive(() => role),
|
||||
ariaLive: readableActive(() => ariaLive),
|
||||
ariaAtomic: readableActive(() => ariaAtomic),
|
||||
ariaRelevant: readableActive(() => ariaRelevant),
|
||||
visuallyHidden: readableActive(() => visuallyHidden)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{#if message !== undefined}{message}{/if}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { readableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { AnnounceProvider } from '../announce-provider.svelte';
|
||||
import type { AnnounceProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
id = createId(uid, 'announce'),
|
||||
defaultTimeout = 1000,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: AnnounceProps = $props();
|
||||
|
||||
const provider = AnnounceProvider.create({
|
||||
id: readableActive(() => id),
|
||||
defaultTimeout: readableActive(() => defaultTimeout)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...provider.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.(provider.snippetProps)}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
aria-relevant="additions text"
|
||||
style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0"
|
||||
>
|
||||
{provider.politeA}
|
||||
</div>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
aria-relevant="additions text"
|
||||
style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0"
|
||||
>
|
||||
{provider.politeB}
|
||||
</div>
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
aria-relevant="additions text"
|
||||
style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0"
|
||||
>
|
||||
{provider.assertiveA}
|
||||
</div>
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
aria-relevant="additions text"
|
||||
style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0"
|
||||
>
|
||||
{provider.assertiveB}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,13 @@
|
||||
export { default as Provider } from './components/announce.svelte';
|
||||
export { default as Region } from './components/announce-region.svelte';
|
||||
|
||||
export { createAnnouncer, announcer } from './global.svelte';
|
||||
|
||||
export type {
|
||||
AnnounceProps as ProviderProps,
|
||||
AnnounceRegionProps as RegionProps,
|
||||
AnnounceProviderSnippetProps as ProviderSnippetProps,
|
||||
AnnouncePriority,
|
||||
AnnounceRole,
|
||||
AnnounceApi
|
||||
} from './types';
|
||||
@ -0,0 +1,140 @@
|
||||
import type { AnnouncePriority, AnnounceApi } from './types';
|
||||
|
||||
/**
|
||||
* A global screen-reader announcer independent of `Announce.Provider`.
|
||||
*
|
||||
* Creates two hidden live regions (polite + assertive) attached to
|
||||
* `document.body` lazily on the first `announce()` call. The regions are
|
||||
* singletons per document, so multiple calls to `createAnnouncer()` share
|
||||
* the same DOM nodes.
|
||||
*
|
||||
* Use this when:
|
||||
* - A utility module needs to announce from outside the component tree.
|
||||
* - You cannot (or prefer not to) mount `<Announce.Provider>` at the app root.
|
||||
*
|
||||
* When both a `createAnnouncer()`-backed call AND a mounted
|
||||
* `Announce.Provider` exist, they are independent — messages do not
|
||||
* cross-post. Prefer the Provider when available; the global API is a
|
||||
* fallback.
|
||||
*
|
||||
* Server-side: `announce()` is a no-op (no `document`).
|
||||
*/
|
||||
|
||||
const POLITE_ID = 'soma-announce-global-polite';
|
||||
const ASSERTIVE_ID = 'soma-announce-global-assertive';
|
||||
|
||||
const VISUALLY_HIDDEN_STYLE =
|
||||
'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0';
|
||||
|
||||
type RegionPair = {
|
||||
a: HTMLDivElement;
|
||||
b: HTMLDivElement;
|
||||
toggle: boolean;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
function ensurePair(
|
||||
politeOrAssertive: AnnouncePriority,
|
||||
baseId: string
|
||||
): RegionPair | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const cache = (globalThis as typeof globalThis & {
|
||||
__somaAnnouncer?: Map<string, RegionPair>;
|
||||
});
|
||||
if (!cache.__somaAnnouncer) cache.__somaAnnouncer = new Map();
|
||||
let pair = cache.__somaAnnouncer.get(baseId);
|
||||
if (pair) return pair;
|
||||
|
||||
const host = document.createElement('div');
|
||||
host.id = baseId;
|
||||
host.style.cssText = VISUALLY_HIDDEN_STYLE;
|
||||
|
||||
const role = politeOrAssertive === 'assertive' ? 'alert' : 'status';
|
||||
|
||||
const make = (suffix: string) => {
|
||||
const el = document.createElement('div');
|
||||
el.id = `${baseId}-${suffix}`;
|
||||
el.setAttribute('role', role);
|
||||
el.setAttribute('aria-live', politeOrAssertive);
|
||||
el.setAttribute('aria-atomic', 'true');
|
||||
el.setAttribute('aria-relevant', 'additions text');
|
||||
return el;
|
||||
};
|
||||
|
||||
const a = make('a');
|
||||
const b = make('b');
|
||||
host.appendChild(a);
|
||||
host.appendChild(b);
|
||||
document.body.appendChild(host);
|
||||
|
||||
pair = { a, b, toggle: false, timer: null };
|
||||
cache.__somaAnnouncer.set(baseId, pair);
|
||||
return pair;
|
||||
}
|
||||
|
||||
function write(pair: RegionPair, text: string, timeout: number) {
|
||||
pair.toggle = !pair.toggle;
|
||||
if (pair.toggle) {
|
||||
pair.a.textContent = text;
|
||||
pair.b.textContent = '';
|
||||
} else {
|
||||
pair.a.textContent = '';
|
||||
pair.b.textContent = text;
|
||||
}
|
||||
if (pair.timer) clearTimeout(pair.timer);
|
||||
pair.timer = setTimeout(() => {
|
||||
pair.a.textContent = '';
|
||||
pair.b.textContent = '';
|
||||
pair.timer = null;
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the global announce/clear pair. Safe to call from any module — does
|
||||
* not require a mounted `<Announce.Provider>`. Regions are created lazily on
|
||||
* first call and persist for the lifetime of the document.
|
||||
*
|
||||
* ```ts
|
||||
* import { createAnnouncer } from '$soma/components/announce';
|
||||
*
|
||||
* const { announce } = createAnnouncer();
|
||||
* // From any handler:
|
||||
* announce('Saved!', 'polite');
|
||||
* ```
|
||||
*
|
||||
* @param defaultTimeout Ms before messages are cleared. @default 1000
|
||||
*/
|
||||
export function createAnnouncer(defaultTimeout = 1000): AnnounceApi {
|
||||
const announce: AnnounceApi['announce'] = (message, priority = 'polite', timeout) => {
|
||||
const baseId = priority === 'assertive' ? ASSERTIVE_ID : POLITE_ID;
|
||||
const pair = ensurePair(priority, baseId);
|
||||
if (!pair) return;
|
||||
write(pair, message, timeout ?? defaultTimeout);
|
||||
};
|
||||
|
||||
const clear: AnnounceApi['clear'] = () => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const cache = (globalThis as typeof globalThis & {
|
||||
__somaAnnouncer?: Map<string, RegionPair>;
|
||||
}).__somaAnnouncer;
|
||||
if (!cache) return;
|
||||
for (const pair of cache.values()) {
|
||||
pair.a.textContent = '';
|
||||
pair.b.textContent = '';
|
||||
if (pair.timer) clearTimeout(pair.timer);
|
||||
pair.timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
return { announce, clear };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: the shared singleton announcer. Equivalent to calling
|
||||
* `createAnnouncer()` once and reusing the result. Lazy: does not touch the
|
||||
* DOM until first `.announce()` call.
|
||||
*/
|
||||
export const announcer: AnnounceApi = {
|
||||
announce: (msg, priority, timeout) => createAnnouncer().announce(msg, priority, timeout),
|
||||
clear: () => createAnnouncer().clear()
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Idlangref constants for the Announce component.
|
||||
*
|
||||
* Announce is a headless primitive for screen reader live-region messages.
|
||||
* Most strings are supplied by the consumer at announce time; only the
|
||||
* optional default `aria-label` on declarative regions uses a translation.
|
||||
*/
|
||||
export const ANNOUNCE_LANGS = {
|
||||
LABEL: '#?components.announce.label|Announcements'
|
||||
} as const;
|
||||
@ -0,0 +1,115 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { WithChild, Without } from '../../types';
|
||||
import type { PrimitiveDivAttributes } from '../../types';
|
||||
|
||||
/** How urgently the message interrupts the user's current activity. */
|
||||
export type AnnouncePriority = 'polite' | 'assertive';
|
||||
|
||||
/** ARIA live-region role. Distinct semantics in ATs: */
|
||||
/** `status` = non-critical updates, `alert` = urgent, `log` = running history, `timer` = time-sensitive. */
|
||||
export type AnnounceRole = 'status' | 'alert' | 'log' | 'timer';
|
||||
|
||||
/** Imperative API exposed by the Provider. */
|
||||
export type AnnounceApi = {
|
||||
/**
|
||||
* Post a message to the appropriate internal live region.
|
||||
* The message is cleared after `timeout` ms so repeat announcements
|
||||
* of the same text still re-trigger the screen reader.
|
||||
*
|
||||
* @param message Text to announce.
|
||||
* @param priority `'polite'` (default) queues behind current speech, `'assertive'` interrupts.
|
||||
* @param timeout Milliseconds before the region is cleared. @default 1000
|
||||
*/
|
||||
announce: (message: string, priority?: AnnouncePriority, timeout?: number) => void;
|
||||
/** Clear all internal live regions immediately. */
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
/** Snippet props for `Announce.Provider`. */
|
||||
export type AnnounceProviderSnippetProps = AnnounceApi;
|
||||
|
||||
// ── Root provider ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props for `Announce.Provider`.
|
||||
*
|
||||
* Mounts two internal hidden live regions (polite + assertive) and exposes
|
||||
* an imperative `announce()` API via snippet props and via context
|
||||
* (descendants can call `AnnounceProvider.require().announce(...)`).
|
||||
*
|
||||
* Mount this ONCE near the app root — typically in the root layout. Nested
|
||||
* providers work but create duplicate regions; screen readers may read the
|
||||
* same message twice.
|
||||
*
|
||||
* For purpose-specific regions (chat log, form errors, timer), compose with
|
||||
* `Announce.Region` inside the Provider.
|
||||
*/
|
||||
export type AnnounceProps = WithChild<
|
||||
{
|
||||
/** DOM id. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/**
|
||||
* Default timeout in ms before a posted message is cleared from the
|
||||
* region. Clearing is what allows the same text to be announced again
|
||||
* — screen readers dedupe identical consecutive strings. @default 1000
|
||||
*/
|
||||
defaultTimeout?: number;
|
||||
children?: Snippet<[AnnounceProviderSnippetProps]>;
|
||||
},
|
||||
AnnounceProviderSnippetProps
|
||||
> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
// ── Region ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props for `Announce.Region` — a declarative live region for persistent,
|
||||
* purpose-specific announcements (chat log, running timer, form error summary).
|
||||
*
|
||||
* Unlike the imperative `announce()` path which uses the Provider's shared
|
||||
* polite/assertive regions, a `Region` is an addressable element in the DOM
|
||||
* — consumers can write directly to it via `Region.write(text)` or let it
|
||||
* reflect a bound `message` prop.
|
||||
*/
|
||||
export type AnnounceRegionProps = WithChild<{
|
||||
/** DOM id. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/**
|
||||
* ARIA role. Each role implies different AT handling:
|
||||
* - `status` (polite) — non-critical status updates.
|
||||
* - `alert` (assertive) — interrupts speech. Use for errors.
|
||||
* - `log` (polite) — running history (chat, activity). Newer content at the end.
|
||||
* - `timer` (polite) — time-sensitive info. Do not announce every tick.
|
||||
* @default 'status'
|
||||
*/
|
||||
role?: AnnounceRole;
|
||||
/**
|
||||
* Override the implicit `aria-live` value from `role`. Only use when you
|
||||
* deliberately want a non-default urgency (e.g. an `alert` region that
|
||||
* should be polite, or a `status` that should be assertive).
|
||||
*/
|
||||
'aria-live'?: AnnouncePriority;
|
||||
/**
|
||||
* Whether the AT reads the entire region on change (`true`) or just the
|
||||
* diff (`false`). @default true
|
||||
*/
|
||||
'aria-atomic'?: boolean;
|
||||
/**
|
||||
* What types of changes trigger an announcement: `'additions'`, `'removals'`,
|
||||
* `'text'`, `'all'`, or a space-separated combination. @default 'additions text'
|
||||
*/
|
||||
'aria-relevant'?: string;
|
||||
/**
|
||||
* Bound reactive message. When the value changes, the region's content
|
||||
* updates and the AT announces the new text. Alternative to writing
|
||||
* children directly.
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* Whether the region is visually hidden via the standard SR-only technique.
|
||||
* Set `false` only when the region doubles as visible UI (e.g. a status
|
||||
* banner that's both visible and announced). @default true
|
||||
*/
|
||||
visuallyHidden?: boolean;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, { role?: unknown; 'aria-live'?: unknown; 'aria-atomic'?: unknown; 'aria-relevant'?: unknown }>;
|
||||
@ -0,0 +1,163 @@
|
||||
# Avatar
|
||||
|
||||
An image avatar with a fallback that shows until the image loads (or fails). The Provider preloads the URL out-of-band; `Image` only becomes visible when `status='loaded'`. A configurable `delayMs` prevents the fallback from flashing for fast-loading images.
|
||||
|
||||
## Anatomy
|
||||
|
||||
```svelte
|
||||
<Avatar.Provider>
|
||||
<Avatar.Image src="/users/jp.jpg" alt="Juan Pérez" />
|
||||
<Avatar.Fallback>JP</Avatar.Fallback>
|
||||
</Avatar.Provider>
|
||||
```
|
||||
|
||||
## Parts
|
||||
|
||||
| Part | Element | Description |
|
||||
| ----------- | --------- | ---------------------------------------------------------------------------- |
|
||||
| `Provider` | `<span>` | Root context. Holds `status`. Emits `data-status`. |
|
||||
| `Image` | `<img>` | `<img>` with `display: none` until the Provider resolves to `loaded`. |
|
||||
| `Fallback` | `<span>` | Visible while `status !== 'loaded'`. Consumer content (initials, icon, …). |
|
||||
|
||||
## Props
|
||||
|
||||
### `Provider`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ---------------- | ---------------------------------------------- | ------- | ----------------------------------------------------------------------------- |
|
||||
| `id` | `string` | auto | DOM id. |
|
||||
| `delayMs` | `number` | `0` | Minimum ms before `status='loaded'` is applied (debounce against flicker). |
|
||||
| `onStatusChange` | `(status: AvatarLoadingStatus) => void` | — | Fires on every status transition. |
|
||||
|
||||
Snippet props: `{ status }`.
|
||||
|
||||
### `Image`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ---------------- | ----------------------------------------- | ------- | --------------------------------------- |
|
||||
| `id` | `string` | auto | DOM id. |
|
||||
| `src` | `string \| null \| undefined` | — | Image URL. Empty/null/undefined → error. |
|
||||
| `crossorigin` | `'anonymous' \| 'use-credentials' \| ''` | — | Passed through to the img + preloader. |
|
||||
| `referrerpolicy` | `ReferrerPolicy` | — | Passed through to the img + preloader. |
|
||||
|
||||
### `Fallback`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ---- | -------- | ------- | ----------- |
|
||||
| `id` | `string` | auto | DOM id. |
|
||||
|
||||
## ARIA
|
||||
|
||||
No explicit ARIA roles. `Avatar.Image` is a plain `<img alt>`; the Fallback is a decorative `<span>`. Consumers must provide `alt` on `Image` for the image to carry its semantic name.
|
||||
|
||||
## Data Attributes
|
||||
|
||||
| Part | Attribute | Values |
|
||||
| --------- | --------------------- | ----------------------------------------------- |
|
||||
| Provider | `data-avatar` | Always present |
|
||||
| Provider | `data-status` | `idle` \| `loading` \| `loaded` \| `error` |
|
||||
| Image | `data-avatar-image` | Always present |
|
||||
| Image | `data-status` | Inherited from provider |
|
||||
| Fallback | `data-avatar-fallback`| Always present |
|
||||
| Fallback | `data-status` | Inherited from provider |
|
||||
|
||||
## Status machine
|
||||
|
||||
```
|
||||
idle → loading → loaded
|
||||
↓
|
||||
error
|
||||
```
|
||||
|
||||
- `idle`: no `src` set yet, or component just mounted.
|
||||
- `loading`: `src` is non-empty; preloader in flight.
|
||||
- `loaded`: image decoded; the `<img>` becomes visible, fallback hides.
|
||||
- `error`: preload failed, `src` is empty/null, or the URL is unreachable.
|
||||
|
||||
Preloading uses a detached `new Image()` so the browser decodes the bitmap before the rendered `<img>` becomes visible — no flash of unstyled image.
|
||||
|
||||
## Comparison
|
||||
|
||||
| Feature | Soma | Radix | Ark UI | bits-ui | react-aria |
|
||||
| ---------------------------------- | :--: | :---: | :----: | :-----: | :--------: |
|
||||
| Dedicated component | ✅ | ✅ | ✅ | ✅ | ❌¹ |
|
||||
| Image preload via `new Image()` | ✅ | ✅ | ✅ | ✅ | — |
|
||||
| `delayMs` debounce | ✅ | ✅ | ✅ | ✅ | — |
|
||||
| `onStatusChange` callback | ✅ | ✅ | ✅ | ✅ | — |
|
||||
| `data-status` on root | ✅ | ❌² | ✅ | ✅ | — |
|
||||
| `Group` primitive | ❌³ | ❌ | ✅ | ❌ | — |
|
||||
| Built-in initials helper | ❌⁴ | ❌ | ❌ | ❌ | — |
|
||||
| `crossorigin` / `referrerpolicy` | ✅ | ❌ | ❌ | ❌ | — |
|
||||
|
||||
¹ react-aria leaves Avatar to the styled layer.
|
||||
² Radix only propagates status via snippet / data-attrs on child parts.
|
||||
³ Avatar stacks are purely visual; the air layer owns `Avatar.Group` with spacing tokens and overlap.
|
||||
⁴ `getInitials(name)` is a one-liner for the consumer; not part of the headless contract.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Group stack**: overlap and spacing are visual concerns. Air implements `Avatar.Group` with `--air-avatar-overlap` tokens.
|
||||
- **Initials derivation**: consumers pass their own text. Different locales / naming conventions would make a built-in helper wrong for someone.
|
||||
- **Skeleton state**: use the `idle` / `loading` status directly in CSS — `Avatar.Fallback[data-status='loading']` can be styled as a pulse.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic
|
||||
|
||||
```svelte
|
||||
<Avatar.Provider>
|
||||
<Avatar.Image src="/avatars/jp.jpg" alt="Juan Pérez" />
|
||||
<Avatar.Fallback>JP</Avatar.Fallback>
|
||||
</Avatar.Provider>
|
||||
```
|
||||
|
||||
### With delay (no flash on fast loads)
|
||||
|
||||
```svelte
|
||||
<Avatar.Provider delayMs={200}>
|
||||
<Avatar.Image src={user.avatar} alt={user.name} />
|
||||
<Avatar.Fallback>{user.initials}</Avatar.Fallback>
|
||||
</Avatar.Provider>
|
||||
```
|
||||
|
||||
### Reacting to status (loader ring, skeleton)
|
||||
|
||||
```svelte
|
||||
<Avatar.Provider>
|
||||
{#snippet children({ status })}
|
||||
<Avatar.Image src={user.avatar} alt={user.name} />
|
||||
<Avatar.Fallback>
|
||||
{#if status === 'loading'}
|
||||
<svg class="spinner" />
|
||||
{:else}
|
||||
{user.initials}
|
||||
{/if}
|
||||
</Avatar.Fallback>
|
||||
{/snippet}
|
||||
</Avatar.Provider>
|
||||
```
|
||||
|
||||
### Styling with `data-status`
|
||||
|
||||
```css
|
||||
[data-avatar] {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
[data-avatar-image] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
[data-avatar][data-status='error'] {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
```
|
||||
@ -0,0 +1,221 @@
|
||||
import { watch } from 'runed';
|
||||
import { Provider, context, type WithRefOpts } from '../../provider';
|
||||
import { createAttrs, registerContract } from '../../attrs';
|
||||
import { state, type ActiveProps, type StateProps } from '../../reactive';
|
||||
import type { OnChangeFn } from '../../types';
|
||||
import type { HTMLImgAttributes } from 'svelte/elements';
|
||||
import type { AvatarLoadingStatus } from './types';
|
||||
|
||||
const attrs = createAttrs({
|
||||
component: 'avatar',
|
||||
parts: ['root', 'image', 'fallback'] as const
|
||||
});
|
||||
|
||||
registerContract({
|
||||
name: 'avatar',
|
||||
version: 1,
|
||||
parts: {
|
||||
root: [
|
||||
{
|
||||
attr: 'data-status',
|
||||
values: ['idle', 'loading', 'loaded', 'error'],
|
||||
description: 'Image loading status'
|
||||
}
|
||||
],
|
||||
image: [
|
||||
{
|
||||
attr: 'data-status',
|
||||
values: ['idle', 'loading', 'loaded', 'error'],
|
||||
description: 'Image loading status (inherited)'
|
||||
}
|
||||
],
|
||||
fallback: [
|
||||
{
|
||||
attr: 'data-status',
|
||||
values: ['idle', 'loading', 'loaded', 'error'],
|
||||
description: 'Image loading status (inherited)'
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
type CrossOrigin = HTMLImgAttributes['crossorigin'];
|
||||
type ReferrerPolicy = HTMLImgAttributes['referrerpolicy'];
|
||||
|
||||
// ── Root provider ──────────────────────────────────────────────────────────
|
||||
|
||||
interface AvatarOpts
|
||||
extends WithRefOpts,
|
||||
StateProps<{ status: AvatarLoadingStatus }>,
|
||||
ActiveProps<{
|
||||
delayMs: number;
|
||||
onStatusChange: OnChangeFn<AvatarLoadingStatus> | undefined;
|
||||
}> {}
|
||||
|
||||
export class AvatarProvider extends Provider<AvatarOpts> {
|
||||
static readonly ctx = context<AvatarProvider>('Avatar');
|
||||
static get(): AvatarProvider | undefined {
|
||||
return this.ctx.getOr(undefined) as AvatarProvider | undefined;
|
||||
}
|
||||
static require(): AvatarProvider {
|
||||
return this.ctx.get();
|
||||
}
|
||||
|
||||
static create(opts: AvatarOpts) {
|
||||
return new AvatarProvider(opts);
|
||||
}
|
||||
|
||||
private constructor(opts: AvatarOpts) {
|
||||
super(opts, 'Avatar', 'root', attrs.root, AvatarProvider.ctx);
|
||||
|
||||
// Fire onStatusChange whenever status transitions.
|
||||
let previous = opts.status.current;
|
||||
$effect(() => {
|
||||
const current = opts.status.current;
|
||||
if (current !== previous) {
|
||||
opts.onStatusChange.current?.(current);
|
||||
previous = current;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setStatus(next: AvatarLoadingStatus) {
|
||||
if (this.opts.status.current === next) return;
|
||||
this.opts.status.current = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload the image in the background. Updates `status` through
|
||||
* `loading → loaded` (with optional `delayMs` debounce) or `→ error`.
|
||||
* Returns a cleanup function that cancels the pending delay timer + the
|
||||
* image listeners if `src` changes before load resolves.
|
||||
*/
|
||||
loadImage(
|
||||
src: string,
|
||||
crossorigin?: CrossOrigin,
|
||||
referrerPolicy?: ReferrerPolicy
|
||||
): (() => void) | void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const image = new Image();
|
||||
let timerId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
image.src = src;
|
||||
if (crossorigin !== undefined) image.crossOrigin = crossorigin as string;
|
||||
if (referrerPolicy) image.referrerPolicy = referrerPolicy;
|
||||
|
||||
this.setStatus('loading');
|
||||
|
||||
image.onload = () => {
|
||||
const delay = this.opts.delayMs.current;
|
||||
if (delay > 0) {
|
||||
timerId = setTimeout(() => this.setStatus('loaded'), delay);
|
||||
} else {
|
||||
this.setStatus('loaded');
|
||||
}
|
||||
};
|
||||
image.onerror = () => {
|
||||
this.setStatus('error');
|
||||
};
|
||||
|
||||
return () => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
if (timerId !== undefined) clearTimeout(timerId);
|
||||
};
|
||||
}
|
||||
|
||||
readonly snippetProps = $derived.by(() => ({
|
||||
status: this.opts.status.current
|
||||
}));
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
'data-status': this.opts.status.current
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Image ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AvatarImageOpts
|
||||
extends WithRefOpts,
|
||||
ActiveProps<{
|
||||
src: string | null | undefined;
|
||||
crossOrigin: CrossOrigin;
|
||||
referrerPolicy: ReferrerPolicy;
|
||||
}> {}
|
||||
|
||||
export class AvatarImageProvider extends Provider<AvatarImageOpts> {
|
||||
static create(opts: AvatarImageOpts) {
|
||||
return new AvatarImageProvider(opts);
|
||||
}
|
||||
|
||||
readonly provider: AvatarProvider;
|
||||
|
||||
private constructor(opts: AvatarImageOpts) {
|
||||
super(opts, 'Avatar', 'image', attrs.image);
|
||||
this.provider = AvatarProvider.require();
|
||||
|
||||
// Preload on src change. `watch.pre` runs synchronously so the
|
||||
// status flips to 'loading' before the first paint.
|
||||
watch.pre(
|
||||
[
|
||||
() => this.opts.src.current,
|
||||
() => this.opts.crossOrigin.current,
|
||||
() => this.opts.referrerPolicy.current
|
||||
],
|
||||
([src, crossOrigin, referrerPolicy]) => {
|
||||
if (!src) {
|
||||
this.provider.setStatus('error');
|
||||
return;
|
||||
}
|
||||
return this.provider.loadImage(src, crossOrigin, referrerPolicy);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
readonly props = $derived.by(() => {
|
||||
const status = this.provider.opts.status.current;
|
||||
return this.assertProps({
|
||||
...this.baseProps,
|
||||
// The img element exists in DOM for all states, but is hidden
|
||||
// until `loaded` so preloading doesn't flicker.
|
||||
style: {
|
||||
display: status === 'loaded' ? 'block' : 'none'
|
||||
},
|
||||
src: this.opts.src.current || undefined,
|
||||
crossorigin: this.opts.crossOrigin.current,
|
||||
referrerpolicy: this.opts.referrerPolicy.current,
|
||||
'data-status': status
|
||||
} as const);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Fallback ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface AvatarFallbackOpts extends WithRefOpts {}
|
||||
|
||||
export class AvatarFallbackProvider extends Provider<AvatarFallbackOpts> {
|
||||
static create(opts: AvatarFallbackOpts) {
|
||||
return new AvatarFallbackProvider(opts);
|
||||
}
|
||||
|
||||
readonly provider: AvatarProvider;
|
||||
|
||||
private constructor(opts: AvatarFallbackOpts) {
|
||||
super(opts, 'Avatar', 'fallback', attrs.fallback);
|
||||
this.provider = AvatarProvider.require();
|
||||
}
|
||||
|
||||
readonly props = $derived.by(() => {
|
||||
const status = this.provider.opts.status.current;
|
||||
// Visible while idle / loading / error. Hidden once image loaded.
|
||||
return this.assertProps({
|
||||
...this.baseProps,
|
||||
style: status === 'loaded' ? { display: 'none' } : undefined,
|
||||
'data-status': status
|
||||
} as const);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { AvatarFallbackProvider } from '../avatar-provider.svelte';
|
||||
import type { AvatarFallbackProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'avatar-fallback'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: AvatarFallbackProps = $props();
|
||||
|
||||
const state = AvatarFallbackProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<span {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
{/if}
|
||||
@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { AvatarImageProvider } from '../avatar-provider.svelte';
|
||||
import type { AvatarImageProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'avatar-image'),
|
||||
src,
|
||||
crossorigin,
|
||||
referrerpolicy,
|
||||
...restProps
|
||||
}: AvatarImageProps = $props();
|
||||
|
||||
const state = AvatarImageProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
src: readableActive(() => src),
|
||||
crossOrigin: readableActive(() => crossorigin),
|
||||
referrerPolicy: readableActive(() => referrerpolicy)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps({}, restProps, state.props));
|
||||
</script>
|
||||
|
||||
<img {...mergedProps} alt={restProps.alt ?? ''} />
|
||||
@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { AvatarProvider } from '../avatar-provider.svelte';
|
||||
import type { AvatarProps, AvatarLoadingStatus } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'avatar'),
|
||||
delayMs = 0,
|
||||
onStatusChange = () => {},
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: AvatarProps = $props();
|
||||
|
||||
let status = $state<AvatarLoadingStatus>('idle');
|
||||
|
||||
const provider = AvatarProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
status: writableActive(
|
||||
() => status,
|
||||
(v) => (status = v)
|
||||
),
|
||||
delayMs: readableActive(() => delayMs),
|
||||
onStatusChange: readableActive(() => onStatusChange)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...provider.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<span {...mergedProps}>
|
||||
{@render children?.(provider.snippetProps)}
|
||||
</span>
|
||||
{/if}
|
||||
@ -0,0 +1,11 @@
|
||||
export { default as Provider } from './components/avatar.svelte';
|
||||
export { default as Image } from './components/avatar-image.svelte';
|
||||
export { default as Fallback } from './components/avatar-fallback.svelte';
|
||||
|
||||
export type {
|
||||
AvatarProps as ProviderProps,
|
||||
AvatarImageProps as ImageProps,
|
||||
AvatarFallbackProps as FallbackProps,
|
||||
AvatarProviderSnippetProps as ProviderSnippetProps,
|
||||
AvatarLoadingStatus
|
||||
} from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,78 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { WithChild, Without, OnChangeFn } from '../../types';
|
||||
import type {
|
||||
PrimitiveDivAttributes,
|
||||
PrimitiveSpanAttributes
|
||||
} from '../../types';
|
||||
import type { HTMLImgAttributes } from 'svelte/elements';
|
||||
|
||||
/** Image loading status tracked internally. */
|
||||
export type AvatarLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';
|
||||
|
||||
/** Snippet props exposed by `Avatar.Provider`. */
|
||||
export type AvatarProviderSnippetProps = {
|
||||
status: AvatarLoadingStatus;
|
||||
};
|
||||
|
||||
// ── Root provider ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props for the root `Avatar.Provider`.
|
||||
*
|
||||
* Composes `Image` (the actual `<img>`) + `Fallback` (initials, icon, or
|
||||
* placeholder). The Provider preloads the image out-of-band; only when
|
||||
* `status='loaded'` does `Image` show and `Fallback` hide. A configurable
|
||||
* `delayMs` prevents fallback flash for fast-loading images.
|
||||
*/
|
||||
export type AvatarProps = WithChild<
|
||||
{
|
||||
/** DOM id. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/**
|
||||
* Minimum delay in ms before the Fallback shows. Prevents the
|
||||
* initials flashing for images that load in <100ms. @default 0
|
||||
*/
|
||||
delayMs?: number;
|
||||
/** Fires on every status transition. */
|
||||
onStatusChange?: OnChangeFn<AvatarLoadingStatus>;
|
||||
children?: Snippet<[AvatarProviderSnippetProps]>;
|
||||
},
|
||||
AvatarProviderSnippetProps
|
||||
> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
// ── Image ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props for `Avatar.Image`. Wraps `<img>`; the Provider preloads the URL
|
||||
* and only reveals the element (`display: block`) when `status='loaded'`.
|
||||
*
|
||||
* When `src` becomes `undefined` / `null`, the Provider flips to
|
||||
* `status='error'` so the Fallback shows.
|
||||
*/
|
||||
export type AvatarImageProps = WithChild<{
|
||||
id?: string;
|
||||
/** Image source. Required. */
|
||||
src: string | null | undefined;
|
||||
/** `crossorigin` attribute passed through to the img + preloader. */
|
||||
crossorigin?: HTMLImgAttributes['crossorigin'];
|
||||
/** `referrerpolicy` attribute passed through. */
|
||||
referrerpolicy?: HTMLImgAttributes['referrerpolicy'];
|
||||
}> &
|
||||
Without<
|
||||
HTMLImgAttributes,
|
||||
{ src?: unknown; crossorigin?: unknown; referrerpolicy?: unknown }
|
||||
>;
|
||||
|
||||
// ── Fallback ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props for `Avatar.Fallback` — shown while `status` is `idle`, `loading`,
|
||||
* or `error`. Consumer chooses the content (initials `<span>{JP}</span>`,
|
||||
* icon, placeholder image). The Provider hides it via inline `display: none`
|
||||
* once `status='loaded'`.
|
||||
*/
|
||||
export type AvatarFallbackProps = WithChild<{
|
||||
id?: string;
|
||||
}> &
|
||||
Without<PrimitiveSpanAttributes, {}>;
|
||||
@ -0,0 +1,174 @@
|
||||
# Clipboard
|
||||
|
||||
A `navigator.clipboard.writeText` wrapper with a `copied` state that auto-resets after a configurable timeout. Consumers compose a `Trigger` button (copy on click) and an optional `Indicator` that renders only while the copy is fresh.
|
||||
|
||||
`navigator.clipboard` is required — there is no `execCommand('copy')` fallback. 98%+ of browsers in 2026 support the async API, and the legacy API's synchronous-gesture requirement is incompatible with Svelte 5's microtask-based event handling.
|
||||
|
||||
## Anatomy
|
||||
|
||||
```svelte
|
||||
<Clipboard.Provider value="npm install soma">
|
||||
<Clipboard.Trigger>Copy</Clipboard.Trigger>
|
||||
<Clipboard.Indicator>Copied!</Clipboard.Indicator>
|
||||
</Clipboard.Provider>
|
||||
```
|
||||
|
||||
## Parts
|
||||
|
||||
| Part | Element | Description |
|
||||
| ----------- | ---------- | --------------------------------------------------------------------------- |
|
||||
| `Provider` | `<div>` | Root context. Holds `value` + `copied` + imperative `copy()`. |
|
||||
| `Trigger` | `<button>` | Copies on click. Announces `data-copied` for visual feedback. |
|
||||
| `Indicator` | `<span>` | Renders only while `copied=true` (unless `forceMount`). Decorative. |
|
||||
|
||||
## Props
|
||||
|
||||
### `Provider`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ---------- | ------------------------------- | ------- | ------------------------------------------------------------------------- |
|
||||
| `id` | `string` | auto | DOM id. |
|
||||
| `value` | `string` | — | **Required.** String to copy. |
|
||||
| `timeout` | `number` | `2000` | Ms to keep `copied=true` after a successful copy. |
|
||||
| `onCopy` | `(value: string) => void` | — | Fires on successful copy. |
|
||||
| `onError` | `(err: unknown) => void` | — | Fires when the clipboard API throws (permissions, blur, missing API). |
|
||||
|
||||
Snippet props: `{ value, copied, copy }`.
|
||||
|
||||
### `Trigger`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------ | -------- | --------------------------------- | ------------------- |
|
||||
| `id` | `string` | auto | DOM id. |
|
||||
| `aria-label` | `string` | translated `Copy to clipboard` / `Copied` (swaps with state) | Accessible name. |
|
||||
|
||||
### `Indicator`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------ | --------- | ------- | --------------------------------------------------------------- |
|
||||
| `id` | `string` | auto | DOM id. |
|
||||
| `forceMount` | `boolean` | `false` | Keep in DOM even when `copied=false`, for CSS exit transitions. |
|
||||
|
||||
## ARIA
|
||||
|
||||
| Part | Attribute | Value |
|
||||
| --------- | ----------------- | ----------------------------------------------------- |
|
||||
| Trigger | `type` | `button` |
|
||||
| Trigger | `aria-label` | Translated `Copy to clipboard` → `Copied` on transition |
|
||||
| Trigger | `aria-live` | `polite` — announces the label change |
|
||||
| Indicator | `aria-hidden` | `true` (decorative; the Trigger's label is the source of truth) |
|
||||
|
||||
## Data Attributes
|
||||
|
||||
| Part | Attribute | Values |
|
||||
| --------- | --------------------------- | ---------------------------------------------------- |
|
||||
| Provider | `data-clipboard` | Always present |
|
||||
| Provider | `data-copied` | Present (empty string) while fresh |
|
||||
| Trigger | `data-clipboard-trigger` | Always present |
|
||||
| Trigger | `data-copied` | Present while fresh |
|
||||
| Indicator | `data-clipboard-indicator` | Always present when rendered |
|
||||
| Indicator | `data-copied` | Always present (Indicator only renders while copied) |
|
||||
|
||||
## Keyboard
|
||||
|
||||
| Key | Action |
|
||||
| ---------------- | ---------------------------------- |
|
||||
| `Enter` / `Space` | Clicks the Trigger → calls `copy()` |
|
||||
|
||||
## i18n
|
||||
|
||||
| Key | English | Spanish |
|
||||
| -------- | ------------------ | ----------------------- |
|
||||
| `copy` | `Copy to clipboard` | `Copiar al portapapeles` |
|
||||
| `copied` | `Copied` | `Copiado` |
|
||||
|
||||
Override per-instance via `aria-label` on `Trigger`.
|
||||
|
||||
## Comparison
|
||||
|
||||
| Feature | Soma | Radix | Ark UI | bits-ui |
|
||||
| --------------------------------- | :--: | :---: | :----: | :-----: |
|
||||
| Dedicated component | ✅ | ❌¹ | ✅ | ❌ |
|
||||
| `navigator.clipboard.writeText` | ✅ | — | ✅ | — |
|
||||
| Auto-reset `copied` flag | ✅ | — | ✅ | — |
|
||||
| Configurable `timeout` | ✅ | — | ✅ | — |
|
||||
| `onCopy` / `onError` callbacks | ✅ | — | ✅ | — |
|
||||
| Indicator conditional mount | ✅ | — | ✅ | — |
|
||||
| `data-copied` attr | ✅ | — | ✅ | — |
|
||||
| `aria-live` on Trigger label | ✅ | — | ❌ | — |
|
||||
| Translated label swap | ✅ | — | ❌ | — |
|
||||
| `execCommand` fallback | ❌² | — | ❌ | — |
|
||||
|
||||
¹ Radix does not ship a Clipboard primitive. Consumers roll their own `navigator.clipboard` call + `useState(copied)`.
|
||||
² Dropped by design — see the intro note. The legacy API requires a synchronous gesture path that Svelte 5 does not provide.
|
||||
|
||||
## Usage
|
||||
|
||||
### Button with state swap
|
||||
|
||||
```svelte
|
||||
<Clipboard.Provider value={code}>
|
||||
{#snippet children({ copied })}
|
||||
<Clipboard.Trigger>
|
||||
{copied ? '✓ Copied!' : 'Copy'}
|
||||
</Clipboard.Trigger>
|
||||
{/snippet}
|
||||
</Clipboard.Provider>
|
||||
```
|
||||
|
||||
### Separate Indicator (icon swap)
|
||||
|
||||
```svelte
|
||||
<Clipboard.Provider value={code}>
|
||||
<Clipboard.Trigger>
|
||||
<CopyIcon />
|
||||
</Clipboard.Trigger>
|
||||
<Clipboard.Indicator>
|
||||
<CheckIcon />
|
||||
</Clipboard.Indicator>
|
||||
</Clipboard.Provider>
|
||||
```
|
||||
|
||||
### Imperative copy (no button)
|
||||
|
||||
```svelte
|
||||
<Clipboard.Provider value={shareUrl}>
|
||||
{#snippet children({ copy, copied })}
|
||||
<p>Link copied automatically</p>
|
||||
<button onclick={() => copy()}>Share</button>
|
||||
{#if copied}<span role="status">Done!</span>{/if}
|
||||
{/snippet}
|
||||
</Clipboard.Provider>
|
||||
```
|
||||
|
||||
### Styling with `data-copied`
|
||||
|
||||
```css
|
||||
[data-clipboard-trigger] {
|
||||
transition: background 150ms;
|
||||
}
|
||||
[data-clipboard-trigger][data-copied] {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
```
|
||||
|
||||
### Error handling
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let error = $state<string | null>(null);
|
||||
</script>
|
||||
|
||||
<Clipboard.Provider
|
||||
value={code}
|
||||
onError={(err) => {
|
||||
error = err instanceof Error ? err.message : 'Could not copy';
|
||||
}}
|
||||
>
|
||||
<Clipboard.Trigger>Copy</Clipboard.Trigger>
|
||||
</Clipboard.Provider>
|
||||
{#if error}<p role="alert">{error}</p>{/if}
|
||||
```
|
||||
|
||||
Permission denial (e.g. the browser blocks the API in insecure contexts) reaches `onError`; the Provider doesn't swallow it.
|
||||
@ -0,0 +1,177 @@
|
||||
import { Provider, context, type WithRefOpts } from '../../provider';
|
||||
import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs';
|
||||
import { readableActive, type Active, type ActiveProps } from '../../reactive';
|
||||
import type { OnChangeFn, SomaMouseEvent } from '../../types';
|
||||
import { Soma } from '../../core/soma.svelte';
|
||||
import { CLIPBOARD_LANGS } from './langs';
|
||||
|
||||
const attrs = createAttrs({
|
||||
component: 'clipboard',
|
||||
parts: ['root', 'trigger', 'indicator'] as const
|
||||
});
|
||||
|
||||
registerContract({
|
||||
name: 'clipboard',
|
||||
version: 1,
|
||||
parts: {
|
||||
root: [{ attr: 'data-copied', description: 'Fresh copy within timeout window' }],
|
||||
trigger: [{ attr: 'data-copied', description: 'Fresh copy within timeout window' }],
|
||||
indicator: [{ attr: 'data-copied', description: 'Always present when rendered' }]
|
||||
}
|
||||
});
|
||||
|
||||
// ── Root provider ──────────────────────────────────────────────────────────
|
||||
|
||||
interface ClipboardOpts
|
||||
extends WithRefOpts,
|
||||
ActiveProps<{
|
||||
value: string;
|
||||
timeout: number;
|
||||
onCopy: OnChangeFn<string> | undefined;
|
||||
onError: ((err: unknown) => void) | undefined;
|
||||
}> {}
|
||||
|
||||
export class ClipboardProvider extends Provider<ClipboardOpts> {
|
||||
static readonly ctx = context<ClipboardProvider>('Clipboard');
|
||||
static get(): ClipboardProvider | undefined {
|
||||
return this.ctx.getOr(undefined) as ClipboardProvider | undefined;
|
||||
}
|
||||
static require(): ClipboardProvider {
|
||||
return this.ctx.get();
|
||||
}
|
||||
|
||||
static create(opts: ClipboardOpts) {
|
||||
return new ClipboardProvider(opts);
|
||||
}
|
||||
|
||||
readonly soma = Soma.get();
|
||||
|
||||
copied = $state(false);
|
||||
private resetTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private constructor(opts: ClipboardOpts) {
|
||||
super(opts, 'Clipboard', 'root', attrs.root, ClipboardProvider.ctx);
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (this.resetTimer) clearTimeout(this.resetTimer);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the current value. Returns a promise that resolves when the
|
||||
* clipboard write finishes (or rejects if the API throws / is missing).
|
||||
* Also schedules a `copied=false` reset after `timeout` ms.
|
||||
*/
|
||||
copy = async (): Promise<void> => {
|
||||
const value = this.opts.value.current;
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard) {
|
||||
const err = new Error(
|
||||
'navigator.clipboard is not available in this environment.'
|
||||
);
|
||||
this.opts.onError.current?.(err);
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
this.copied = true;
|
||||
this.opts.onCopy.current?.(value);
|
||||
|
||||
if (this.resetTimer) clearTimeout(this.resetTimer);
|
||||
this.resetTimer = setTimeout(() => {
|
||||
this.copied = false;
|
||||
this.resetTimer = null;
|
||||
}, this.opts.timeout.current);
|
||||
} catch (err) {
|
||||
this.opts.onError.current?.(err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
readonly snippetProps = $derived.by(() => ({
|
||||
value: this.opts.value.current,
|
||||
copied: this.copied,
|
||||
copy: this.copy
|
||||
}));
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
'data-copied': boolToEmptyStrOrUndef(this.copied)
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Trigger ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ClipboardTriggerOpts
|
||||
extends WithRefOpts,
|
||||
ActiveProps<{ ariaLabel: string | undefined }> {}
|
||||
|
||||
export class ClipboardTriggerProvider extends Provider<ClipboardTriggerOpts> {
|
||||
static create(opts: ClipboardTriggerOpts) {
|
||||
return new ClipboardTriggerProvider(opts);
|
||||
}
|
||||
|
||||
readonly provider: ClipboardProvider;
|
||||
|
||||
private constructor(opts: ClipboardTriggerOpts) {
|
||||
super(opts, 'Clipboard', 'trigger', attrs.trigger);
|
||||
this.provider = ClipboardProvider.require();
|
||||
}
|
||||
|
||||
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(() => {
|
||||
const override = this.opts.ariaLabel.current;
|
||||
if (override) return override;
|
||||
// When copied, announce the copied state; otherwise the copy action.
|
||||
return this.provider.copied
|
||||
? this.provider.soma?.langs.ts(CLIPBOARD_LANGS.COPIED)
|
||||
: this.provider.soma?.langs.ts(CLIPBOARD_LANGS.COPY);
|
||||
});
|
||||
|
||||
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
|
||||
this.provider.copy().catch(() => {
|
||||
// onError already fired from the provider; swallow here to avoid
|
||||
// unhandled-rejection noise on button click.
|
||||
});
|
||||
};
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
type: 'button' as const,
|
||||
'aria-label': this.resolvedAriaLabel.current,
|
||||
'aria-live': 'polite' as const,
|
||||
'data-copied': boolToEmptyStrOrUndef(this.provider.copied),
|
||||
onclick: this.onclick
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Indicator ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface ClipboardIndicatorOpts extends WithRefOpts {}
|
||||
|
||||
export class ClipboardIndicatorProvider extends Provider<ClipboardIndicatorOpts> {
|
||||
static create(opts: ClipboardIndicatorOpts) {
|
||||
return new ClipboardIndicatorProvider(opts);
|
||||
}
|
||||
|
||||
readonly provider: ClipboardProvider;
|
||||
|
||||
private constructor(opts: ClipboardIndicatorOpts) {
|
||||
super(opts, 'Clipboard', 'indicator', attrs.indicator);
|
||||
this.provider = ClipboardProvider.require();
|
||||
}
|
||||
|
||||
readonly isPresent = $derived.by(() => this.provider.copied);
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
'aria-hidden': true as const,
|
||||
'data-copied': ''
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { ClipboardIndicatorProvider } from '../clipboard-provider.svelte';
|
||||
import type { ClipboardIndicatorProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'clipboard-indicator'),
|
||||
forceMount = false,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: ClipboardIndicatorProps = $props();
|
||||
|
||||
const state = ClipboardIndicatorProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if state.isPresent || forceMount}
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<span {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { ClipboardTriggerProvider } from '../clipboard-provider.svelte';
|
||||
import type { ClipboardTriggerProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'clipboard-trigger'),
|
||||
'aria-label': ariaLabel,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: ClipboardTriggerProps = $props();
|
||||
|
||||
const state = ClipboardTriggerProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
ariaLabel: readableActive(() => ariaLabel)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { ClipboardProvider } from '../clipboard-provider.svelte';
|
||||
import type { ClipboardProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'clipboard'),
|
||||
value,
|
||||
timeout = 2000,
|
||||
onCopy,
|
||||
onError,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: ClipboardProps = $props();
|
||||
|
||||
const state = ClipboardProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
value: readableActive(() => value),
|
||||
timeout: readableActive(() => timeout),
|
||||
onCopy: readableActive(() => onCopy),
|
||||
onError: readableActive(() => onError)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...state.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.(state.snippetProps)}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,10 @@
|
||||
export { default as Provider } from './components/clipboard.svelte';
|
||||
export { default as Trigger } from './components/clipboard-trigger.svelte';
|
||||
export { default as Indicator } from './components/clipboard-indicator.svelte';
|
||||
|
||||
export type {
|
||||
ClipboardProps as ProviderProps,
|
||||
ClipboardTriggerProps as TriggerProps,
|
||||
ClipboardIndicatorProps as IndicatorProps,
|
||||
ClipboardProviderSnippetProps as ProviderSnippetProps
|
||||
} from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,5 @@
|
||||
/** Idlangref constants for the Clipboard component. */
|
||||
export const CLIPBOARD_LANGS = {
|
||||
COPY: '#?components.clipboard.copy|Copy to clipboard',
|
||||
COPIED: '#?components.clipboard.copied|Copied',
|
||||
} as const;
|
||||
@ -0,0 +1,81 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { WithChild, Without, OnChangeFn } from '../../types';
|
||||
import type {
|
||||
PrimitiveDivAttributes,
|
||||
PrimitiveButtonAttributes,
|
||||
PrimitiveSpanAttributes
|
||||
} from '../../types';
|
||||
|
||||
/** Snippet props exposed by `Clipboard.Provider`. */
|
||||
export type ClipboardProviderSnippetProps = {
|
||||
/** Current value that gets copied on trigger. */
|
||||
value: string;
|
||||
/** `true` for `timeout` ms after a successful copy. */
|
||||
copied: boolean;
|
||||
/** Imperative: call to copy immediately (bypasses Trigger click). */
|
||||
copy: () => Promise<void>;
|
||||
};
|
||||
|
||||
// ── Root provider ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props for the root `Clipboard.Provider`.
|
||||
*
|
||||
* Wraps `navigator.clipboard.writeText` with a `copied` state that auto-
|
||||
* resets after `timeout` ms. Exposes `Trigger` (the button that triggers
|
||||
* the copy) and `Indicator` (only visible while `copied=true`).
|
||||
*
|
||||
* `navigator.clipboard` is required — no `execCommand('copy')` fallback.
|
||||
* 98% of browsers in 2026 support the async API; the fallback's security
|
||||
* model (requires direct user gesture + sync execution) is incompatible
|
||||
* with Svelte 5's microtask-based event handling.
|
||||
*/
|
||||
export type ClipboardProps = WithChild<
|
||||
{
|
||||
/** DOM id. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
|
||||
/** String to copy. Required. */
|
||||
value: string;
|
||||
/** Alias for value if preferred ergonomically. */
|
||||
/** ms to keep `copied=true` after a successful copy. @default 2000 */
|
||||
timeout?: number;
|
||||
|
||||
/** Fires on successful copy. */
|
||||
onCopy?: OnChangeFn<string>;
|
||||
/** Fires when clipboard API throws (permission denied, blur, etc.). */
|
||||
onError?: (err: unknown) => void;
|
||||
|
||||
children?: Snippet<[ClipboardProviderSnippetProps]>;
|
||||
},
|
||||
ClipboardProviderSnippetProps
|
||||
> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
// ── Trigger ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props for `Clipboard.Trigger` — button that copies `Provider.value` on
|
||||
* click. Emits `data-copied` while the copy is fresh so CSS can style
|
||||
* feedback (color change, icon swap via `:not(.copied)` / `.copied`).
|
||||
*/
|
||||
export type ClipboardTriggerProps = WithChild<{
|
||||
id?: string;
|
||||
/** Accessible name. @default translated `'Copy to clipboard'` */
|
||||
'aria-label'?: string;
|
||||
}> &
|
||||
Without<PrimitiveButtonAttributes, { 'aria-label'?: string }>;
|
||||
|
||||
// ── Indicator ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props for `Clipboard.Indicator` — decorative marker that renders only
|
||||
* while `copied=true` (unless `forceMount`). Consumer puts a "Copied!"
|
||||
* label or a check icon inside.
|
||||
*/
|
||||
export type ClipboardIndicatorProps = WithChild<{
|
||||
id?: string;
|
||||
/** Keep in DOM while not copied, for CSS exit transitions. @default false */
|
||||
forceMount?: boolean;
|
||||
}> &
|
||||
Without<PrimitiveSpanAttributes, {}>;
|
||||
@ -0,0 +1,212 @@
|
||||
# DragDrop
|
||||
|
||||
A headless drag-and-drop system — pointer + keyboard + screen reader. Coordinates draggable sources and drop targets within a Provider boundary, fires a single `onDrop` event with a typed payload, and announces each drag step through the global `Announce` live region.
|
||||
|
||||
Soma chooses this system over HTML5 native `draggable` because HTML5 DnD has poor mobile support, incompatible cross-browser dataTransfer semantics, and no built-in keyboard model. This primitive works everywhere pointer events work (mouse, touch, pen) plus a full WAI-ARIA-aligned keyboard contract.
|
||||
|
||||
## Anatomy
|
||||
|
||||
```svelte
|
||||
<DragDrop.Provider onDrop={(e) => moveItem(e.value, e.target)}>
|
||||
<DragDrop.Draggable value="task-1" data={{ kind: 'task' }}>
|
||||
Drag me
|
||||
</DragDrop.Draggable>
|
||||
|
||||
<DragDrop.Droppable accept={(data) => data.kind === 'task'} textValue="Done">
|
||||
Drop here
|
||||
</DragDrop.Droppable>
|
||||
|
||||
<DragDrop.Preview>
|
||||
{#snippet children({ active })}
|
||||
<div class="ghost">{active.label}</div>
|
||||
{/snippet}
|
||||
</DragDrop.Preview>
|
||||
</DragDrop.Provider>
|
||||
```
|
||||
|
||||
## Parts
|
||||
|
||||
| Part | Element | Description |
|
||||
| ----------- | ---------- | -------------------------------------------------------------------- |
|
||||
| `Provider` | `<div>` | Coordinates the drag gesture across descendants. Announces ARIA. |
|
||||
| `Draggable` | `<div>` | An item that can be picked up. Pointer + keyboard gesture host. |
|
||||
| `Droppable` | `<div>` | A target that accepts drops. Filters via `accept(data, value)`. |
|
||||
| `Preview` | `<div>` | Optional floating ghost element that follows the pointer during drag.|
|
||||
|
||||
## Props
|
||||
|
||||
### `Provider`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------------ | --------------------------------------------------------------------- | ------- | ------------------------------------------------------------- |
|
||||
| `id` | `string` | auto | DOM id. |
|
||||
| `onDragStart` | `(e: { value, data, preventDefault }) => void` | — | Fires before drag starts. Call `preventDefault` to cancel. |
|
||||
| `onDragEnd` | `(e: DragEndEvent) => void` | — | Fires on drop or cancel. |
|
||||
| `onDrop` | `(e: DropEvent) => void` | — | Fires on successful drop. |
|
||||
| `announceEnabled` | `boolean` | `true` | Emit live-region announcements via `Announce` global API. |
|
||||
|
||||
Snippet props: `{ active, cancel }`.
|
||||
|
||||
### `Draggable`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------ | -------------------- | ------- | --------------------------------------------------------------------- |
|
||||
| `id` | `string` | auto | DOM id. |
|
||||
| `value` | `string` | — | **Required.** Unique identifier; returned in `onDrop.value`. |
|
||||
| `data` | `Record<string, any>`| `{}` | Payload; returned in `onDrop.data`. |
|
||||
| `disabled` | `boolean` | `false` | Disables dragging. |
|
||||
| `moveBuffer` | `number` | `5` | Pixels the pointer must move before a drag is recognised. |
|
||||
| `textValue` | `string` | — | Label for announcements. Falls back to aria-label → textContent. |
|
||||
|
||||
Snippet props: `{ dragging, handleProps }`.
|
||||
|
||||
### `Droppable`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | ----------------------------------------- | --------------- | ----------------------------------------------------------------- |
|
||||
| `id` | `string` | auto | DOM id. |
|
||||
| `accept` | `(data, value) => boolean` | `() => true` | Filter — returning `false` removes this target from keyboard nav. |
|
||||
| `disabled` | `boolean` | `false` | Disables all drops on this element. |
|
||||
| `textValue` | `string` | — | Label for announcements. |
|
||||
|
||||
Snippet props: `{ over, accepting }`.
|
||||
|
||||
## ARIA
|
||||
|
||||
| Part | Attribute | Value |
|
||||
| --------- | ------------------------ | ------------------------------------------------------------ |
|
||||
| Draggable | `role` | `button` |
|
||||
| Draggable | `aria-roledescription` | Translated `'draggable'` |
|
||||
| Draggable | `aria-grabbed` | `true` during active drag (legacy but widely supported) |
|
||||
| Draggable | `tabindex` | `0` (disabled: `-1`) |
|
||||
| Droppable | `role` | `region` |
|
||||
| Droppable | `aria-roledescription` | Translated `'drop zone'` |
|
||||
| Droppable | `aria-dropeffect` | `'move'` while accepting, `'none'` otherwise |
|
||||
| Droppable | `tabindex` | `0` during drag if accepting; `-1` otherwise |
|
||||
|
||||
Each drag step is announced via the `Announce` global API (see the component's [langs.ts](./langs.ts)): drag-started, drag-over-target, dropped, cancelled, no-targets-available. Disable with `announceEnabled={false}` on the Provider when wiring your own.
|
||||
|
||||
## Data Attributes
|
||||
|
||||
| Part | Attribute | Values |
|
||||
| --------- | --------------------------------- | -------------------------------- |
|
||||
| Provider | `data-drag-drop` | Always present |
|
||||
| Provider | `data-dragging` | Present during active drag |
|
||||
| Draggable | `data-drag-drop-draggable` | Always present |
|
||||
| Draggable | `data-dragging` | This draggable is the source |
|
||||
| Draggable | `data-disabled` / `data-value` / `data-text-value` | — |
|
||||
| Droppable | `data-drag-drop-droppable` | Always present |
|
||||
| Droppable | `data-dragover` | Pointer / focus over this target |
|
||||
| Droppable | `data-accepting` | Would accept the current drag |
|
||||
| Droppable | `data-disabled` | Drops disabled |
|
||||
| Preview | `data-drag-drop-preview` | Always present |
|
||||
| Preview | `data-active` | Present during active drag |
|
||||
|
||||
## Keyboard
|
||||
|
||||
| Focus on | Key | Action |
|
||||
| ----------- | ---------------------------- | -------------------------------------------------------------------- |
|
||||
| Draggable | `Space` / `Enter` | Begin drag. Focus jumps to the first accepting Droppable. |
|
||||
| Droppable | `ArrowDown` / `ArrowRight` / `Tab` | Move to next accepting Droppable (wraps). |
|
||||
| Droppable | `ArrowUp` / `ArrowLeft` / `Shift+Tab` | Move to previous accepting Droppable. |
|
||||
| Droppable | `Space` / `Enter` | Drop. |
|
||||
| any | `Escape` | Cancel the drag. Focus returns to the source Draggable. |
|
||||
|
||||
Droppables are explicitly added/removed from the tab order based on the accept filter — a disabled or non-accepting target is silently skipped during keyboard navigation.
|
||||
|
||||
## Comparison
|
||||
|
||||
| Feature | Soma | Radix | Ark UI | react-aria | dnd-kit |
|
||||
| ------------------------------------ | :--: | :---: | :----: | :--------: | :-----: |
|
||||
| Pointer (mouse + touch + pen) | ✅ | ❌ | ❌ | ✅ | ✅ |
|
||||
| Keyboard drag (Space/Enter/Escape) | ✅ | — | — | ✅ | ✅ |
|
||||
| Arrow / Tab to navigate drop targets | ✅ | — | — | ✅ | ⚠️¹ |
|
||||
| Accept filter per target | ✅ | — | — | ✅ | ✅ |
|
||||
| Typed `data` payload | ✅ | — | — | ✅ | ✅ |
|
||||
| Cancel (Escape → source focus) | ✅ | — | — | ✅ | ✅ |
|
||||
| Live-region announcements | ✅ | — | — | ✅ | ⚠️² |
|
||||
| Translated messages | ✅ | — | — | ❌ | ❌ |
|
||||
| DragPreview (ghost element) | ✅ | — | — | ✅ | ✅ |
|
||||
| Integration with Announce global API | ✅ | — | — | — | — |
|
||||
| Sortable (within-list reorder) | ⚠️³ | — | — | ✅ | ✅ |
|
||||
| Auto-scroll near viewport edges | ❌⁴ | — | — | ✅ | ✅ |
|
||||
|
||||
¹ dnd-kit uses keyboard sensors that move items directionally; no explicit Tab between targets.
|
||||
² dnd-kit provides announcer hooks; consumers must wire the message strings themselves.
|
||||
³ Consumers implement sort by rendering N Droppables between items (one before, one after each row) and collapsing empty slots. Works but more verbose than a `<Sortable>` wrapper. First-class Sortable primitive can land when a consumer hits the verbosity wall.
|
||||
⁴ Use native CSS overflow + scroll behavior of the container. Auto-scroll on viewport edges is a plausible future addition — the API surface would be a `autoScroll={true}` prop on the Provider.
|
||||
|
||||
## Usage
|
||||
|
||||
### Kanban board
|
||||
|
||||
```svelte
|
||||
<DragDrop.Provider onDrop={moveTask}>
|
||||
{#each columns as col (col.id)}
|
||||
<DragDrop.Droppable
|
||||
accept={(data) => col.accepts.includes(data.kind)}
|
||||
textValue={col.title}
|
||||
>
|
||||
<h3>{col.title}</h3>
|
||||
{#each col.tasks as t (t.id)}
|
||||
<DragDrop.Draggable value={t.id} data={{ kind: t.kind }} textValue={t.title}>
|
||||
{t.title}
|
||||
</DragDrop.Draggable>
|
||||
{/each}
|
||||
</DragDrop.Droppable>
|
||||
{/each}
|
||||
|
||||
<DragDrop.Preview>
|
||||
{#snippet children({ active })}
|
||||
<span class="chip">{active.label}</span>
|
||||
{/snippet}
|
||||
</DragDrop.Preview>
|
||||
</DragDrop.Provider>
|
||||
```
|
||||
|
||||
### File explorer drop zone
|
||||
|
||||
```svelte
|
||||
<DragDrop.Provider onDrop={moveFile}>
|
||||
<TreeGrid.Provider>
|
||||
{#each files as f (f.id)}
|
||||
<TreeGrid.Row value={f.id}>
|
||||
<TreeGrid.Cell>
|
||||
<DragDrop.Draggable value={f.id} data={{ kind: 'file' }} textValue={f.name}>
|
||||
{f.name}
|
||||
</DragDrop.Draggable>
|
||||
</TreeGrid.Cell>
|
||||
</TreeGrid.Row>
|
||||
{/each}
|
||||
</TreeGrid.Provider>
|
||||
|
||||
{#each folders as folder (folder.id)}
|
||||
<DragDrop.Droppable
|
||||
accept={(data, value) => data.kind === 'file' && !folder.contains(value)}
|
||||
textValue={folder.name}
|
||||
>
|
||||
📁 {folder.name}
|
||||
</DragDrop.Droppable>
|
||||
{/each}
|
||||
</DragDrop.Provider>
|
||||
```
|
||||
|
||||
### Sortable list (reorder)
|
||||
|
||||
Render a Droppable "gap" between every pair of items. Each gap's `accept` returns true when the dragged item isn't adjacent to that gap (the move would be a no-op).
|
||||
|
||||
```svelte
|
||||
<DragDrop.Provider onDrop={(e) => reorder(e.value, parseInt(e.target.dataset.gapIndex ?? '0'))}>
|
||||
{#each items as item, i (item.id)}
|
||||
<DragDrop.Droppable
|
||||
accept={(_, v) => v !== item.id && v !== items[i - 1]?.id}
|
||||
textValue="Gap {i}"
|
||||
data-gap-index={i}
|
||||
/>
|
||||
<DragDrop.Draggable value={item.id} textValue={item.label}>
|
||||
{item.label}
|
||||
</DragDrop.Draggable>
|
||||
{/each}
|
||||
<DragDrop.Droppable textValue="End" data-gap-index={items.length} />
|
||||
</DragDrop.Provider>
|
||||
```
|
||||
@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { DraggableProvider } from '../drag-drop-provider.svelte';
|
||||
import type { DraggableProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'drag-drop-draggable'),
|
||||
value,
|
||||
data = {},
|
||||
disabled = false,
|
||||
moveBuffer = 5,
|
||||
textValue,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: DraggableProps = $props();
|
||||
|
||||
const provider = DraggableProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
value: readableActive(() => value),
|
||||
data: readableActive(() => data),
|
||||
disabled: readableActive(() => disabled),
|
||||
moveBuffer: readableActive(() => moveBuffer),
|
||||
textValue: readableActive(() => textValue)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...provider.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.(provider.snippetProps)}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { DroppableProvider } from '../drag-drop-provider.svelte';
|
||||
import type { DroppableProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'drag-drop-droppable'),
|
||||
accept,
|
||||
disabled = false,
|
||||
textValue,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: DroppableProps = $props();
|
||||
|
||||
const provider = DroppableProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
accept: readableActive(() => accept),
|
||||
disabled: readableActive(() => disabled),
|
||||
textValue: readableActive(() => textValue)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...provider.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.(provider.snippetProps)}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { readableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { DragPreviewProvider } from '../drag-drop-provider.svelte';
|
||||
import type { DragPreviewProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
id = createId(uid, 'drag-drop-preview'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: DragPreviewProps = $props();
|
||||
|
||||
const provider = DragPreviewProvider.create({
|
||||
id: readableActive(() => id)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if provider.provider.active}
|
||||
{#if child}
|
||||
{@render child({ active: provider.provider.active, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.({ active: provider.provider.active })}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { DragDropProvider } from '../drag-drop-provider.svelte';
|
||||
import type { DragDropProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'drag-drop'),
|
||||
onDragStart = () => {},
|
||||
onDragEnd = () => {},
|
||||
onDrop = () => {},
|
||||
announceEnabled = true,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: DragDropProps = $props();
|
||||
|
||||
const provider = DragDropProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
onDragStart: readableActive(() => onDragStart),
|
||||
onDragEnd: readableActive(() => onDragEnd),
|
||||
onDrop: readableActive(() => onDrop),
|
||||
announceEnabled: readableActive(() => announceEnabled)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...provider.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.(provider.snippetProps)}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,634 @@
|
||||
import { Provider, context, type WithRefOpts, type ProviderOpts } from '../../provider';
|
||||
import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs';
|
||||
import { readableActive, type Active, type ActiveProps } from '../../reactive';
|
||||
import type { SomaKeyboardEvent } from '../../types';
|
||||
import { KEYS } from '../../keyboard';
|
||||
import { Soma } from '../../core/soma.svelte';
|
||||
import { createAnnouncer } from '../announce/global.svelte';
|
||||
import { DRAG_DROP_LANGS } from './langs';
|
||||
import type { ActiveDrag, DragData, DragEndEvent, DropEvent } from './types';
|
||||
|
||||
const attrs = createAttrs({
|
||||
component: 'drag-drop',
|
||||
parts: ['root', 'draggable', 'droppable', 'preview'] as const
|
||||
});
|
||||
|
||||
registerContract({
|
||||
name: 'drag-drop',
|
||||
version: 1,
|
||||
parts: {
|
||||
root: [
|
||||
{ attr: 'data-dragging', description: 'A drag is currently active' }
|
||||
],
|
||||
draggable: [
|
||||
{ attr: 'data-dragging', description: 'This draggable is the active source' },
|
||||
{ attr: 'data-disabled', description: 'Dragging disabled' }
|
||||
],
|
||||
droppable: [
|
||||
{ attr: 'data-dragover', description: 'The active drag is currently hovering' },
|
||||
{ attr: 'data-accepting', description: 'Would accept the current drag' },
|
||||
{ attr: 'data-disabled', description: 'Drops disabled' }
|
||||
],
|
||||
preview: [
|
||||
{ attr: 'data-active', description: 'Rendered during an active drag' }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const globalAnnounce = /* @__PURE__ */ createAnnouncer(1500);
|
||||
|
||||
interface DragDropOpts
|
||||
extends WithRefOpts,
|
||||
ActiveProps<{
|
||||
onDragStart:
|
||||
| ((e: { value: string; data: DragData; preventDefault: () => void }) => void)
|
||||
| undefined;
|
||||
onDragEnd: ((e: DragEndEvent) => void) | undefined;
|
||||
onDrop: ((e: DropEvent) => void) | undefined;
|
||||
announceEnabled: boolean;
|
||||
}> {}
|
||||
|
||||
// ── Root provider ──────────────────────────────────────────────────────────
|
||||
|
||||
export class DragDropProvider extends Provider<DragDropOpts> {
|
||||
static readonly ctx = context<DragDropProvider>('DragDrop');
|
||||
static get(): DragDropProvider | undefined {
|
||||
return this.ctx.getOr(undefined) as DragDropProvider | undefined;
|
||||
}
|
||||
static require(): DragDropProvider {
|
||||
return this.ctx.get();
|
||||
}
|
||||
|
||||
static create(opts: DragDropOpts) {
|
||||
return new DragDropProvider(opts);
|
||||
}
|
||||
|
||||
readonly soma = Soma.get();
|
||||
|
||||
/** Currently active drag (pointer or keyboard). */
|
||||
active = $state<ActiveDrag | null>(null);
|
||||
/** Droppable element currently under the pointer / keyboard focus. */
|
||||
overTarget = $state<HTMLElement | null>(null);
|
||||
/** `true` when the active drag originated from keyboard Space/Enter. */
|
||||
keyboard = $state(false);
|
||||
|
||||
private pointerMoveListener: ((e: PointerEvent) => void) | null = null;
|
||||
private pointerUpListener: ((e: PointerEvent) => void) | null = null;
|
||||
private keyListener: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
private constructor(opts: DragDropOpts) {
|
||||
super(opts, 'DragDrop', 'root', attrs.root, DragDropProvider.ctx);
|
||||
$effect(() => {
|
||||
return () => this.teardown();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Announcements ───────────────────────────────────────────────────────
|
||||
|
||||
private announce(key: string, priority: 'polite' | 'assertive' = 'polite') {
|
||||
if (!this.opts.announceEnabled.current) return;
|
||||
const ling = this.soma?.langs;
|
||||
const text = ling ? ling.ts(key) : key;
|
||||
globalAnnounce.announce(text, priority);
|
||||
}
|
||||
|
||||
private announceTemplate(
|
||||
key: string,
|
||||
vars: Record<string, string>,
|
||||
priority: 'polite' | 'assertive' = 'polite'
|
||||
) {
|
||||
if (!this.opts.announceEnabled.current) return;
|
||||
const ling = this.soma?.langs;
|
||||
const text = ling ? ling.t(key, vars) : key;
|
||||
globalAnnounce.announce(text, priority);
|
||||
}
|
||||
|
||||
// ── DOM queries ────────────────────────────────────────────────────────
|
||||
|
||||
/** All enabled Droppable elements inside this Provider. */
|
||||
getDroppables(): HTMLElement[] {
|
||||
const root = this.opts.ref.current;
|
||||
if (!root) return [];
|
||||
return Array.from(
|
||||
root.querySelectorAll<HTMLElement>(`[${attrs.droppable}]:not([data-disabled])`)
|
||||
).filter((el) => el.closest(`[${attrs.root}]`) === root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Droppables that accept the current drag, in DOM order.
|
||||
*/
|
||||
getAcceptingDroppables(): HTMLElement[] {
|
||||
const drag = this.active;
|
||||
if (!drag) return [];
|
||||
return this.getDroppables().filter((el) => this.isAccepting(el, drag));
|
||||
}
|
||||
|
||||
private isAccepting(el: HTMLElement, drag: ActiveDrag): boolean {
|
||||
const accept = (el as HTMLElement & { __somaAccept?: (data: DragData, value: string) => boolean })
|
||||
.__somaAccept;
|
||||
if (!accept) return true;
|
||||
try {
|
||||
return accept(drag.data, drag.value) !== false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private dropTargetFromPoint(x: number, y: number): HTMLElement | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const el = document.elementFromPoint(x, y);
|
||||
if (!(el instanceof HTMLElement)) return null;
|
||||
return el.closest<HTMLElement>(`[${attrs.droppable}]:not([data-disabled])`);
|
||||
}
|
||||
|
||||
// ── Start / commit / cancel ────────────────────────────────────────────
|
||||
|
||||
/** Begin a drag. Returns `false` if the `onDragStart` callback prevented it. */
|
||||
startDrag(params: {
|
||||
value: string;
|
||||
data: DragData;
|
||||
label: string;
|
||||
source: HTMLElement;
|
||||
pointer: { x: number; y: number } | null;
|
||||
keyboard: boolean;
|
||||
}): boolean {
|
||||
if (this.active) return false;
|
||||
let prevented = false;
|
||||
this.opts.onDragStart.current?.({
|
||||
value: params.value,
|
||||
data: params.data,
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
}
|
||||
});
|
||||
if (prevented) return false;
|
||||
|
||||
this.active = {
|
||||
value: params.value,
|
||||
data: params.data,
|
||||
label: params.label,
|
||||
pointer: params.pointer,
|
||||
source: params.source
|
||||
};
|
||||
this.keyboard = params.keyboard;
|
||||
this.announceTemplate(DRAG_DROP_LANGS.DRAG_STARTED, { item: params.label });
|
||||
|
||||
if (params.keyboard) {
|
||||
this.setupKeyboardNav();
|
||||
const firstTarget = this.getAcceptingDroppables()[0];
|
||||
if (firstTarget) {
|
||||
this.setOverTarget(firstTarget, { focus: true });
|
||||
} else {
|
||||
this.announce(DRAG_DROP_LANGS.DRAG_NO_TARGETS);
|
||||
}
|
||||
} else {
|
||||
this.setupPointerDrag();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private setOverTarget(el: HTMLElement | null, opts?: { focus?: boolean }) {
|
||||
if (this.overTarget === el) return;
|
||||
this.overTarget = el;
|
||||
if (!el) return;
|
||||
if (opts?.focus) el.focus();
|
||||
const label = el.getAttribute('data-text-value') || el.getAttribute('aria-label') || el.textContent || '';
|
||||
this.announceTemplate(DRAG_DROP_LANGS.DRAG_OVER_TARGET, { target: label.trim() });
|
||||
}
|
||||
|
||||
commitDrop(target: HTMLElement) {
|
||||
const drag = this.active;
|
||||
if (!drag) return;
|
||||
if (!this.isAccepting(target, drag)) {
|
||||
this.cancelDrag();
|
||||
return;
|
||||
}
|
||||
const targetLabel =
|
||||
target.getAttribute('data-text-value') ||
|
||||
target.getAttribute('aria-label') ||
|
||||
target.textContent ||
|
||||
'';
|
||||
const event: DropEvent = {
|
||||
value: drag.value,
|
||||
data: drag.data,
|
||||
target,
|
||||
targetLabel: targetLabel.trim()
|
||||
};
|
||||
this.opts.onDrop.current?.(event);
|
||||
this.opts.onDragEnd.current?.({
|
||||
value: drag.value,
|
||||
data: drag.data,
|
||||
outcome: 'drop',
|
||||
target
|
||||
});
|
||||
this.announceTemplate(DRAG_DROP_LANGS.DRAG_DROPPED, {
|
||||
item: drag.label,
|
||||
target: targetLabel.trim()
|
||||
});
|
||||
this.finish();
|
||||
}
|
||||
|
||||
cancelDrag() {
|
||||
const drag = this.active;
|
||||
if (!drag) return;
|
||||
this.opts.onDragEnd.current?.({
|
||||
value: drag.value,
|
||||
data: drag.data,
|
||||
outcome: 'cancel'
|
||||
});
|
||||
this.announce(DRAG_DROP_LANGS.DRAG_CANCELLED);
|
||||
// Return focus to the source for keyboard drags.
|
||||
const source = drag.source;
|
||||
this.finish();
|
||||
if (this.keyboard && source && typeof source.focus === 'function') {
|
||||
source.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private finish() {
|
||||
this.active = null;
|
||||
this.overTarget = null;
|
||||
this.keyboard = false;
|
||||
this.teardown();
|
||||
}
|
||||
|
||||
private teardown() {
|
||||
if (this.pointerMoveListener) {
|
||||
window.removeEventListener('pointermove', this.pointerMoveListener);
|
||||
this.pointerMoveListener = null;
|
||||
}
|
||||
if (this.pointerUpListener) {
|
||||
window.removeEventListener('pointerup', this.pointerUpListener);
|
||||
window.removeEventListener('pointercancel', this.pointerUpListener);
|
||||
this.pointerUpListener = null;
|
||||
}
|
||||
if (this.keyListener) {
|
||||
window.removeEventListener('keydown', this.keyListener);
|
||||
this.keyListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pointer drag ───────────────────────────────────────────────────────
|
||||
|
||||
private setupPointerDrag() {
|
||||
this.pointerMoveListener = (e: PointerEvent) => {
|
||||
if (!this.active) return;
|
||||
this.active = { ...this.active, pointer: { x: e.clientX, y: e.clientY } };
|
||||
const target = this.dropTargetFromPoint(e.clientX, e.clientY);
|
||||
if (target && this.active && this.isAccepting(target, this.active)) {
|
||||
if (this.overTarget !== target) this.setOverTarget(target);
|
||||
} else {
|
||||
this.overTarget = null;
|
||||
}
|
||||
};
|
||||
this.pointerUpListener = (e: PointerEvent) => {
|
||||
if (!this.active) return;
|
||||
if (e.type === 'pointercancel') {
|
||||
this.cancelDrag();
|
||||
return;
|
||||
}
|
||||
const target = this.dropTargetFromPoint(e.clientX, e.clientY);
|
||||
if (target) {
|
||||
this.commitDrop(target);
|
||||
} else {
|
||||
this.cancelDrag();
|
||||
}
|
||||
};
|
||||
window.addEventListener('pointermove', this.pointerMoveListener);
|
||||
window.addEventListener('pointerup', this.pointerUpListener);
|
||||
window.addEventListener('pointercancel', this.pointerUpListener);
|
||||
}
|
||||
|
||||
// ── Keyboard drag ──────────────────────────────────────────────────────
|
||||
|
||||
private setupKeyboardNav() {
|
||||
this.keyListener = (e: KeyboardEvent) => {
|
||||
if (!this.active || !this.keyboard) return;
|
||||
if (e.key === KEYS.ESCAPE) {
|
||||
e.preventDefault();
|
||||
this.cancelDrag();
|
||||
return;
|
||||
}
|
||||
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
|
||||
if (this.overTarget) {
|
||||
e.preventDefault();
|
||||
this.commitDrop(this.overTarget);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
e.key === KEYS.ARROW_DOWN ||
|
||||
e.key === KEYS.ARROW_RIGHT ||
|
||||
e.key === KEYS.TAB
|
||||
) {
|
||||
e.preventDefault();
|
||||
const list = this.getAcceptingDroppables();
|
||||
if (list.length === 0) return;
|
||||
const idx = this.overTarget ? list.indexOf(this.overTarget) : -1;
|
||||
const next = list[(idx + 1) % list.length];
|
||||
if (next) this.setOverTarget(next, { focus: true });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
e.key === KEYS.ARROW_UP ||
|
||||
e.key === KEYS.ARROW_LEFT ||
|
||||
(e.shiftKey && e.key === KEYS.TAB)
|
||||
) {
|
||||
e.preventDefault();
|
||||
const list = this.getAcceptingDroppables();
|
||||
if (list.length === 0) return;
|
||||
const idx = this.overTarget ? list.indexOf(this.overTarget) : 0;
|
||||
const prev = list[(idx - 1 + list.length) % list.length];
|
||||
if (prev) this.setOverTarget(prev, { focus: true });
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', this.keyListener);
|
||||
}
|
||||
|
||||
// ── Root props ─────────────────────────────────────────────────────────
|
||||
|
||||
readonly snippetProps = $derived.by(() => ({
|
||||
active: this.active,
|
||||
cancel: () => this.cancelDrag()
|
||||
}));
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
'data-dragging': boolToEmptyStrOrUndef(this.active !== null)
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Draggable ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface DraggableOpts
|
||||
extends WithRefOpts,
|
||||
ActiveProps<{
|
||||
value: string;
|
||||
data: DragData;
|
||||
disabled: boolean;
|
||||
moveBuffer: number;
|
||||
textValue: string | undefined;
|
||||
}> {}
|
||||
|
||||
export class DraggableProvider extends Provider<DraggableOpts> {
|
||||
static create(opts: DraggableOpts) {
|
||||
return new DraggableProvider(opts);
|
||||
}
|
||||
|
||||
readonly provider: DragDropProvider;
|
||||
|
||||
/** Pending pointer-down state; promoted to active drag after moveBuffer exceeded. */
|
||||
private pending: {
|
||||
id: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
} | null = null;
|
||||
|
||||
private constructor(opts: DraggableOpts) {
|
||||
super(opts, 'DragDrop', 'draggable', attrs.draggable);
|
||||
this.provider = DragDropProvider.require();
|
||||
}
|
||||
|
||||
readonly isActive = $derived.by(
|
||||
() => this.provider.active?.value === this.opts.value.current
|
||||
);
|
||||
|
||||
private getLabel(): string {
|
||||
return (
|
||||
this.opts.textValue.current ||
|
||||
this.opts.ref.current?.getAttribute('aria-label') ||
|
||||
this.opts.ref.current?.textContent?.trim() ||
|
||||
this.opts.value.current
|
||||
);
|
||||
}
|
||||
|
||||
readonly onpointerdown = (e: PointerEvent) => {
|
||||
if (this.opts.disabled.current || e.button !== 0) return;
|
||||
const el = this.opts.ref.current;
|
||||
if (!el) return;
|
||||
this.pending = { id: e.pointerId, startX: e.clientX, startY: e.clientY };
|
||||
|
||||
const onMove = (me: PointerEvent) => {
|
||||
if (!this.pending || me.pointerId !== this.pending.id) return;
|
||||
const dx = me.clientX - this.pending.startX;
|
||||
const dy = me.clientY - this.pending.startY;
|
||||
if (Math.hypot(dx, dy) < this.opts.moveBuffer.current) return;
|
||||
// Promote to active drag.
|
||||
const pending = this.pending;
|
||||
this.pending = null;
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onCancel);
|
||||
window.removeEventListener('pointercancel', onCancel);
|
||||
this.provider.startDrag({
|
||||
value: this.opts.value.current,
|
||||
data: this.opts.data.current,
|
||||
label: this.getLabel(),
|
||||
source: el,
|
||||
pointer: { x: me.clientX, y: me.clientY },
|
||||
keyboard: false
|
||||
});
|
||||
};
|
||||
const onCancel = () => {
|
||||
this.pending = null;
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onCancel);
|
||||
window.removeEventListener('pointercancel', onCancel);
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onCancel);
|
||||
window.addEventListener('pointercancel', onCancel);
|
||||
};
|
||||
|
||||
readonly onkeydown = (e: KeyboardEvent) => {
|
||||
if (this.opts.disabled.current) return;
|
||||
if (this.provider.active) return; // drag already in progress
|
||||
if (e.key !== KEYS.SPACE && e.key !== KEYS.ENTER) return;
|
||||
const el = this.opts.ref.current;
|
||||
if (!el) return;
|
||||
e.preventDefault();
|
||||
this.provider.startDrag({
|
||||
value: this.opts.value.current,
|
||||
data: this.opts.data.current,
|
||||
label: this.getLabel(),
|
||||
source: el,
|
||||
pointer: null,
|
||||
keyboard: true
|
||||
});
|
||||
};
|
||||
|
||||
readonly handleProps = $derived.by(
|
||||
() =>
|
||||
({
|
||||
onpointerdown: this.onpointerdown,
|
||||
onkeydown: this.onkeydown,
|
||||
tabindex: 0,
|
||||
'data-drag-handle': ''
|
||||
}) as Record<string, unknown>
|
||||
);
|
||||
|
||||
readonly snippetProps = $derived.by(() => ({
|
||||
dragging: this.isActive,
|
||||
handleProps: this.handleProps
|
||||
}));
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
role: 'button' as const,
|
||||
tabindex: this.opts.disabled.current ? -1 : 0,
|
||||
'aria-disabled': this.opts.disabled.current ? true : undefined,
|
||||
'aria-roledescription': this.provider.soma?.langs.ts(
|
||||
DRAG_DROP_LANGS.DRAGGABLE_ROLE
|
||||
),
|
||||
'aria-grabbed': this.isActive ? true : undefined,
|
||||
'data-dragging': boolToEmptyStrOrUndef(this.isActive),
|
||||
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
|
||||
'data-text-value': this.opts.textValue.current,
|
||||
'data-value': this.opts.value.current,
|
||||
onpointerdown: this.onpointerdown,
|
||||
onkeydown: this.onkeydown,
|
||||
'style:touch-action': 'none'
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Droppable ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface DroppableOpts
|
||||
extends WithRefOpts,
|
||||
ActiveProps<{
|
||||
accept: ((data: DragData, value: string) => boolean) | undefined;
|
||||
disabled: boolean;
|
||||
textValue: string | undefined;
|
||||
}> {}
|
||||
|
||||
export class DroppableProvider extends Provider<DroppableOpts> {
|
||||
static create(opts: DroppableOpts) {
|
||||
return new DroppableProvider(opts);
|
||||
}
|
||||
|
||||
readonly provider: DragDropProvider;
|
||||
|
||||
private constructor(opts: DroppableOpts) {
|
||||
super(opts, 'DragDrop', 'droppable', attrs.droppable);
|
||||
this.provider = DragDropProvider.require();
|
||||
|
||||
// Attach the accept predicate directly to the DOM element so the
|
||||
// Provider can read it without a registry (which would require reactive
|
||||
// bookkeeping + cleanup for every Droppable mount/unmount).
|
||||
$effect(() => {
|
||||
const el = opts.ref.current;
|
||||
const accept = opts.accept.current;
|
||||
if (!el) return;
|
||||
(el as HTMLElement & { __somaAccept?: typeof accept }).__somaAccept = accept;
|
||||
return () => {
|
||||
delete (el as HTMLElement & { __somaAccept?: unknown }).__somaAccept;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
readonly isOver = $derived.by(() => this.provider.overTarget === this.opts.ref.current);
|
||||
readonly isAccepting = $derived.by(() => {
|
||||
const drag = this.provider.active;
|
||||
if (!drag || this.opts.disabled.current) return false;
|
||||
const accept = this.opts.accept.current;
|
||||
if (!accept) return true;
|
||||
try {
|
||||
return accept(drag.data, drag.value) !== false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
readonly onpointerup = (e: PointerEvent) => {
|
||||
// Safety: the Provider's window listener already handles commit via
|
||||
// elementFromPoint, but in case setPointerCapture steals events we
|
||||
// also catch the release here.
|
||||
if (!this.provider.active || this.opts.disabled.current) return;
|
||||
if (!this.isAccepting) return;
|
||||
const el = this.opts.ref.current;
|
||||
if (!el) return;
|
||||
e.stopPropagation();
|
||||
this.provider.commitDrop(el);
|
||||
};
|
||||
|
||||
readonly snippetProps = $derived.by(() => ({
|
||||
over: this.isOver,
|
||||
accepting: this.isAccepting
|
||||
}));
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
role: 'region' as const,
|
||||
tabindex: this.provider.active && this.isAccepting ? 0 : -1,
|
||||
'aria-roledescription': this.provider.soma?.langs.ts(
|
||||
DRAG_DROP_LANGS.DROPPABLE_ROLE
|
||||
),
|
||||
'aria-dropeffect':
|
||||
this.provider.active && this.isAccepting ? ('move' as const) : ('none' as const),
|
||||
'aria-disabled': this.opts.disabled.current ? true : undefined,
|
||||
'data-dragover': boolToEmptyStrOrUndef(this.isOver),
|
||||
'data-accepting': boolToEmptyStrOrUndef(this.isAccepting),
|
||||
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
|
||||
'data-text-value': this.opts.textValue.current,
|
||||
onpointerup: this.onpointerup
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Preview ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DragPreviewOpts extends ProviderOpts {}
|
||||
|
||||
export class DragPreviewProvider extends Provider<DragPreviewOpts> {
|
||||
static create(opts: DragPreviewOpts) {
|
||||
return new DragPreviewProvider(opts);
|
||||
}
|
||||
|
||||
readonly provider: DragDropProvider;
|
||||
|
||||
private constructor(opts: DragPreviewOpts) {
|
||||
super(opts, 'DragDrop', 'preview', attrs.preview);
|
||||
this.provider = DragDropProvider.require();
|
||||
}
|
||||
|
||||
readonly active: Active<typeof this.provider.active> = readableActive(
|
||||
() => this.provider.active
|
||||
);
|
||||
|
||||
readonly style = $derived.by(() => {
|
||||
const a = this.provider.active;
|
||||
if (!a) return { display: 'none' };
|
||||
const pt = a.pointer;
|
||||
if (!pt) {
|
||||
const rect = a.source.getBoundingClientRect();
|
||||
return {
|
||||
position: 'fixed',
|
||||
left: `${rect.left}px`,
|
||||
top: `${rect.top}px`,
|
||||
'pointer-events': 'none',
|
||||
'z-index': '9999'
|
||||
};
|
||||
}
|
||||
return {
|
||||
position: 'fixed',
|
||||
left: `${pt.x + 12}px`,
|
||||
top: `${pt.y + 12}px`,
|
||||
'pointer-events': 'none',
|
||||
'z-index': '9999'
|
||||
};
|
||||
});
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
'aria-hidden': true as const,
|
||||
'data-active': boolToEmptyStrOrUndef(this.provider.active !== null),
|
||||
style: this.style
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
export { default as Provider } from './components/drag-drop.svelte';
|
||||
export { default as Draggable } from './components/drag-drop-draggable.svelte';
|
||||
export { default as Droppable } from './components/drag-drop-droppable.svelte';
|
||||
export { default as Preview } from './components/drag-drop-preview.svelte';
|
||||
|
||||
export type {
|
||||
DragDropProps as ProviderProps,
|
||||
DraggableProps,
|
||||
DroppableProps,
|
||||
DragPreviewProps as PreviewProps,
|
||||
DragProviderSnippetProps as ProviderSnippetProps,
|
||||
DraggableSnippetProps,
|
||||
DroppableSnippetProps,
|
||||
DragData,
|
||||
ActiveDrag,
|
||||
DropEvent,
|
||||
DragEndEvent
|
||||
} from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,18 @@
|
||||
export const DRAG_DROP_LANGS = {
|
||||
/** Announced when a drag begins via keyboard or pointer. `{{item}}` is the draggable's textValue / aria-label. */
|
||||
DRAG_STARTED: '#?components.drag-drop.drag-started|Started dragging {{item}}',
|
||||
/** Announced when the pointer / keyboard focus enters a valid drop zone. */
|
||||
DRAG_OVER_TARGET: '#?components.drag-drop.drag-over|Over drop zone {{target}}',
|
||||
/** Announced on successful drop. */
|
||||
DRAG_DROPPED: '#?components.drag-drop.dropped|Dropped {{item}} on {{target}}',
|
||||
/** Announced when the drag is cancelled. */
|
||||
DRAG_CANCELLED: '#?components.drag-drop.cancelled|Drag cancelled',
|
||||
/** Announced when no valid drop target is available. */
|
||||
DRAG_NO_TARGETS: '#?components.drag-drop.no-targets|No drop targets available',
|
||||
/** `aria-roledescription` for a Draggable. */
|
||||
DRAGGABLE_ROLE: '#?components.drag-drop.draggable-role|draggable',
|
||||
/** `aria-roledescription` for a Droppable. */
|
||||
DROPPABLE_ROLE: '#?components.drag-drop.droppable-role|drop zone',
|
||||
/** Label for a Droppable currently accepting the active drag. */
|
||||
DROPPABLE_ACTIVE: '#?components.drag-drop.droppable-active|active drop zone'
|
||||
} as const;
|
||||
@ -0,0 +1,175 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { WithChild, Without } from '../../types';
|
||||
import type { PrimitiveDivAttributes } from '../../types';
|
||||
|
||||
/** Arbitrary drag payload. Consumer-defined shape. */
|
||||
export type DragData = Record<string, unknown>;
|
||||
|
||||
/** Info about the currently active drag. */
|
||||
export type ActiveDrag<D extends DragData = DragData> = {
|
||||
/** Stable identifier from the Draggable's `value` prop. */
|
||||
value: string;
|
||||
/** Consumer-supplied payload. */
|
||||
data: D;
|
||||
/** Text label (textValue, aria-label, or element textContent) for announcements. */
|
||||
label: string;
|
||||
/** Current pointer position in viewport coords, or `null` for keyboard drags not yet moved. */
|
||||
pointer: { x: number; y: number } | null;
|
||||
/** Source element. */
|
||||
source: HTMLElement;
|
||||
};
|
||||
|
||||
/** Info passed to `onDrop`. */
|
||||
export type DropEvent<D extends DragData = DragData> = {
|
||||
value: string;
|
||||
data: D;
|
||||
/** Drop target DOM element. */
|
||||
target: HTMLElement;
|
||||
/** Drop target label (textValue / aria-label / textContent). */
|
||||
targetLabel: string;
|
||||
};
|
||||
|
||||
export type DragEndEvent<D extends DragData = DragData> = {
|
||||
value: string;
|
||||
data: D;
|
||||
/** `'drop'` when dropped on a valid target, `'cancel'` when cancelled (Escape / invalid release). */
|
||||
outcome: 'drop' | 'cancel';
|
||||
target?: HTMLElement;
|
||||
};
|
||||
|
||||
export type DragProviderSnippetProps = {
|
||||
active: ActiveDrag | null;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export type DraggableSnippetProps = {
|
||||
dragging: boolean;
|
||||
handleProps: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DroppableSnippetProps = {
|
||||
over: boolean;
|
||||
accepting: boolean;
|
||||
};
|
||||
|
||||
// ── Provider ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props for `DragDrop.Provider`.
|
||||
*
|
||||
* Coordinates pointer + keyboard drag-and-drop across a subtree. Mount one
|
||||
* Provider around the smallest region that contains both the Draggables and
|
||||
* the Droppables that participate together.
|
||||
*
|
||||
* The Provider does NOT own the data — it only coordinates the gesture +
|
||||
* announces via live regions. Consumer receives `onDrop` and mutates their
|
||||
* own state.
|
||||
*/
|
||||
export type DragDropProps = WithChild<
|
||||
{
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* Fires before the drag starts. Call `e.preventDefault()` to cancel.
|
||||
* (Rarely needed — `Draggable.disabled` is preferred.)
|
||||
*/
|
||||
onDragStart?: (e: { value: string; data: DragData; preventDefault: () => void }) => void;
|
||||
/** Fires when the drag ends (drop or cancel). */
|
||||
onDragEnd?: (e: DragEndEvent) => void;
|
||||
/** Fires on successful drop. */
|
||||
onDrop?: (e: DropEvent) => void;
|
||||
|
||||
/**
|
||||
* Emit screen-reader announcements via the global `Announce` API
|
||||
* (drag start, over, drop, cancel). @default true
|
||||
*/
|
||||
announceEnabled?: boolean;
|
||||
|
||||
children?: Snippet<[DragProviderSnippetProps]>;
|
||||
},
|
||||
DragProviderSnippetProps
|
||||
> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
// ── Draggable ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props for `DragDrop.Draggable`.
|
||||
*
|
||||
* Wraps a DOM element that can be dragged. The consumer can use the snippet
|
||||
* `handleProps` pattern to restrict the drag "grip" to a child element
|
||||
* (e.g. a handle icon) — otherwise the whole element is the handle.
|
||||
*/
|
||||
export type DraggableProps = WithChild<
|
||||
{
|
||||
id?: string;
|
||||
/** Unique identifier for this item. Returned in onDrop. */
|
||||
value: string;
|
||||
/** Arbitrary payload the consumer receives in onDrop. */
|
||||
data?: DragData;
|
||||
/** Disable dragging. @default false */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Pixels the pointer must move before a drag is recognised. Prevents
|
||||
* accidental drags on clicks. @default 5
|
||||
*/
|
||||
moveBuffer?: number;
|
||||
/** Label for announcements. Falls back to aria-label → textContent. */
|
||||
textValue?: string;
|
||||
children?: Snippet<[DraggableSnippetProps]>;
|
||||
},
|
||||
DraggableSnippetProps
|
||||
> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
// ── Droppable ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props for `DragDrop.Droppable`.
|
||||
*
|
||||
* A drop target. When a drag is in progress and the pointer hovers this
|
||||
* element (or keyboard focus reaches it), `data-dragover` is present. On
|
||||
* drop, `onDrop` fires on the Provider with the source's value / data.
|
||||
*/
|
||||
export type DroppableProps = WithChild<
|
||||
{
|
||||
id?: string;
|
||||
/**
|
||||
* Filter — return `true` if the current drag can be dropped here.
|
||||
* When `false`, the droppable is skipped in keyboard nav and pointer
|
||||
* hits are ignored. @default () => true
|
||||
*/
|
||||
accept?: (data: DragData, value: string) => boolean;
|
||||
/** Label for announcements. Falls back to aria-label → textContent. */
|
||||
textValue?: string;
|
||||
/** Disable drops on this target. @default false */
|
||||
disabled?: boolean;
|
||||
children?: Snippet<[DroppableSnippetProps]>;
|
||||
},
|
||||
DroppableSnippetProps
|
||||
> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
// ── Preview ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Snippet props for `DragDrop.Preview`. */
|
||||
export type DragPreviewSnippetProps = {
|
||||
active: ActiveDrag;
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for `DragDrop.Preview`.
|
||||
*
|
||||
* Optional floating element that follows the pointer during drag. Positioned
|
||||
* `fixed` at the current pointer position with `pointer-events: none`. For
|
||||
* keyboard drags (no pointer position), rendered at the source element's
|
||||
* position until first move.
|
||||
*/
|
||||
export type DragPreviewProps = WithChild<
|
||||
{
|
||||
id?: string;
|
||||
children?: Snippet<[DragPreviewSnippetProps]>;
|
||||
},
|
||||
DragPreviewSnippetProps
|
||||
> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
@ -0,0 +1,267 @@
|
||||
# Feed
|
||||
|
||||
Implements the WAI-ARIA [Feed pattern](https://www.w3.org/WAI/ARIA/apg/patterns/feed/) — a scrollable stream of `role="article"` children where assistive tech navigates between articles with `PageUp` / `PageDown`. Each article exposes its position via `aria-posinset` / `aria-setsize` so the user always knows where they are.
|
||||
|
||||
Use Feed for activity streams, chat histories, threaded comments — anything where the article count can grow via lazy loading. Use `GridList` or `Listbox` instead when items are bounded and the user picks one.
|
||||
|
||||
## Anatomy
|
||||
|
||||
```svelte
|
||||
<Feed.Provider {totalItems} {busy} {onLoadMore}>
|
||||
{#each posts as post (post.id)}
|
||||
<Feed.Article>
|
||||
<Feed.ArticleTitle level={3}>{post.title}</Feed.ArticleTitle>
|
||||
<Feed.ArticleDescription>{post.body}</Feed.ArticleDescription>
|
||||
</Feed.Article>
|
||||
{/each}
|
||||
</Feed.Provider>
|
||||
```
|
||||
|
||||
## Parts
|
||||
|
||||
| Part | Element | Description |
|
||||
| -------------------- | --------- | ------------------------------------------------------------------------------------ |
|
||||
| `Provider` | `<div>` | `role="feed"`. Coordinates PageUp/PageDown navigation. |
|
||||
| `Article` | `<div>` | `role="article"`. Focusable, auto-computed `aria-posinset` / `aria-setsize` / level. |
|
||||
| `ArticleTitle` | `<div>` | `role="heading"` + auto-derived `aria-level` from thread depth. |
|
||||
| `ArticleDescription` | `<div>` | Linked via `aria-describedby` on the enclosing Article. |
|
||||
| `Thread` | `<div>` | Nested sub-feed (`role="feed"`). Child articles inherit level + 1. |
|
||||
| `Sentinel` | `<div>` | Invisible IntersectionObserver probe. Fires `onLoadMore` when visible. |
|
||||
|
||||
## Props
|
||||
|
||||
### `Provider`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------------- | --------------------- | ---------- | ------------------------------------------------------------------------ |
|
||||
| `id` | `string` | auto | DOM id. |
|
||||
| `totalItems` | `number \| undefined` | — | Total across all pages. `undefined` when unknown (infinite / unbounded). |
|
||||
| `busy` | `boolean` | `false` | Whether more items are loading. Sets `aria-busy`. |
|
||||
| `onLoadMore` | `() => void` | — | Called when the user PageDown's past the last article. |
|
||||
| `aria-label` | `string` | translated | Accessible name (default `'Feed'`). |
|
||||
| `aria-labelledby` | `string` | — | External label id. |
|
||||
|
||||
Snippet props: `{ busy }`.
|
||||
|
||||
### `Article`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------------- | --------------------------------------------------------------------------- |
|
||||
| `id` | `string` | auto | DOM id. |
|
||||
| `posinset` | `number` | auto (1-based DOM index) | Explicit position in the full set (use when DOM order ≠ feed order). |
|
||||
|
||||
Snippet props: `{ index, total }` (0-based `index`, `total` is `totalItems` or `undefined`).
|
||||
|
||||
### `ArticleTitle`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------- | ------------------------ | ---------------------------------------------- | -------------------------------------------------- |
|
||||
| `id` | `string` | auto | DOM id. |
|
||||
| `level` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | derived from thread depth (3 top, 4 nested, …) | Heading level. Explicit value wins. |
|
||||
|
||||
### `ArticleDescription`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ---- | -------- | ------- | ----------- |
|
||||
| `id` | `string` | auto | DOM id. |
|
||||
|
||||
### `Thread`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ---- | -------- | ------- | ----------- |
|
||||
| `id` | `string` | auto | DOM id. |
|
||||
|
||||
### `Sentinel`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------- | --------------------------------------- | ------------------------ | ----------------------------------------------------------------------------- |
|
||||
| `id` | `string` | auto | DOM id. |
|
||||
| `rootMargin` | `string` | `'200px'` | IntersectionObserver rootMargin. |
|
||||
| `threshold` | `number \| number[]` | `0` | IntersectionObserver threshold. |
|
||||
| `root` | `Element \| Document \| null` | `null` | Observer root (`null` = viewport). |
|
||||
| `onIntersect` | `() => void` | parent Feed's `onLoadMore` | Callback when the sentinel enters the observed zone. |
|
||||
| `disabled` | `boolean` | `false` | Skip observer setup. |
|
||||
|
||||
## ARIA
|
||||
|
||||
| Part | Attribute | Value |
|
||||
| ------------------- | ------------------ | ---------------------------------------- |
|
||||
| Provider | `role` | `feed` |
|
||||
| Provider | `aria-busy` | `true` when `busy` |
|
||||
| Provider | `aria-label` | Translated default or override |
|
||||
| Article | `role` | `article` |
|
||||
| Article | `tabindex` | `0` (focusable for PageUp/PageDown nav) |
|
||||
| Article | `aria-posinset` | Explicit `posinset` or auto-computed |
|
||||
| Article | `aria-setsize` | `totalItems`, or `-1` when unknown (per APG) |
|
||||
| Article | `aria-labelledby` | Auto-wired to `ArticleTitle` id |
|
||||
| Article | `aria-describedby` | Auto-wired to `ArticleDescription` id |
|
||||
| ArticleTitle | `role` | `heading` |
|
||||
| ArticleTitle | `aria-level` | As prop (`3` by default) |
|
||||
|
||||
Per [the APG](https://www.w3.org/WAI/ARIA/apg/patterns/feed/):
|
||||
- `aria-setsize="-1"` means "total unknown" (soma emits this when `totalItems` is undefined).
|
||||
- `aria-busy="true"` signals that new articles are being loaded; AT may defer announcements.
|
||||
|
||||
## Data Attributes
|
||||
|
||||
| Part | Attribute | Values |
|
||||
| ------------------- | --------------------------------- | ----------------------------------- |
|
||||
| Provider | `data-feed` | Always present |
|
||||
| Provider | `data-busy` | Present when busy |
|
||||
| Article | `data-feed-article` | Always present |
|
||||
| Article | `data-posinset` | Numeric position |
|
||||
| Article | `data-level` | Nesting depth (1 = top, 2 = Thread) |
|
||||
| ArticleTitle | `data-feed-article-title` | Always present |
|
||||
| ArticleDescription | `data-feed-article-description` | Always present |
|
||||
| Thread | `data-feed-thread` | Always present |
|
||||
| Thread | `data-level` | Thread depth |
|
||||
| Sentinel | `data-feed-sentinel` | Always present |
|
||||
| Sentinel | `data-intersecting` | Present while in the observed zone |
|
||||
|
||||
## Keyboard
|
||||
|
||||
| Key | Action |
|
||||
| ------------------------ | --------------------------------------------------------------------- |
|
||||
| `PageDown` (on Article) | Focus next Article. At the last one, calls `onLoadMore()`. |
|
||||
| `PageUp` (on Article) | Focus previous Article. |
|
||||
| `Ctrl+Home` | Focus first Article. |
|
||||
| `Ctrl+End` | Focus last Article. |
|
||||
| `Tab` (within Article) | Enters inner interactive elements per native tab order. |
|
||||
|
||||
## Virtualization
|
||||
|
||||
Compose with [`VirtualList`](../virtual-list/README.md) for long streams:
|
||||
|
||||
```svelte
|
||||
<Feed.Provider aria-label="Activity">
|
||||
<VirtualList.Provider count={posts.length} itemSize={120} getItemKey={(i) => posts[i].id}>
|
||||
{#snippet children({ virtualItems, totalSize })}
|
||||
<VirtualList.Viewport class="feed-viewport">
|
||||
<div style:height="{totalSize}px" style:position="relative">
|
||||
{#each virtualItems as v (v.key)}
|
||||
<VirtualList.Item index={v.index} start={v.start} size={v.size}>
|
||||
<Feed.Article>
|
||||
<Feed.ArticleTitle>{posts[v.index].title}</Feed.ArticleTitle>
|
||||
<Feed.ArticleDescription>{posts[v.index].body}</Feed.ArticleDescription>
|
||||
</Feed.Article>
|
||||
</VirtualList.Item>
|
||||
{/each}
|
||||
</div>
|
||||
</VirtualList.Viewport>
|
||||
{/snippet}
|
||||
</VirtualList.Provider>
|
||||
<Feed.Sentinel onIntersect={loadMore} />
|
||||
</Feed.Provider>
|
||||
```
|
||||
|
||||
PageUp/PageDown still work — the Provider's `getArticles()` queries the current DOM, so only mounted articles participate. Pair with `Feed.Sentinel` for auto-load at the viewport's tail.
|
||||
|
||||
## Comparison
|
||||
|
||||
| Feature | Soma | Radix | Ark UI | react-aria | APG |
|
||||
| ---------------------------------- | :--: | :---: | :----: | :--------: | :-: |
|
||||
| `role="feed"` root | ✅ | ❌ | ❌ | ❌¹ | ✅ |
|
||||
| `role="article"` children | ✅ | — | — | — | ✅ |
|
||||
| `aria-posinset` / `aria-setsize` | ✅ | — | — | — | ✅ |
|
||||
| `aria-busy` during load | ✅ | — | — | — | ✅ |
|
||||
| PageUp / PageDown navigation | ✅ | — | — | — | ✅ |
|
||||
| `onLoadMore` callback | ✅ | — | — | — | — |
|
||||
| Ctrl+Home / Ctrl+End jumps | ✅ | — | — | — | ✅ |
|
||||
| Auto-wired title/description | ✅ | — | — | — | — |
|
||||
| Nested threads (`Feed.Thread`) | ✅ | — | — | ❌ | — |
|
||||
| Auto-derived heading level | ✅ | — | — | ❌ | — |
|
||||
| IntersectionObserver auto-load | ✅ | — | — | ✅ | — |
|
||||
| Virtualization (via compose) | ✅² | — | — | ✅ | — |
|
||||
|
||||
¹ react-aria recommends `GridList` or `ListBox` rather than `role=feed`. Soma ships Feed because WAI-ARIA defines the pattern and it's the correct semantic for activity streams.
|
||||
² Compose with `VirtualList` — see the section above. No coupling between soma and the virtualizer.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic infinite feed
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let posts = $state<Post[]>([]);
|
||||
let busy = $state(false);
|
||||
let done = $state(false);
|
||||
|
||||
async function loadMore() {
|
||||
if (busy || done) return;
|
||||
busy = true;
|
||||
const page = await fetchPosts({ after: posts.at(-1)?.id });
|
||||
if (page.length === 0) done = true;
|
||||
else posts = [...posts, ...page];
|
||||
busy = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Feed.Provider {busy} onLoadMore={loadMore} aria-label="Activity">
|
||||
{#each posts as post (post.id)}
|
||||
<Feed.Article>
|
||||
<Feed.ArticleTitle>{post.title}</Feed.ArticleTitle>
|
||||
<Feed.ArticleDescription>{post.body}</Feed.ArticleDescription>
|
||||
</Feed.Article>
|
||||
{/each}
|
||||
</Feed.Provider>
|
||||
```
|
||||
|
||||
### Known total (paginated feed)
|
||||
|
||||
```svelte
|
||||
<Feed.Provider totalItems={500} aria-label="Comments">
|
||||
{#each visible as c (c.id)}
|
||||
<Feed.Article posinset={c.index + 1}>
|
||||
<!-- index/total available via snippet props -->
|
||||
</Feed.Article>
|
||||
{/each}
|
||||
</Feed.Provider>
|
||||
```
|
||||
|
||||
### IntersectionObserver auto-load
|
||||
|
||||
Drop a `Feed.Sentinel` inside the Provider. When it scrolls into view it fires the Provider's `onLoadMore` automatically:
|
||||
|
||||
```svelte
|
||||
<Feed.Provider {busy} onLoadMore={loadMore}>
|
||||
{#each posts as p (p.id)}
|
||||
<Feed.Article>...</Feed.Article>
|
||||
{/each}
|
||||
<Feed.Sentinel rootMargin="400px" disabled={busy} />
|
||||
</Feed.Provider>
|
||||
```
|
||||
|
||||
Keyboard users still trigger `onLoadMore` via `PageDown` at the last article — the Sentinel is additive for pointer/scroll users.
|
||||
|
||||
### Threaded replies
|
||||
|
||||
Wrap replies in a `Feed.Thread` inside the parent Article. Nested articles automatically inherit:
|
||||
|
||||
- `aria-setsize` scoped to the thread (not the root feed).
|
||||
- `aria-level` on the heading bumped by one per nesting level.
|
||||
- `data-level` attribute on both Thread and Article.
|
||||
|
||||
```svelte
|
||||
<Feed.Provider aria-label="Discussion">
|
||||
{#each posts as post (post.id)}
|
||||
<Feed.Article>
|
||||
<Feed.ArticleTitle>{post.title}</Feed.ArticleTitle>
|
||||
<Feed.ArticleDescription>{post.body}</Feed.ArticleDescription>
|
||||
|
||||
{#if post.replies?.length}
|
||||
<Feed.Thread>
|
||||
{#each post.replies as r (r.id)}
|
||||
<Feed.Article>
|
||||
<!-- aria-level is auto: 4 when the parent was level-3 -->
|
||||
<Feed.ArticleTitle>Reply from {r.author}</Feed.ArticleTitle>
|
||||
<Feed.ArticleDescription>{r.body}</Feed.ArticleDescription>
|
||||
</Feed.Article>
|
||||
{/each}
|
||||
</Feed.Thread>
|
||||
{/if}
|
||||
</Feed.Article>
|
||||
{/each}
|
||||
</Feed.Provider>
|
||||
```
|
||||
|
||||
Threads nest to any depth — the level computation walks up through enclosing Threads. Each Thread is a fresh `role="feed"` with its own posinset/setsize.
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { FeedArticleDescriptionProvider } from '../feed-provider.svelte';
|
||||
import type { FeedArticleDescriptionProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'feed-article-description'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: FeedArticleDescriptionProps = $props();
|
||||
|
||||
const provider = FeedArticleDescriptionProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { FeedArticleTitleProvider } from '../feed-provider.svelte';
|
||||
import type { FeedArticleTitleProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'feed-article-title'),
|
||||
level,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: FeedArticleTitleProps = $props();
|
||||
|
||||
const provider = FeedArticleTitleProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
level: readableActive(() => level)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { FeedArticleProvider } from '../feed-provider.svelte';
|
||||
import type { FeedArticleProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'feed-article'),
|
||||
posinset,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: FeedArticleProps = $props();
|
||||
|
||||
const provider = FeedArticleProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
posinset: readableActive(() => posinset)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...provider.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.(provider.snippetProps)}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { FeedSentinelProvider } from '../feed-provider.svelte';
|
||||
import type { FeedSentinelProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'feed-sentinel'),
|
||||
rootMargin = '200px',
|
||||
threshold = 0,
|
||||
root = null,
|
||||
onIntersect,
|
||||
disabled = false,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: FeedSentinelProps = $props();
|
||||
|
||||
const provider = FeedSentinelProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
rootMargin: readableActive(() => rootMargin),
|
||||
threshold: readableActive(() => threshold),
|
||||
root: readableActive(() => root),
|
||||
onIntersect: readableActive(() => onIntersect),
|
||||
disabled: readableActive(() => disabled)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { FeedThreadProvider } from '../feed-provider.svelte';
|
||||
import type { FeedThreadProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'feed-thread'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: FeedThreadProps = $props();
|
||||
|
||||
const provider = FeedThreadProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { FeedProvider } from '../feed-provider.svelte';
|
||||
import type { FeedProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'feed'),
|
||||
totalItems,
|
||||
busy = false,
|
||||
onLoadMore = () => {},
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledby,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: FeedProps = $props();
|
||||
|
||||
const provider = FeedProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
totalItems: readableActive(() => totalItems),
|
||||
busy: readableActive(() => busy),
|
||||
ariaLabel: readableActive(() => ariaLabel),
|
||||
ariaLabelledby: readableActive(() => ariaLabelledby),
|
||||
onLoadMore: readableActive(() => onLoadMore)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...provider.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.(provider.snippetProps)}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,17 @@
|
||||
export { default as Provider } from './components/feed.svelte';
|
||||
export { default as Article } from './components/feed-article.svelte';
|
||||
export { default as ArticleTitle } from './components/feed-article-title.svelte';
|
||||
export { default as ArticleDescription } from './components/feed-article-description.svelte';
|
||||
export { default as Thread } from './components/feed-thread.svelte';
|
||||
export { default as Sentinel } from './components/feed-sentinel.svelte';
|
||||
|
||||
export type {
|
||||
FeedProps as ProviderProps,
|
||||
FeedArticleProps as ArticleProps,
|
||||
FeedArticleTitleProps as ArticleTitleProps,
|
||||
FeedArticleDescriptionProps as ArticleDescriptionProps,
|
||||
FeedThreadProps as ThreadProps,
|
||||
FeedSentinelProps as SentinelProps,
|
||||
FeedProviderSnippetProps as ProviderSnippetProps,
|
||||
FeedArticleSnippetProps as ArticleSnippetProps
|
||||
} from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,4 @@
|
||||
export const FEED_LANGS = {
|
||||
LABEL: '#?components.feed.label|Feed',
|
||||
ARTICLE_LABEL: '#?components.feed.article-label|Article'
|
||||
} as const;
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { GridListCellProvider } from '../grid-list-provider.svelte';
|
||||
import type { GridListCellProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'grid-list-cell'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: GridListCellProps = $props();
|
||||
|
||||
const provider = GridListCellProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { GridListRowProvider } from '../grid-list-provider.svelte';
|
||||
import type { GridListRowProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'grid-list-row'),
|
||||
value,
|
||||
textValue,
|
||||
disabled = false,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: GridListRowProps = $props();
|
||||
|
||||
const provider = GridListRowProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
value: readableActive(() => value),
|
||||
textValue: readableActive(() => textValue),
|
||||
disabled: readableActive(() => disabled)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...provider.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.(provider.snippetProps)}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { GridListSelectionCheckboxProvider } from '../grid-list-provider.svelte';
|
||||
import type { GridListSelectionCheckboxProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'grid-list-selection-checkbox'),
|
||||
'aria-label': ariaLabel,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: GridListSelectionCheckboxProps = $props();
|
||||
|
||||
const provider = GridListSelectionCheckboxProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
ariaLabel: readableActive(() => ariaLabel)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { GridListProvider } from '../grid-list-provider.svelte';
|
||||
import type { GridListProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'grid-list'),
|
||||
value = $bindable([]),
|
||||
onValueChange = () => {},
|
||||
selectionMode = 'single',
|
||||
loop = false,
|
||||
typeahead = true,
|
||||
typeaheadTimeout = 500,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
required = false,
|
||||
invalid = false,
|
||||
name,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledby,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: GridListProps = $props();
|
||||
|
||||
const provider = GridListProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
value: writableActive(
|
||||
() => value,
|
||||
(v) => {
|
||||
value = v;
|
||||
onValueChange(v);
|
||||
}
|
||||
),
|
||||
selectionMode: readableActive(() => selectionMode),
|
||||
loop: readableActive(() => loop),
|
||||
typeahead: readableActive(() => typeahead),
|
||||
typeaheadTimeout: readableActive(() => typeaheadTimeout),
|
||||
disabled: readableActive(() => disabled),
|
||||
readonly: readableActive(() => readonly),
|
||||
required: readableActive(() => required),
|
||||
invalid: readableActive(() => invalid),
|
||||
name: readableActive(() => name),
|
||||
ariaLabel: readableActive(() => ariaLabel),
|
||||
ariaLabelledby: readableActive(() => ariaLabelledby),
|
||||
onValueChange: readableActive(() => onValueChange)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...provider.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.(provider.snippetProps)}
|
||||
{#if name}
|
||||
{#each value as v (v)}
|
||||
<input type="hidden" {name} value={v} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,14 @@
|
||||
export { default as Provider } from './components/grid-list.svelte';
|
||||
export { default as Row } from './components/grid-list-row.svelte';
|
||||
export { default as Cell } from './components/grid-list-cell.svelte';
|
||||
export { default as SelectionCheckbox } from './components/grid-list-selection-checkbox.svelte';
|
||||
|
||||
export type {
|
||||
GridListProps as ProviderProps,
|
||||
GridListRowProps as RowProps,
|
||||
GridListCellProps as CellProps,
|
||||
GridListSelectionCheckboxProps as SelectionCheckboxProps,
|
||||
GridListProviderSnippetProps as ProviderSnippetProps,
|
||||
GridListRowSnippetProps as RowSnippetProps,
|
||||
GridListSelectionMode
|
||||
} from './types';
|
||||
@ -0,0 +1,650 @@
|
||||
import { Provider, context, type WithRefOpts } from '../../provider';
|
||||
import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs';
|
||||
import {
|
||||
readableActive,
|
||||
type Active,
|
||||
type ActiveProps,
|
||||
type StateProps
|
||||
} from '../../reactive';
|
||||
import type {
|
||||
OnChangeFn,
|
||||
Direction,
|
||||
SomaKeyboardEvent,
|
||||
SomaMouseEvent
|
||||
} from '../../types';
|
||||
import { KEYS, getDirectionalKeys } from '../../keyboard';
|
||||
import { Soma } from '../../core/soma.svelte';
|
||||
import { FieldProvider } from '../field/field-provider.svelte';
|
||||
import { GRID_LIST_LANGS } from './langs';
|
||||
import type { GridListSelectionMode } from './types';
|
||||
|
||||
const attrs = createAttrs({
|
||||
component: 'grid-list',
|
||||
parts: ['root', 'row', 'cell', 'selection-checkbox'] as const
|
||||
});
|
||||
|
||||
registerContract({
|
||||
name: 'grid-list',
|
||||
version: 1,
|
||||
parts: {
|
||||
root: [
|
||||
{ attr: 'data-disabled', description: 'Disabled' },
|
||||
{ attr: 'data-readonly', description: 'Read-only' },
|
||||
{ attr: 'data-invalid', description: 'Invalid' },
|
||||
{ attr: 'data-required', description: 'Required' },
|
||||
{ attr: 'data-empty', description: 'No rows selected' },
|
||||
{
|
||||
attr: 'data-selection-mode',
|
||||
values: ['none', 'single', 'multiple'],
|
||||
description: 'Selection mode'
|
||||
}
|
||||
],
|
||||
row: [
|
||||
{
|
||||
attr: 'data-state',
|
||||
values: ['selected', 'unselected'],
|
||||
description: 'Selection state'
|
||||
},
|
||||
{ attr: 'data-highlighted', description: 'Currently focused row' },
|
||||
{ attr: 'data-disabled', description: 'Row disabled' }
|
||||
],
|
||||
cell: [],
|
||||
'selection-checkbox': [
|
||||
{
|
||||
attr: 'data-state',
|
||||
values: ['checked', 'unchecked'],
|
||||
description: 'Matches parent row selection'
|
||||
},
|
||||
{ attr: 'data-disabled', description: 'Disabled' }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// ── Root provider ──────────────────────────────────────────────────────────
|
||||
|
||||
interface GridListOpts
|
||||
extends WithRefOpts,
|
||||
StateProps<{ value: string[] }>,
|
||||
ActiveProps<{
|
||||
selectionMode: GridListSelectionMode;
|
||||
loop: boolean;
|
||||
typeahead: boolean;
|
||||
typeaheadTimeout: number;
|
||||
disabled: boolean;
|
||||
readonly: boolean;
|
||||
required: boolean;
|
||||
invalid: boolean;
|
||||
name: string | undefined;
|
||||
ariaLabel: string | undefined;
|
||||
ariaLabelledby: string | undefined;
|
||||
onValueChange: OnChangeFn<string[]> | undefined;
|
||||
}> {}
|
||||
|
||||
export class GridListProvider extends Provider<GridListOpts> {
|
||||
static readonly ctx = context<GridListProvider>('GridList');
|
||||
static get(): GridListProvider | undefined {
|
||||
return this.ctx.getOr(undefined) as GridListProvider | undefined;
|
||||
}
|
||||
static require(): GridListProvider {
|
||||
return this.ctx.get();
|
||||
}
|
||||
|
||||
static create(opts: GridListOpts) {
|
||||
return new GridListProvider(opts);
|
||||
}
|
||||
|
||||
readonly soma = Soma.get();
|
||||
readonly field = FieldProvider.get();
|
||||
|
||||
private typeaheadBuffer = '';
|
||||
private typeaheadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Anchor for Shift+Click range selection. */
|
||||
private anchor: string | null = null;
|
||||
|
||||
private constructor(opts: GridListOpts) {
|
||||
super(opts, 'GridList', 'root', attrs.root, GridListProvider.ctx);
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Field-aware flags ───────────────────────────────────────────────────
|
||||
|
||||
readonly isDisabled = $derived.by(
|
||||
() => this.opts.disabled.current || (this.field?.isDisabled ?? false)
|
||||
);
|
||||
readonly isReadonly = $derived.by(
|
||||
() => this.opts.readonly.current || (this.field?.isReadonly ?? false)
|
||||
);
|
||||
readonly isRequired = $derived.by(
|
||||
() => this.opts.required.current || (this.field?.isRequired ?? false)
|
||||
);
|
||||
readonly isInvalid = $derived.by(
|
||||
() => this.opts.invalid.current || (this.field?.isInvalid ?? false)
|
||||
);
|
||||
|
||||
readonly resolvedDir: Active<Direction> = readableActive(
|
||||
() => this.soma?.presentation.getDir() ?? 'ltr'
|
||||
);
|
||||
|
||||
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(() =>
|
||||
this.opts.ariaLabelledby.current
|
||||
? undefined
|
||||
: this.opts.ariaLabel.current ||
|
||||
this.soma?.langs.ts(GRID_LIST_LANGS.LABEL) ||
|
||||
undefined
|
||||
);
|
||||
|
||||
readonly isEmpty = $derived.by(() => this.opts.value.current.length === 0);
|
||||
|
||||
// ── DOM queries ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Enabled row elements in DOM order, scoped to this root. */
|
||||
getRows(): HTMLElement[] {
|
||||
const root = this.opts.ref.current;
|
||||
if (!root) return [];
|
||||
return Array.from(
|
||||
root.querySelectorAll<HTMLElement>(`[${attrs.row}]:not([data-disabled])`)
|
||||
).filter((el) => el.closest(`[${attrs.root}]`) === root);
|
||||
}
|
||||
|
||||
/** All rows including disabled — used for range-select over contiguous rows. */
|
||||
private getAllRows(): HTMLElement[] {
|
||||
const root = this.opts.ref.current;
|
||||
if (!root) return [];
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(`[${attrs.row}]`)).filter(
|
||||
(el) => el.closest(`[${attrs.root}]`) === root
|
||||
);
|
||||
}
|
||||
|
||||
/** Single lifted derivation for the roving-tabindex target — O(1) per row. */
|
||||
readonly rovingTargetEl = $derived.by(() => {
|
||||
const rows = this.getRows();
|
||||
if (rows.length === 0) return undefined;
|
||||
const selected = new Set(this.opts.value.current);
|
||||
return (
|
||||
rows.find((el) => selected.has(el.getAttribute('data-value') ?? '')) ??
|
||||
rows[0]
|
||||
);
|
||||
});
|
||||
|
||||
// ── Selection ───────────────────────────────────────────────────────────
|
||||
|
||||
isSelected(value: string): boolean {
|
||||
return this.opts.value.current.includes(value);
|
||||
}
|
||||
|
||||
select(value: string, mode: 'replace' | 'toggle' | 'range') {
|
||||
if (this.isDisabled || this.isReadonly) return;
|
||||
const selectionMode = this.opts.selectionMode.current;
|
||||
if (selectionMode === 'none') return;
|
||||
const current = this.opts.value.current;
|
||||
|
||||
let next: string[];
|
||||
if (mode === 'range' && selectionMode === 'multiple' && this.anchor) {
|
||||
next = this.computeRange(this.anchor, value);
|
||||
} else if (mode === 'toggle' && selectionMode === 'multiple') {
|
||||
next = current.includes(value)
|
||||
? current.filter((v) => v !== value)
|
||||
: [...current, value];
|
||||
} else {
|
||||
// 'replace' or 'toggle' on single mode
|
||||
next = current.length === 1 && current[0] === value ? current : [value];
|
||||
}
|
||||
|
||||
if (next !== current) {
|
||||
this.opts.value.current = next;
|
||||
this.opts.onValueChange.current?.(next);
|
||||
}
|
||||
if (mode !== 'range') this.anchor = value;
|
||||
}
|
||||
|
||||
private computeRange(from: string, to: string): string[] {
|
||||
const all = this.getAllRows().map((el) => el.getAttribute('data-value') ?? '');
|
||||
const i = all.indexOf(from);
|
||||
const j = all.indexOf(to);
|
||||
if (i < 0 || j < 0) return [to];
|
||||
const [lo, hi] = i <= j ? [i, j] : [j, i];
|
||||
return all.slice(lo, hi + 1);
|
||||
}
|
||||
|
||||
selectAll() {
|
||||
if (
|
||||
this.isDisabled ||
|
||||
this.isReadonly ||
|
||||
this.opts.selectionMode.current !== 'multiple'
|
||||
)
|
||||
return;
|
||||
const next = this.getRows().map((el) => el.getAttribute('data-value') ?? '');
|
||||
this.opts.value.current = next;
|
||||
this.opts.onValueChange.current?.(next);
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (this.isDisabled || this.isReadonly) return;
|
||||
if (this.opts.value.current.length === 0) return;
|
||||
this.opts.value.current = [];
|
||||
this.opts.onValueChange.current?.([]);
|
||||
this.anchor = null;
|
||||
}
|
||||
|
||||
// ── Keyboard navigation ─────────────────────────────────────────────────
|
||||
|
||||
private focusAt(index: number) {
|
||||
const rows = this.getRows();
|
||||
if (rows.length === 0) return;
|
||||
const clamped = Math.max(0, Math.min(rows.length - 1, index));
|
||||
rows[clamped]?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive descendants of a row, in tab order. Used for cell-level
|
||||
* 2D navigation (ArrowLeft/Right within a row).
|
||||
*/
|
||||
private getFocusableCellsInRow(row: HTMLElement): HTMLElement[] {
|
||||
const selector =
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([data-grid-list-row])';
|
||||
return Array.from(row.querySelectorAll<HTMLElement>(selector)).filter(
|
||||
(el) => el.closest(`[${attrs.row}]`) === row && !el.hasAttribute('data-grid-list-row')
|
||||
);
|
||||
}
|
||||
|
||||
/** ArrowRight on a row: move focus to the first focusable cell descendant. */
|
||||
private focusFirstCellInRow(row: HTMLElement): boolean {
|
||||
const cells = this.getFocusableCellsInRow(row);
|
||||
if (cells.length === 0) return false;
|
||||
cells[0]!.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** ArrowLeft/Right within a row: cycle between focusable descendants. */
|
||||
handleCellKeydown(e: SomaKeyboardEvent<HTMLElement>) {
|
||||
if (this.isDisabled) return;
|
||||
const dir = this.resolvedDir.current;
|
||||
const { nextKey, prevKey } = getDirectionalKeys(dir, 'horizontal');
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
const row = target.closest<HTMLElement>(`[${attrs.row}]`);
|
||||
if (!row) return;
|
||||
|
||||
if (e.key === nextKey) {
|
||||
const cells = this.getFocusableCellsInRow(row);
|
||||
const idx = cells.indexOf(target);
|
||||
if (idx < 0) return;
|
||||
if (idx < cells.length - 1) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cells[idx + 1]!.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === prevKey) {
|
||||
const cells = this.getFocusableCellsInRow(row);
|
||||
const idx = cells.indexOf(target);
|
||||
if (idx < 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (idx === 0) {
|
||||
// Return focus to the row itself.
|
||||
row.focus();
|
||||
} else {
|
||||
cells[idx - 1]!.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ArrowUp/Down / Home / End bubble up to the row handler so vertical
|
||||
// navigation still works while focus is inside a cell.
|
||||
}
|
||||
|
||||
handleRowKeydown(e: SomaKeyboardEvent<HTMLElement>) {
|
||||
if (this.isDisabled) return;
|
||||
|
||||
const dir = this.resolvedDir.current;
|
||||
// GridList is always vertical for navigation (grid pattern).
|
||||
const { nextKey, prevKey } = getDirectionalKeys(dir, 'vertical');
|
||||
const { nextKey: hNextKey, prevKey: hPrevKey } = getDirectionalKeys(dir, 'horizontal');
|
||||
const rows = this.getRows();
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const currentIndex = rows.indexOf(e.currentTarget);
|
||||
const loop = this.opts.loop.current;
|
||||
|
||||
// Horizontal — only handled when focus is on the row itself (not a cell).
|
||||
// From the row, ArrowRight enters the first focusable cell. ArrowLeft
|
||||
// is a no-op (you're already at the row level).
|
||||
if (e.target === e.currentTarget && e.key === hNextKey) {
|
||||
if (this.focusFirstCellInRow(e.currentTarget)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.target === e.currentTarget && e.key === hPrevKey) {
|
||||
// no-op; consume so the event doesn't scroll the viewport.
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === nextKey) {
|
||||
e.preventDefault();
|
||||
const next = loop
|
||||
? (currentIndex + 1) % rows.length
|
||||
: Math.min(currentIndex + 1, rows.length - 1);
|
||||
this.focusAt(next);
|
||||
return;
|
||||
}
|
||||
if (e.key === prevKey) {
|
||||
e.preventDefault();
|
||||
const prev = loop
|
||||
? (currentIndex - 1 + rows.length) % rows.length
|
||||
: Math.max(currentIndex - 1, 0);
|
||||
this.focusAt(prev);
|
||||
return;
|
||||
}
|
||||
if (e.key === KEYS.HOME) {
|
||||
e.preventDefault();
|
||||
this.focusAt(0);
|
||||
return;
|
||||
}
|
||||
if (e.key === KEYS.END) {
|
||||
e.preventDefault();
|
||||
this.focusAt(rows.length - 1);
|
||||
return;
|
||||
}
|
||||
if (e.key === KEYS.PAGE_UP) {
|
||||
e.preventDefault();
|
||||
this.focusAt(Math.max(currentIndex - 10, 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === KEYS.PAGE_DOWN) {
|
||||
e.preventDefault();
|
||||
this.focusAt(Math.min(currentIndex + 10, rows.length - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === KEYS.SPACE) {
|
||||
e.preventDefault();
|
||||
const value = e.currentTarget.getAttribute('data-value') ?? undefined;
|
||||
if (value !== undefined) {
|
||||
const mode =
|
||||
e.shiftKey && this.opts.selectionMode.current === 'multiple'
|
||||
? 'range'
|
||||
: e.ctrlKey || e.metaKey
|
||||
? 'toggle'
|
||||
: this.opts.selectionMode.current === 'multiple'
|
||||
? 'toggle'
|
||||
: 'replace';
|
||||
this.select(value, mode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
(e.key === 'a' || e.key === 'A') &&
|
||||
this.opts.selectionMode.current === 'multiple'
|
||||
) {
|
||||
e.preventDefault();
|
||||
this.selectAll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === KEYS.ESCAPE) {
|
||||
if (this.opts.value.current.length > 0) {
|
||||
e.preventDefault();
|
||||
this.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.opts.typeahead.current &&
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey &&
|
||||
!e.altKey &&
|
||||
e.key.length === 1 &&
|
||||
/\S/.test(e.key)
|
||||
) {
|
||||
this.handleTypeahead(e.key, currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private handleTypeahead(char: string, fromIndex: number) {
|
||||
this.typeaheadBuffer += char.toLowerCase();
|
||||
if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
|
||||
this.typeaheadTimer = setTimeout(() => {
|
||||
this.typeaheadBuffer = '';
|
||||
}, this.opts.typeaheadTimeout.current);
|
||||
|
||||
const rows = this.getRows();
|
||||
if (rows.length === 0) return;
|
||||
const needle = this.typeaheadBuffer;
|
||||
const start = needle.length === 1 ? fromIndex + 1 : fromIndex;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const idx = (start + i) % rows.length;
|
||||
const el = rows[idx];
|
||||
if (!el) continue;
|
||||
const text = (el.getAttribute('data-text-value') || el.textContent || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (text.startsWith(needle)) {
|
||||
el.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleRowClick(value: string, e: SomaMouseEvent<HTMLElement>) {
|
||||
if (this.isDisabled || this.isReadonly) return;
|
||||
const mode =
|
||||
e.shiftKey && this.opts.selectionMode.current === 'multiple'
|
||||
? 'range'
|
||||
: (e.ctrlKey || e.metaKey) && this.opts.selectionMode.current === 'multiple'
|
||||
? 'toggle'
|
||||
: 'replace';
|
||||
this.select(value, mode);
|
||||
e.currentTarget.focus();
|
||||
}
|
||||
|
||||
// ── Root props ──────────────────────────────────────────────────────────
|
||||
|
||||
readonly snippetProps = $derived.by(() => ({
|
||||
value: this.opts.value.current,
|
||||
isEmpty: this.isEmpty,
|
||||
selectAll: () => this.selectAll(),
|
||||
clear: () => this.clear()
|
||||
}));
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
role: 'grid' as const,
|
||||
'aria-label': this.resolvedAriaLabel.current,
|
||||
'aria-labelledby': this.opts.ariaLabelledby.current,
|
||||
'aria-multiselectable':
|
||||
this.opts.selectionMode.current === 'multiple' ? true : undefined,
|
||||
'aria-disabled': this.isDisabled ? true : undefined,
|
||||
'aria-readonly': this.isReadonly ? true : undefined,
|
||||
'aria-invalid': this.isInvalid ? true : undefined,
|
||||
'aria-required': this.isRequired ? true : undefined,
|
||||
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
|
||||
'data-readonly': boolToEmptyStrOrUndef(this.isReadonly),
|
||||
'data-invalid': boolToEmptyStrOrUndef(this.isInvalid),
|
||||
'data-required': boolToEmptyStrOrUndef(this.isRequired),
|
||||
'data-empty': boolToEmptyStrOrUndef(this.isEmpty),
|
||||
'data-selection-mode': this.opts.selectionMode.current
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Row ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface GridListRowOpts
|
||||
extends WithRefOpts,
|
||||
ActiveProps<{
|
||||
value: string;
|
||||
textValue: string | undefined;
|
||||
disabled: boolean;
|
||||
}> {}
|
||||
|
||||
export class GridListRowProvider extends Provider<GridListRowOpts> {
|
||||
static create(opts: GridListRowOpts) {
|
||||
return new GridListRowProvider(opts);
|
||||
}
|
||||
|
||||
readonly provider: GridListProvider;
|
||||
|
||||
private constructor(opts: GridListRowOpts) {
|
||||
super(opts, 'GridList', 'row', attrs.row);
|
||||
this.provider = GridListProvider.require();
|
||||
}
|
||||
|
||||
readonly isSelected = $derived.by(() =>
|
||||
this.provider.isSelected(this.opts.value.current)
|
||||
);
|
||||
readonly isDisabled = $derived.by(
|
||||
() => this.opts.disabled.current || this.provider.isDisabled
|
||||
);
|
||||
readonly isRovingTarget = $derived.by(
|
||||
() => this.opts.ref.current === this.provider.rovingTargetEl
|
||||
);
|
||||
|
||||
readonly onclick = (e: MouseEvent) => {
|
||||
if (this.isDisabled) return;
|
||||
this.provider.handleRowClick(this.opts.value.current, e as SomaMouseEvent<HTMLElement>);
|
||||
};
|
||||
|
||||
readonly onkeydown = (e: KeyboardEvent) => {
|
||||
this.provider.handleRowKeydown(e as SomaKeyboardEvent<HTMLElement>);
|
||||
};
|
||||
|
||||
readonly snippetProps = $derived.by(() => ({
|
||||
selected: this.isSelected,
|
||||
disabled: this.isDisabled
|
||||
}));
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
role: 'row' as const,
|
||||
tabindex: this.isDisabled ? -1 : this.isRovingTarget ? 0 : -1,
|
||||
'aria-selected':
|
||||
this.provider.opts.selectionMode.current === 'none'
|
||||
? undefined
|
||||
: this.isSelected,
|
||||
'aria-disabled': this.isDisabled ? true : undefined,
|
||||
'data-value': this.opts.value.current,
|
||||
'data-text-value': this.opts.textValue.current,
|
||||
'data-state': this.isSelected ? ('selected' as const) : ('unselected' as const),
|
||||
'data-highlighted': boolToEmptyStrOrUndef(this.isRovingTarget),
|
||||
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
|
||||
onclick: this.onclick,
|
||||
onkeydown: this.onkeydown
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cell ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface GridListCellOpts extends WithRefOpts {}
|
||||
|
||||
export class GridListCellProvider extends Provider<GridListCellOpts> {
|
||||
static create(opts: GridListCellOpts) {
|
||||
return new GridListCellProvider(opts);
|
||||
}
|
||||
|
||||
readonly provider: GridListProvider;
|
||||
|
||||
private constructor(opts: GridListCellOpts) {
|
||||
super(opts, 'GridList', 'cell', attrs.cell);
|
||||
this.provider = GridListProvider.require();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate horizontal arrow navigation to the Provider so the user can
|
||||
* move between focusable descendants of a row with ArrowLeft/Right, and
|
||||
* ArrowLeft from the first cell returns focus to the row.
|
||||
*/
|
||||
readonly onkeydown = (e: KeyboardEvent) => {
|
||||
this.provider.handleCellKeydown(e as SomaKeyboardEvent<HTMLElement>);
|
||||
};
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.assertProps({
|
||||
...this.baseProps,
|
||||
role: 'gridcell' as const,
|
||||
onkeydown: this.onkeydown
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
|
||||
// ── SelectionCheckbox ──────────────────────────────────────────────────────
|
||||
|
||||
interface GridListSelectionCheckboxOpts
|
||||
extends WithRefOpts,
|
||||
ActiveProps<{ ariaLabel: string | undefined }> {}
|
||||
|
||||
export class GridListSelectionCheckboxProvider extends Provider<GridListSelectionCheckboxOpts> {
|
||||
static create(opts: GridListSelectionCheckboxOpts) {
|
||||
return new GridListSelectionCheckboxProvider(opts);
|
||||
}
|
||||
|
||||
readonly provider: GridListProvider;
|
||||
/** Resolved from the nearest enclosing Row's value. */
|
||||
private rowValue: string | null = null;
|
||||
|
||||
private constructor(opts: GridListSelectionCheckboxOpts) {
|
||||
super(opts, 'GridList', 'selection-checkbox', attrs['selection-checkbox']);
|
||||
this.provider = GridListProvider.require();
|
||||
}
|
||||
|
||||
/** Read the enclosing row's value lazily (on click). */
|
||||
private findRowValue(el: HTMLElement | null): string | null {
|
||||
if (!el) return null;
|
||||
const row = el.closest<HTMLElement>(`[${attrs.row}]`);
|
||||
return row?.getAttribute('data-value') ?? null;
|
||||
}
|
||||
|
||||
readonly isChecked = $derived.by(() => {
|
||||
if (!this.rowValue) return false;
|
||||
return this.provider.isSelected(this.rowValue);
|
||||
});
|
||||
|
||||
readonly onclick = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
this.rowValue = this.findRowValue(target);
|
||||
if (!this.rowValue) return;
|
||||
this.provider.select(this.rowValue, 'toggle');
|
||||
};
|
||||
|
||||
readonly resolvedAriaLabel = $derived.by(
|
||||
() =>
|
||||
this.opts.ariaLabel.current ||
|
||||
this.provider.soma?.langs.ts(GRID_LIST_LANGS.SELECT_ROW) ||
|
||||
undefined
|
||||
);
|
||||
|
||||
readonly props = $derived.by(() => {
|
||||
// Read current row association via DOM for aria-state — safe on every render.
|
||||
const el = this.opts.ref.current;
|
||||
this.rowValue = this.findRowValue(el);
|
||||
const checked = this.rowValue
|
||||
? this.provider.isSelected(this.rowValue)
|
||||
: false;
|
||||
return this.assertProps({
|
||||
...this.baseProps,
|
||||
type: 'button' as const,
|
||||
role: 'checkbox' as const,
|
||||
'aria-checked': checked,
|
||||
'aria-label': this.resolvedAriaLabel,
|
||||
'aria-disabled': this.provider.isDisabled || this.provider.isReadonly || undefined,
|
||||
'data-state': checked ? ('checked' as const) : ('unchecked' as const),
|
||||
'data-disabled': boolToEmptyStrOrUndef(
|
||||
this.provider.isDisabled || this.provider.isReadonly
|
||||
),
|
||||
onclick: this.onclick
|
||||
} as const);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,5 @@
|
||||
export const GRID_LIST_LANGS = {
|
||||
LABEL: '#?components.grid-list.label|Grid list',
|
||||
SELECT_ROW: '#?components.grid-list.select-row|Select row',
|
||||
SELECT_ALL: '#?components.grid-list.select-all|Select all'
|
||||
} as const;
|
||||
@ -0,0 +1,132 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { WithChild, Without, OnChangeFn } from '../../types';
|
||||
import type {
|
||||
PrimitiveDivAttributes,
|
||||
PrimitiveButtonAttributes
|
||||
} from '../../types';
|
||||
|
||||
/** Selection mode. */
|
||||
export type GridListSelectionMode = 'none' | 'single' | 'multiple';
|
||||
|
||||
/** Snippet props for `GridList.Provider`. */
|
||||
export type GridListProviderSnippetProps = {
|
||||
value: string[];
|
||||
isEmpty: boolean;
|
||||
selectAll: () => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
/** Snippet props for `GridList.Row`. */
|
||||
export type GridListRowSnippetProps = {
|
||||
selected: boolean;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
// ── Root provider ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props for `GridList.Provider`.
|
||||
*
|
||||
* A selectable list with grid semantics (`role="grid"`) — rows are focusable,
|
||||
* cells are the content slots. Supports keyboard navigation, multi-select via
|
||||
* Shift/Ctrl click, typeahead, and form integration via a hidden input.
|
||||
*
|
||||
* Use `GridList` over `Listbox` when rows contain multiple interactive
|
||||
* elements (actions, links, checkboxes) — the `grid` role lets assistive
|
||||
* tech navigate those with Tab / cell-level commands. Use `Listbox` when
|
||||
* rows are atomic options.
|
||||
*/
|
||||
export type GridListProps = WithChild<
|
||||
{
|
||||
/** DOM id. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
|
||||
/** Selected row values. Bindable. @default [] */
|
||||
value?: string[];
|
||||
/** Fires on selection change. */
|
||||
onValueChange?: OnChangeFn<string[]>;
|
||||
|
||||
/** Selection mode. @default 'single' */
|
||||
selectionMode?: GridListSelectionMode;
|
||||
|
||||
/** Whether arrow navigation wraps at the ends. @default false */
|
||||
loop?: boolean;
|
||||
/** Whether typing characters jumps focus to matching row. @default true */
|
||||
typeahead?: boolean;
|
||||
/** Ms before the typeahead buffer resets. @default 500 */
|
||||
typeaheadTimeout?: number;
|
||||
|
||||
// Flags — OR-merged with enclosing `Field.Provider`
|
||||
/** @default false */
|
||||
disabled?: boolean;
|
||||
/** @default false */
|
||||
readonly?: boolean;
|
||||
/** @default false */
|
||||
required?: boolean;
|
||||
/** @default false */
|
||||
invalid?: boolean;
|
||||
|
||||
/** Name for form submission. */
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* Accessible name for the grid. Ignored when `aria-labelledby` is set
|
||||
* or when inside a `Field.Provider` with a `Field.Label`.
|
||||
* @default translated `'Grid list'`
|
||||
*/
|
||||
'aria-label'?: string;
|
||||
/** ID of an external element that labels this grid. */
|
||||
'aria-labelledby'?: string;
|
||||
|
||||
children?: Snippet<[GridListProviderSnippetProps]>;
|
||||
},
|
||||
GridListProviderSnippetProps
|
||||
> &
|
||||
Without<PrimitiveDivAttributes, { 'aria-label'?: string; 'aria-labelledby'?: string }>;
|
||||
|
||||
// ── Row ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props for `GridList.Row`.
|
||||
*
|
||||
* Rendered as `role="row"`. One row per `value`. Participates in roving
|
||||
* tabindex — only the selected (or first) row has `tabindex=0`.
|
||||
*/
|
||||
export type GridListRowProps = WithChild<
|
||||
{
|
||||
id?: string;
|
||||
/** Unique value for this row. Used for selection + typeahead. */
|
||||
value: string;
|
||||
/**
|
||||
* Optional text used by typeahead search. Falls back to the row's
|
||||
* `textContent`. Useful when the visible text is stylized (icons only, etc).
|
||||
*/
|
||||
textValue?: string;
|
||||
/** Whether the row is disabled. @default false */
|
||||
disabled?: boolean;
|
||||
children?: Snippet<[GridListRowSnippetProps]>;
|
||||
},
|
||||
GridListRowSnippetProps
|
||||
> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
// ── Cell ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Props for `GridList.Cell` — rendered as `role="gridcell"`. */
|
||||
export type GridListCellProps = WithChild<{
|
||||
id?: string;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
// ── SelectionCheckbox ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props for `GridList.SelectionCheckbox` — toggles row selection, with an
|
||||
* accessible label that matches the row's text.
|
||||
*/
|
||||
export type GridListSelectionCheckboxProps = WithChild<{
|
||||
id?: string;
|
||||
/** Accessible name override. @default translated `'Select row'` */
|
||||
'aria-label'?: string;
|
||||
}> &
|
||||
Without<PrimitiveButtonAttributes, { 'aria-label'?: string; type?: unknown }>;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue