uix: consolidate DOM + relocate morfo runtime to its layer

Three architectural cleanups in one cut:

1. **morfo runtime relocates to its own layer.** The MorfoRuntime + the
   pure morfo→attrs resolver (and their tests) move from
   `src/uix/soma/morfo/` → `src/uix/morfo/`. They were never
   soma-specific — they interpret a morfo declaration on behalf of any
   consumer. Soma was just the only consumer at the time. The new
   barrel at `$uix/morfo` re-exports `createMorfoRuntime`,
   `MorfoRuntime`, `MorfoRuntimeSources`, `RegisterPartOpts`,
   `SourceMap`, `SemanticEmitter`, `TriggerOptions`, plus the resolver
   helpers (`findMorfoPart`, `resolvePartAttrs`, `shouldEmitMorfoEntry`,
   etc.).

2. **`src/uix/adom/` is gone.** The runtime pieces (`createActiveDom`,
   `BodyScrollLock`, `DOMContext`, `RovingFocusGroup`, viewport) all
   live at `$adom` (`src/arts/adom/`) which is the canonical
   ecosystem-level ActiveDom. The `apply.ts` types and helpers
   (`DomAttrValue`, `applyChange`, `removeAttrs`, `StructuralChange`)
   live in `$libs/dom` and are re-exported from `$adom`. The 4 last
   `$uix/adom` import sites updated to `$adom`.

3. **`src/uix/soma/dom/` is gone.** The 14 utilities (`isBrowser`,
   `getDocument`, `isHTMLElement`, focus + tabbable helpers, etc.) all
   exist as a superset in `$libs/dom`. The 12 soma consumers updated
   their imports from `'../dom'` / `'$soma/dom'` → `'$libs/dom'`. No
   functional change — purely import-path consolidation.

`src/uix/active-uix/` is preserved as an empty placeholder for the
future cross-layer composition class that will set up morfo + soma +
sema + eidos together.

The relocated runtime now imports cleanly:
  - `ActiveDom`, `DomAttrValue` ← `$adom`
  - `EngineSemantic`, sema helpers ← `$uix/sema`
  - `Active`, `State` ← `$libs/reactive` (was `$soma/reactive`)
  - `createAttrs` ← `$soma/attrs` (cross-layer; createAttrs is morfo
    logic that lives in soma for now — slated for relocation in a
    follow-up)
  - `attachRef`, `RefAttachment` ← `$soma/provider/...` (idem)

Verified: vitest 14 files / 197 tests, smoke 68/68, morfo-check 66/66.
morfo-runtime
dev 5 months ago
parent a6e2c1e443
commit 9b07cead6b

@ -1,5 +1,5 @@
import { Context } from 'runed';
import { createActiveDom } from '$uix/adom';
import { createActiveDom } from '$adom';
import { EngineSemantic } from '$uix/sema';
import type {
AppOptions,

@ -1,4 +1,4 @@
import { createActiveDom } from '$uix/adom';
import { createActiveDom } from '$adom';
import { EngineSemantic } from '$uix/sema';
import type { AppLangs, AppPresentation, AppLogger, AppSemantic } from './types';

@ -1,4 +1,4 @@
import type { ActiveDom } from '$uix/adom';
import type { ActiveDom } from '$adom';
import type { SemanticSignal } from '$uix/sema';
// ── Direction ────────────────────────────────────────────────────────────────

@ -1,171 +0,0 @@
# ActiveDom
`uix/adom` contiene `ActiveDom`: el servicio DOM reactivo de aplicación.
## Qué es hoy
Ahora mismo `ActiveDom` no es un bus de eventos semánticos ni un reflector de
`data-event*`.
Su responsabilidad actual es más pequeña y más concreta:
- exponer el ancho de viewport de forma reactiva
- resolver el breakpoint actual
- mantener la definición de breakpoints de la app
- resolver valores responsive
- ofrecer helpers de consulta (`isAtLeast`, `matches`)
En otras palabras:
```text
uix/lib/dom -> uix/adom -> app.dom / soma.dom
puro reactivo consumo de app
```
## Qué pertenece a cada capa
### `uix/lib/dom`
Primitives DOM puras o casi puras:
- guards y traversal DOM
- focus helpers
- tabbable helpers
- responsive helpers puros
No mantiene estado de aplicación.
### `uix/adom`
Runtime reactivo de DOM:
- `viewport`
- `breakpoints`
- `currentBreakpoint`
- `resolve(...)`
- `isAtLeast(...)`
- `matches(...)`
- `BodyScrollLock` como helper global de body scroll lock
- `DOMContext` como helper scoped para `Document` / `ShadowRoot`
- `RovingFocusGroup` como helper runtime para navegación compuesta por teclado
`ActiveDom` sí mantiene estado reactivo y por eso vive aquí, no en `uix/lib/dom`.
## Posición en App
`ActiveDom` vive a nivel de aplicación:
```ts
app.dom
soma.dom
```
La implementación actual se conecta desde:
- [app.svelte.ts](/G:/dev/svelte/vicen/src/lib/ext/app/app.svelte.ts)
- [soma.svelte.ts](/G:/dev/svelte/vicen/src/uix/soma/core/soma.svelte.ts)
## API actual
La API pública real de `ActiveDom` hoy es esta:
```ts
export type ActiveDom = {
breakpoints: Active<Breakpoints>
viewport: { width: number }
currentBreakpoint: Active<Breakpoint>
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined
isAtLeast(breakpoint: Breakpoint): boolean
matches(breakpoint: Breakpoint): boolean
}
```
Creación:
```ts
const dom = createActiveDom({
breakpoints: readableActive(() => ({
lg: 1100
}))
})
const domWithDefaults = createActiveDom()
```
Reglas:
- los breakpoints se definen en la creación del `dom`
- `breakpoints` es opcional; si no se pasa, usa `BREAKPOINTS_DEFAULT`
- no hay herencia de `dom` padre
- `ActiveDom` es servicio de app, no scope anidado
- `ActiveDom` activa el tracking de `resize` al crearse
- `viewport` es singleton de proceso: todas las instancias de `ActiveDom`
comparten el mismo objeto `viewport`. Lo que sí es por instancia es
`breakpoints` (y por tanto `currentBreakpoint`, `resolve`, `isAtLeast`,
`matches`)
## Qué no es
`ActiveDom` hoy no es:
- `SemanticEngine`
- broker de eventos
- reflector de `data-event*`
- hub de `MutationObserver`
- sistema de theme
- reemplazo de `uix/lib/dom`
Además, `uix/adom` puede alojar helpers DOM con estado global real, como
`BodyScrollLock`, o helpers scoped de runtime como `DOMContext`, cuando ya no
son primitives puras de `uix/lib/dom` pero tampoco pertenecen a `Soma`.
También caben aquí helpers runtime de foco con estado propio, como
`RovingFocusGroup`, que reutilizan `uix/lib/dom` por debajo pero ya no son
solo utilidades puras.
## Relación con otras piezas
### Semántica
La semántica pertenece a `Sema` y a `SemanticEngine`, no a `ActiveDom`.
Si mañana `ActiveDom` refleja eventos al DOM, será como consumidor de
`SemanticEngine`, no como autoridad semántica.
### Theme
El theme no pertenece a `dom`.
Va en `app.presentation`, porque es estado de presentación de aplicación, no una
primitive DOM.
### Air y Terra
`air` y `terra` no consumen esta capa nueva.
Su código actual sirve como referencia histórica para extraer utilidades hacia
`uix/lib/dom`, pero no forman parte del runtime nuevo.
## Estado del diseño
`ActiveDom` está en fase fundacional.
Lo que ya está cerrado:
- `app.dom`
- `soma.dom`
- `viewport`
- `breakpoints`
- `currentBreakpoint`
- resolución responsive
Lo que queda para fases posteriores, si de verdad hace falta:
- reflexión de eventos semánticos al DOM
- observers compartidos
- APIs por `Document` o `ShadowRoot`
- introspección/diagnóstico de runtime más rica
La regla importante por ahora es simple:
> `ActiveDom` es el servicio reactivo de DOM de la app; `uix/lib/dom` es su base pura.

@ -1,66 +0,0 @@
import { readableActive, type Active } from '$reactive'
import {
BREAKPOINTS_DEFAULT,
getCurrentBreakpoint,
resolveResponsiveProp,
type Breakpoint,
type Breakpoints,
type ResponsiveProp
} from '$uix/lib/dom'
import { applyChange, removeAttrs, type StructuralChange } from './apply.js'
import { initViewportTracking, viewport } from './viewport.svelte.js'
export type ActiveDomProps = {
breakpoints?: Active<Partial<Breakpoints>>
}
export type ActiveDom = {
breakpoints: Active<Breakpoints>
viewport: { readonly width: number }
currentBreakpoint: Active<Breakpoint>
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined
isAtLeast(breakpoint: Breakpoint): boolean
matches(breakpoint: Breakpoint): boolean
apply(change: StructuralChange): void
remove(target: HTMLElement, names: readonly string[]): void
}
const viewportReadonly: { readonly width: number } = {
get width() {
return viewport.width
}
}
export function createActiveDom(props: ActiveDomProps = {}): ActiveDom {
initViewportTracking()
const breakpoints = readableActive(() => ({
...BREAKPOINTS_DEFAULT,
...props.breakpoints?.current
}))
const currentBreakpoint = readableActive(() =>
getCurrentBreakpoint(viewport.width, breakpoints.current)
)
return {
breakpoints,
viewport: viewportReadonly,
currentBreakpoint,
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined {
return resolveResponsiveProp(value, viewport.width, breakpoints.current)
},
isAtLeast(breakpoint: Breakpoint): boolean {
return viewport.width >= breakpoints.current[breakpoint]
},
matches(breakpoint: Breakpoint): boolean {
return currentBreakpoint.current === breakpoint
},
apply(change: StructuralChange): void {
applyChange(change)
},
remove(target: HTMLElement, names: readonly string[]): void {
removeAttrs(target, names)
}
}
}

@ -1,50 +0,0 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { readableActive } from '$reactive'
import { createActiveDom } from './active-dom.svelte'
describe('ActiveDom', () => {
beforeEach(() => {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: 1024
})
document.body.innerHTML = ''
window.dispatchEvent(new Event('resize'))
})
it('uses default breakpoints when no overrides are provided', () => {
const dom = createActiveDom()
expect(dom.currentBreakpoint.current).toBe('lg')
expect(dom.isAtLeast('md')).toBe(true)
expect(dom.matches('lg')).toBe(true)
})
it('merges partial breakpoint overrides', () => {
const dom = createActiveDom({
breakpoints: readableActive(() => ({ lg: 1200 }))
})
expect(dom.breakpoints.current.lg).toBe(1200)
expect(dom.currentBreakpoint.current).toBe('md')
})
it('tracks window resize and updates viewport-derived state', () => {
const dom = createActiveDom()
expect(dom.viewport.width).toBe(1024)
expect(dom.currentBreakpoint.current).toBe('lg')
window.innerWidth = 460
window.dispatchEvent(new Event('resize'))
expect(dom.viewport.width).toBe(460)
expect(dom.currentBreakpoint.current).toBe('base')
expect(dom.isAtLeast('sm')).toBe(false)
expect(dom.resolve({ base: 'stack', sm: 'inline' })).toBe('stack')
})
})

@ -1,75 +0,0 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { applyChange, removeAttrs } from './apply'
describe('apply', () => {
let target: HTMLElement
beforeEach(() => {
document.body.innerHTML = ''
target = document.createElement('div')
document.body.appendChild(target)
})
it('writes string values via setAttribute', () => {
applyChange({ target, attrs: { 'data-state': 'open', role: 'dialog' } })
expect(target.getAttribute('data-state')).toBe('open')
expect(target.getAttribute('role')).toBe('dialog')
})
it('writes number values stringified', () => {
applyChange({ target, attrs: { 'aria-level': 2, tabindex: -1 } })
expect(target.getAttribute('aria-level')).toBe('2')
expect(target.getAttribute('tabindex')).toBe('-1')
})
it('writes true as empty string (presence flag)', () => {
applyChange({ target, attrs: { 'data-disabled': true } })
expect(target.hasAttribute('data-disabled')).toBe(true)
expect(target.getAttribute('data-disabled')).toBe('')
})
it('removes attribute on false / null / undefined', () => {
target.setAttribute('data-disabled', '')
target.setAttribute('aria-hidden', 'true')
target.setAttribute('data-loading', 'true')
applyChange({
target,
attrs: { 'data-disabled': false, 'aria-hidden': null, 'data-loading': undefined }
})
expect(target.hasAttribute('data-disabled')).toBe(false)
expect(target.hasAttribute('aria-hidden')).toBe(false)
expect(target.hasAttribute('data-loading')).toBe(false)
})
it('removes attrs by name', () => {
target.setAttribute('data-event', 'announce')
target.setAttribute('data-event-phase', 'active')
target.setAttribute('data-state', 'open')
removeAttrs(target, ['data-event', 'data-event-phase'])
expect(target.hasAttribute('data-event')).toBe(false)
expect(target.hasAttribute('data-event-phase')).toBe(false)
expect(target.getAttribute('data-state')).toBe('open')
})
it('mixes write and remove in a single change', () => {
target.setAttribute('data-loading', '')
applyChange({
target,
attrs: {
'data-state': 'open',
'data-loading': false
}
})
expect(target.getAttribute('data-state')).toBe('open')
expect(target.hasAttribute('data-loading')).toBe(false)
})
})

@ -1,48 +0,0 @@
/**
* `apply` / `remove` — the only mutation surface of `ActiveDom`.
*
* `ActiveDom` doesn't know Morfo, Sema, Soma, or Eidos. It receives instructions
* already resolved by upper layers and applies them to the DOM. Nothing else.
*
* Contract:
* - `string | number` → `setAttribute(name, String(value))`
* - `true` → `setAttribute(name, '')` (presence flag, e.g. `data-disabled`)
* - `false | null | undefined` → `removeAttribute(name)`
*
* No event dispatch, no batching, no observation. Pure mutations.
*/
import { isBrowser } from '$uix/lib/dom'
export type DomAttrValue = string | number | boolean | null | undefined
export type StructuralChange = {
target: HTMLElement
attrs: Record<string, DomAttrValue>
}
function writeAttr(target: HTMLElement, name: string, value: DomAttrValue): void {
if (value === false || value === null || value === undefined) {
target.removeAttribute(name)
return
}
if (value === true) {
target.setAttribute(name, '')
return
}
target.setAttribute(name, String(value))
}
export function applyChange(change: StructuralChange): void {
if (!isBrowser) return
for (const name in change.attrs) {
writeAttr(change.target, name, change.attrs[name])
}
}
export function removeAttrs(target: HTMLElement, names: readonly string[]): void {
if (!isBrowser) return
for (const name of names) {
target.removeAttribute(name)
}
}

@ -1,212 +0,0 @@
import { SvelteMap } from 'svelte/reactivity'
import { writableActive, type State } from '$reactive'
import { isBrowser, isIOS } from '$uix/lib/dom'
/**
* Options reserved for future expansion (scrollbar padding/margin compensation
* strategies). Kept as an exported type so consumers can type-forward.
*/
export type BodyScrollLockOption = Record<string, never>
/**
* Properties we mutate on `<body>` while locked. Must all be saved so we can
* restore the exact pre-lock state when the last lock releases.
*/
const MANAGED_PROPERTIES = [
'overflow',
'overflow-x',
'overflow-y',
'padding-right',
'pointer-events',
'--scrollbar-width'
] as const
type ManagedProperty = (typeof MANAGED_PROPERTIES)[number]
type SavedProperties = Partial<Record<ManagedProperty, string>>
const lockMap = new SvelteMap<string, boolean>()
let savedProperties: SavedProperties | null = null
let stopTouchMoveListener: (() => void) | null = null
let cleanupTimeoutId: number | null = null
let cleanupScheduledAt: number | null = null
let pendingPointerEventsToken = 0
let idCounter = 0
function nextId(): string {
idCounter += 1
return `body-scroll-lock-${idCounter}`
}
function isAnyLocked(): boolean {
for (const [, value] of lockMap) {
if (value) return true
}
return false
}
function cancelPendingCleanup() {
if (cleanupTimeoutId === null || !isBrowser) return
window.clearTimeout(cleanupTimeoutId)
cleanupTimeoutId = null
}
function captureBodyProperties() {
if (!isBrowser || savedProperties !== null) return
const style = document.body.style
const saved: SavedProperties = {}
for (const prop of MANAGED_PROPERTIES) {
const value = style.getPropertyValue(prop)
if (value) saved[prop] = value
}
savedProperties = saved
}
function restoreBodyProperties() {
if (!isBrowser) return
const style = document.body.style
const saved = savedProperties ?? {}
for (const prop of MANAGED_PROPERTIES) {
const original = saved[prop]
if (original === undefined) {
style.removeProperty(prop)
} else {
style.setProperty(prop, original)
}
}
detachTouchMoveListener()
savedProperties = null
}
function detachTouchMoveListener() {
stopTouchMoveListener?.()
stopTouchMoveListener = null
}
function attachTouchMoveListener() {
if (!isBrowser || !isIOS || stopTouchMoveListener) return
const listener = (event: TouchEvent) => {
if (event.target !== document.documentElement) return
if (event.touches.length > 1) return
event.preventDefault()
}
document.addEventListener('touchmove', listener, { passive: false })
stopTouchMoveListener = () => {
document.removeEventListener('touchmove', listener)
stopTouchMoveListener = null
}
}
function applyBodyLock() {
if (!isBrowser) return
cancelPendingCleanup()
captureBodyProperties()
const style = document.body.style
const htmlStyle = getComputedStyle(document.documentElement)
const bodyStyle = getComputedStyle(document.body)
const hasStableGutter =
htmlStyle.scrollbarGutter?.includes('stable') || bodyStyle.scrollbarGutter?.includes('stable')
const verticalScrollbarWidth = window.innerWidth - document.documentElement.clientWidth
const paddingRight = Number.parseInt(bodyStyle.paddingRight || '0', 10)
if (verticalScrollbarWidth > 0 && !hasStableGutter) {
style.paddingRight = `${paddingRight + verticalScrollbarWidth}px`
style.setProperty('--scrollbar-width', `${verticalScrollbarWidth}px`)
}
style.overflow = 'hidden'
attachTouchMoveListener()
schedulePointerEventsBlock()
}
/**
* `pointer-events: none` is applied on the next microtask so the triggering
* event (e.g. a click that opens a dialog) finishes its propagation without
* being cut by the lock. The token guards against stale microtasks writing
* after a restore.
*/
function schedulePointerEventsBlock() {
if (!isBrowser) return
const token = ++pendingPointerEventsToken
Promise.resolve().then(() => {
if (token !== pendingPointerEventsToken || !isAnyLocked()) return
document.body.style.pointerEvents = 'none'
})
}
function scheduleCleanupIfNoNewLocks(delay: number | null, callback: () => void) {
if (!isBrowser) return
cancelPendingCleanup()
cleanupScheduledAt = Date.now()
const currentCleanupId = cleanupScheduledAt
const cleanupFn = () => {
cleanupTimeoutId = null
if (cleanupScheduledAt !== currentCleanupId) return
if (!isAnyLocked()) {
callback()
}
}
cleanupTimeoutId = window.setTimeout(cleanupFn, delay ?? 24)
}
export class BodyScrollLock {
readonly id = nextId()
readonly locked: State<boolean>
constructor(
initialState?: boolean,
private readonly restoreScrollDelay: () => number | null = () => null
) {
lockMap.set(this.id, initialState ?? false)
this.locked = writableActive(
() => lockMap.get(this.id) ?? false,
(value: boolean) => {
lockMap.set(this.id, value)
if (value || isAnyLocked()) {
applyBodyLock()
return
}
scheduleCleanupIfNoNewLocks(this.restoreScrollDelay(), restoreBodyProperties)
}
)
if (initialState) applyBodyLock()
}
destroy() {
const wasLocked = lockMap.get(this.id) ?? false
lockMap.delete(this.id)
if (isAnyLocked()) {
applyBodyLock()
return
}
if (wasLocked) {
scheduleCleanupIfNoNewLocks(this.restoreScrollDelay(), restoreBodyProperties)
}
}
static reset() {
lockMap.clear()
cancelPendingCleanup()
restoreBodyProperties()
savedProperties = null
cleanupScheduledAt = null
pendingPointerEventsToken = 0
idCounter = 0
}
}

@ -1,104 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BodyScrollLock } from './body-scroll-lock.svelte'
describe('BodyScrollLock', () => {
beforeEach(() => {
vi.useFakeTimers()
BodyScrollLock.reset()
document.body.setAttribute('style', 'background: red;')
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: 1200
})
Object.defineProperty(document.documentElement, 'clientWidth', {
configurable: true,
value: 1180
})
})
afterEach(() => {
BodyScrollLock.reset()
vi.useRealTimers()
})
it('locks body scroll and restores the initial body style when unlocked', async () => {
const lock = new BodyScrollLock(true)
await Promise.resolve()
expect(document.body.style.overflow).toBe('hidden')
expect(document.body.style.getPropertyValue('--scrollbar-width')).toBe('20px')
lock.locked.current = false
vi.runAllTimers()
await Promise.resolve()
expect(document.body.getAttribute('style')).toBe('background: red;')
})
it('keeps the body locked until the last lock is released', async () => {
const first = new BodyScrollLock(true)
const second = new BodyScrollLock(true)
await Promise.resolve()
first.locked.current = false
vi.runAllTimers()
await Promise.resolve()
expect(document.body.style.overflow).toBe('hidden')
second.destroy()
vi.runAllTimers()
await Promise.resolve()
expect(document.body.getAttribute('style')).toBe('background: red;')
})
it('uses the last active lock delay instead of stale unlocked instances', async () => {
const first = new BodyScrollLock(true, () => 200)
const second = new BodyScrollLock(true, () => 50)
await Promise.resolve()
first.locked.current = false
vi.runAllTimers()
await Promise.resolve()
expect(document.body.style.overflow).toBe('hidden')
second.locked.current = false
vi.advanceTimersByTime(49)
await Promise.resolve()
expect(document.body.style.overflow).toBe('hidden')
vi.advanceTimersByTime(1)
await Promise.resolve()
expect(document.body.getAttribute('style')).toBe('background: red;')
})
it('keeps the final lock restore delay when destroyed', async () => {
const lock = new BodyScrollLock(true, () => 120)
await Promise.resolve()
lock.destroy()
vi.advanceTimersByTime(119)
await Promise.resolve()
expect(document.body.style.overflow).toBe('hidden')
vi.advanceTimersByTime(1)
await Promise.resolve()
expect(document.body.getAttribute('style')).toBe('background: red;')
})
it('allows creating unlocked instances without mutating the body', () => {
new BodyScrollLock(false)
expect(document.body.getAttribute('style')).toBe('background: red;')
})
})

@ -1,80 +0,0 @@
import { readableActive, type Active, type State } from '$reactive'
import {
getActiveElement,
getDocument,
getWindow,
isBrowser,
isDocument,
isShadowRoot
} from '$uix/lib/dom'
type ElementGetter = () => HTMLElement | null
type ContextElement = Active<HTMLElement | null> | State<HTMLElement | null> | ElementGetter
function getDefaultProvider(): Document | null {
return isBrowser ? document : null
}
export class DOMContext {
readonly element: Active<HTMLElement | null>
readonly provider = readableActive<Document | ShadowRoot | null>(() => {
const element = this.element.current
if (!element) return getDefaultProvider()
const providerNode = element.getRootNode()
if (isDocument(providerNode) || isShadowRoot(providerNode)) {
return providerNode
}
return getDocument(element)
})
constructor(element: ContextElement) {
this.element =
typeof element === 'function' ? readableActive(element) : (element as Active<HTMLElement | null>)
}
getDocument = (): Document => {
return getDocument(this.provider.current ?? undefined)
}
getWindow = (): Window => {
return getWindow(this.provider.current ?? undefined)
}
getActiveElement = (): Element | null => {
const provider = this.provider.current
if (!provider) return null
return getActiveElement(provider)
}
isActiveElement = (node: HTMLElement | null): boolean => {
return node === this.getActiveElement()
}
getElementById<T extends Element = HTMLElement>(id: string): T | null {
const provider = this.provider.current
if (!provider || !('getElementById' in provider)) return null
return provider.getElementById(id) as T | null
}
querySelector = <T extends Element = Element>(selector: string): T | null => {
const provider = this.provider.current
if (!provider) return null
return provider.querySelector(selector) as T | null
}
querySelectorAll = <T extends Element = Element>(selector: string): NodeListOf<T> => {
const provider = this.provider.current
if (!provider) return [] as unknown as NodeListOf<T>
return provider.querySelectorAll(selector) as NodeListOf<T>
}
setTimeout = (callback: () => void, delay: number): number => {
return this.getWindow().setTimeout(callback, delay)
}
clearTimeout = (timeoutId: number): void => {
this.getWindow().clearTimeout(timeoutId)
}
}

@ -1,56 +0,0 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DOMContext } from './dom-context.svelte'
describe('DOMContext', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
it('falls back to the global document when no element is present', () => {
const node = document.createElement('div')
node.id = 'global-node'
document.body.appendChild(node)
const context = new DOMContext(() => null)
expect(context.getDocument()).toBe(document)
expect(context.getWindow()).toBe(window)
expect(context.getElementById('global-node')).toBe(node)
expect(context.querySelector('#global-node')).toBe(node)
})
it('scopes queries and active element to the element root', () => {
const host = document.createElement('div')
const shadow = host.attachShadow({ mode: 'open' })
const input = document.createElement('input')
input.id = 'shadow-input'
shadow.appendChild(input)
document.body.appendChild(host)
const context = new DOMContext(() => input)
input.focus()
expect(context.provider.current).toBe(shadow)
expect(context.querySelector('#shadow-input')).toBe(input)
expect(context.getActiveElement()).toBe(input)
expect(context.isActiveElement(input)).toBe(true)
})
it('proxies timers through the provider window', () => {
vi.useFakeTimers()
const context = new DOMContext(() => null)
const callback = vi.fn()
const timeoutId = context.setTimeout(callback, 10)
vi.advanceTimersByTime(10)
expect(callback).toHaveBeenCalledTimes(1)
context.clearTimeout(timeoutId)
vi.useRealTimers()
})
})

@ -1,5 +0,0 @@
export * from './active-dom.svelte.js'
export * from './apply.js'
export * from './body-scroll-lock.svelte.js'
export * from './dom-context.svelte.js'
export * from './roving-focus-group.svelte.js'

@ -1,162 +0,0 @@
import { state, type Active, type State } from '$reactive'
import { isBrowser, isHTMLElement, getElementDirection } from '$uix/lib/dom'
export type RovingFocusOrientation = 'horizontal' | 'vertical'
type DirectionalKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown'
const KEYS = {
ARROW_LEFT: 'ArrowLeft',
ARROW_RIGHT: 'ArrowRight',
ARROW_UP: 'ArrowUp',
ARROW_DOWN: 'ArrowDown',
HOME: 'Home',
END: 'End'
} as const
type RovingFocusGroupOptions = (
| { candidateAttr: string; candidateSelector?: undefined }
| { candidateSelector: string; candidateAttr?: undefined }
) & {
providerNode: Active<HTMLElement | null> | State<HTMLElement | null>
loop: Active<boolean>
orientation: Active<RovingFocusOrientation>
onCandidateFocus?: (node: HTMLElement) => void
}
function getDirectionalKeys(
dir: 'ltr' | 'rtl',
orientation: RovingFocusOrientation
): { nextKey: DirectionalKey; prevKey: DirectionalKey } {
if (orientation === 'vertical') {
return { nextKey: KEYS.ARROW_DOWN, prevKey: KEYS.ARROW_UP }
}
return {
nextKey: dir === 'rtl' ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT,
prevKey: dir === 'rtl' ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT
}
}
function escapeId(id: string): string {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(id)
return id
}
export class RovingFocusGroup {
readonly opts: RovingFocusGroupOptions
readonly currentTabStopId = state<string | null>(null)
constructor(opts: RovingFocusGroupOptions) {
this.opts = opts
}
/**
* Collects the current candidate nodes. Disabled candidates (marked with
* `data-disabled`) are filtered out regardless of which selector strategy
* the consumer chose.
*/
getCandidateNodes(): HTMLElement[] {
if (!isBrowser) return []
const providerNode = this.opts.providerNode.current
if (!providerNode) return []
const selector = this.opts.candidateSelector ?? `[${this.opts.candidateAttr}]`
const nodes = providerNode.querySelectorAll<HTMLElement>(selector)
return Array.from(nodes).filter((node) => !node.hasAttribute('data-disabled'))
}
/**
* Ensures a default tab stop exists. Safe to call in an effect — idempotent
* once `currentTabStopId` is set. Call this from the consumer's mount hook
* so `getTabIndex` can be a pure read.
*/
initializeDefaultTabStop(): void {
if (this.currentTabStopId.current !== null) return
const first = this.getCandidateNodes()[0]
if (first) this.currentTabStopId.current = first.id
}
focusFirstCandidate(): void {
const first = this.getCandidateNodes()[0]
if (!first) return
first.focus()
this.currentTabStopId.current = first.id
}
handleKeydown(
node: HTMLElement | null | undefined,
event: KeyboardEvent,
both = false
): HTMLElement | undefined {
const providerNode = this.opts.providerNode.current
if (!providerNode || !node) return
const items = this.getCandidateNodes()
if (!items.length) return
const currentIndex = items.indexOf(node)
const dir = getElementDirection(providerNode)
const { nextKey, prevKey } = getDirectionalKeys(dir, this.opts.orientation.current)
const loop = this.opts.loop.current
const keyToIndex: Partial<Record<string, number>> = {
[nextKey]: currentIndex + 1,
[prevKey]: currentIndex - 1,
[KEYS.HOME]: 0,
[KEYS.END]: items.length - 1
}
if (both) {
const altNextKey = nextKey === KEYS.ARROW_DOWN ? KEYS.ARROW_RIGHT : KEYS.ARROW_DOWN
const altPrevKey = prevKey === KEYS.ARROW_UP ? KEYS.ARROW_LEFT : KEYS.ARROW_UP
keyToIndex[altNextKey] = currentIndex + 1
keyToIndex[altPrevKey] = currentIndex - 1
}
let itemIndex = keyToIndex[event.key]
if (itemIndex === undefined) return
event.preventDefault()
if (itemIndex < 0 && loop) {
itemIndex = items.length - 1
} else if (itemIndex === items.length && loop) {
itemIndex = 0
}
const itemToFocus = items[itemIndex]
if (!itemToFocus) return
itemToFocus.focus()
this.currentTabStopId.current = itemToFocus.id
this.opts.onCandidateFocus?.(itemToFocus)
return itemToFocus
}
/**
* Pure reader — returns the tabindex a candidate should render with given
* the current state. Never mutates. Call `initializeDefaultTabStop()` from
* the consumer's mount hook to seed the initial value.
*/
getTabIndex(node: HTMLElement | null | undefined): 0 | -1 {
if (!node) return -1
const currentId = this.currentTabStopId.current
if (currentId === null) {
return this.getCandidateNodes()[0] === node ? 0 : -1
}
return node.id === currentId ? 0 : -1
}
setCurrentTabStopId(id: string): void {
this.currentTabStopId.current = id
}
focusCurrentTabStop(): void {
const id = this.currentTabStopId.current
if (!id) return
const node = this.opts.providerNode.current?.querySelector(`#${escapeId(id)}`)
if (!node || !isHTMLElement(node)) return
node.focus()
}
}

@ -1,144 +0,0 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readableActive } from '$reactive'
import { RovingFocusGroup } from './roving-focus-group.svelte'
function createGroup(
root: HTMLElement,
orientation: 'horizontal' | 'vertical' = 'horizontal',
loop = true
) {
return new RovingFocusGroup({
candidateAttr: 'data-roving-item',
providerNode: readableActive(() => root),
loop: readableActive(() => loop),
orientation: readableActive(() => orientation)
})
}
function createKeydownEvent(key: string): KeyboardEvent {
return new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
}
describe('RovingFocusGroup', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
it('assigns the first candidate as the default tab stop', () => {
const root = document.createElement('div')
const first = document.createElement('button')
const second = document.createElement('button')
first.id = 'first'
second.id = 'second'
first.setAttribute('data-roving-item', '')
second.setAttribute('data-roving-item', '')
root.append(first, second)
document.body.appendChild(root)
const group = createGroup(root)
expect(group.getTabIndex(first)).toBe(0)
expect(group.getTabIndex(second)).toBe(-1)
group.initializeDefaultTabStop()
expect(group.currentTabStopId.current).toBe('first')
})
it('filters out disabled candidates', () => {
const root = document.createElement('div')
const first = document.createElement('button')
const disabled = document.createElement('button')
const second = document.createElement('button')
first.id = 'first'
disabled.id = 'disabled'
second.id = 'second'
first.setAttribute('data-roving-item', '')
disabled.setAttribute('data-roving-item', '')
disabled.setAttribute('data-disabled', '')
second.setAttribute('data-roving-item', '')
root.append(first, disabled, second)
document.body.appendChild(root)
const group = createGroup(root)
expect(group.getCandidateNodes()).toEqual([first, second])
group.handleKeydown(first, createKeydownEvent('ArrowRight'))
expect(document.activeElement).toBe(second)
})
it('moves focus to the next candidate and loops when configured', () => {
const root = document.createElement('div')
const first = document.createElement('button')
const second = document.createElement('button')
first.id = 'first'
second.id = 'second'
first.setAttribute('data-roving-item', '')
second.setAttribute('data-roving-item', '')
root.append(first, second)
document.body.appendChild(root)
const group = createGroup(root)
const firstEvent = createKeydownEvent('ArrowRight')
const secondEvent = createKeydownEvent('ArrowRight')
group.handleKeydown(first, firstEvent)
expect(document.activeElement).toBe(second)
expect(group.currentTabStopId.current).toBe('second')
group.handleKeydown(second, secondEvent)
expect(document.activeElement).toBe(first)
expect(group.currentTabStopId.current).toBe('first')
})
it('respects rtl horizontal navigation', () => {
const root = document.createElement('div')
root.style.direction = 'rtl'
const first = document.createElement('button')
const second = document.createElement('button')
first.id = 'first'
second.id = 'second'
first.setAttribute('data-roving-item', '')
second.setAttribute('data-roving-item', '')
root.append(first, second)
document.body.appendChild(root)
const group = createGroup(root)
const event = createKeydownEvent('ArrowLeft')
group.handleKeydown(first, event)
expect(document.activeElement).toBe(second)
expect(group.currentTabStopId.current).toBe('second')
})
it('calls onCandidateFocus and can refocus the current tab stop', () => {
const root = document.createElement('div')
const first = document.createElement('button')
const second = document.createElement('button')
first.id = 'first'
second.id = 'second'
first.setAttribute('data-roving-item', '')
second.setAttribute('data-roving-item', '')
root.append(first, second)
document.body.appendChild(root)
const onCandidateFocus = vi.fn()
const group = new RovingFocusGroup({
candidateAttr: 'data-roving-item',
providerNode: readableActive(() => root),
loop: readableActive(() => true),
orientation: readableActive(() => 'horizontal'),
onCandidateFocus
})
group.handleKeydown(first, createKeydownEvent('ArrowRight'))
group.focusCurrentTabStop()
expect(onCandidateFocus).toHaveBeenCalledWith(second)
expect(document.activeElement).toBe(second)
})
})

@ -1,24 +0,0 @@
import { isBrowser } from '$uix/lib/dom'
export const viewport = $state({ width: isBrowser ? window.innerWidth : 0 })
export const viewportWidth = {
get current() {
return viewport.width
}
}
let trackingInitialized = false
export function initViewportTracking(): void {
if (!isBrowser) return
if (trackingInitialized) return
trackingInitialized = true
const update = () => {
viewport.width = window.innerWidth
}
update()
window.addEventListener('resize', update, { passive: true })
}

@ -28,3 +28,29 @@ export type {
} from './types';
export { v } from './types';
// Runtime — orchestrates prewrite → semantic.emit → handler → effects on
// behalf of any consumer that drives a morfo. Soma providers are the
// primary consumer today.
export {
createMorfoRuntime,
type MorfoRuntime,
type MorfoRuntimeSources,
type RegisterPartOpts,
type SourceMap,
type SemanticEmitter,
type TriggerOptions
} from './runtime.svelte';
// Pure morfo→attrs resolver — walks `data` / `aria` declarations and
// returns a flat attribute map. No reactivity, no DOM. Used by the
// runtime and by anything that needs to derive attrs from a morfo.
export {
findMorfoPart,
resolvePartAttrs,
resolveMorfoSource,
resolveMorfoDataValue,
resolveMorfoAriaValue,
shouldEmitMorfoEntry,
type MorfoBindings
} from './resolver';

@ -16,7 +16,7 @@ import type {
MorfoPart,
MorfoPrimitiveValueSource,
MorfoValueSource
} from '../../morfo/types';
} from './types';
export interface MorfoBindings {
props?: Record<string, unknown>;

@ -3,15 +3,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushSync } from 'svelte'
import { createActiveDom } from '$uix/adom'
import { createActiveDom } from '$adom'
import type { SemanticSignal } from '$uix/sema'
import type { SemanticEmitter } from './runtime.svelte'
import { state } from '../reactive'
import { toggleMorfo } from '../../morfo/components/toggle'
import { toastMorfo } from '../../morfo/components/toast'
import { dialogMorfo } from '../../morfo/components/dialog'
import { switchMorfo } from '../../morfo/components/switch'
import { state } from '$libs/reactive'
import { toggleMorfo } from './components/toggle'
import { toastMorfo } from './components/toast'
import { dialogMorfo } from './components/dialog'
import { switchMorfo } from './components/switch'
import { createMorfoRuntime } from './runtime.svelte'
/**

@ -27,7 +27,7 @@
*/
import { untrack } from 'svelte';
import type { ActiveDom, DomAttrValue } from '$uix/adom';
import type { ActiveDom, DomAttrValue } from '$adom';
import { resolveSemaDuration, resolveSemaIntent, type EngineSemantic } from '$uix/sema';
/**
@ -38,10 +38,10 @@ import { resolveSemaDuration, resolveSemaIntent, type EngineSemantic } from '$ui
*/
export type SemanticEmitter = Pick<EngineSemantic, 'emit'>;
import type { Morfo, MorfoEvent, MorfoPart } from '../../morfo/types';
import { createAttrs } from '../attrs';
import { attachRef, type RefAttachment } from '../provider/provider.svelte';
import type { Active, State } from '../reactive';
import type { Morfo, MorfoEvent, MorfoPart } from './types';
import { createAttrs } from '$soma/attrs';
import { attachRef, type RefAttachment } from '$soma/provider/provider.svelte';
import type { Active, State } from '$libs/reactive';
import {
findMorfoPart,

@ -6,7 +6,7 @@
* in development; it is a no-op in production.
*/
import type { Morfo, MorfoPart } from '../../morfo/types';
import type { Morfo, MorfoPart } from '$uix/morfo';
/** Internal shape of a registered contract. Not consumer-facing. */
interface DataAttrRecord {

@ -1,4 +1,4 @@
import type { Morfo, MorfoPart } from '../../morfo/types';
import type { Morfo, MorfoPart } from '$uix/morfo';
/**
* Creates data-attribute names for a soma component from its morfo.

@ -4,7 +4,7 @@ import { createAttrs, registerContract } from '../../attrs';
import type { OptsFromProps, State } from '../../reactive';
import type { OnChangeFn } from '../../types';
import { Soma } from '../../core/soma.svelte';
import { createMorfoRuntime, type MorfoRuntime } from '../../morfo';
import { createMorfoRuntime, type MorfoRuntime } from '$uix/morfo';
import type { HTMLImgAttributes } from 'svelte/elements';
import type { AvatarLoadingStatus, AvatarProps, AvatarImageProps } from './types';

@ -3,7 +3,7 @@ import { createAttrs, registerContract } from '../../attrs';
import { state, type ActiveProps, type StateProps } from '../../reactive';
import type { SomaMouseEvent } from '../../types';
import { Soma } from '../../core/soma.svelte';
import { createMorfoRuntime, type MorfoRuntime } from '../../morfo';
import { createMorfoRuntime, type MorfoRuntime } from '$uix/morfo';
import { collapsibleMorfo } from '../../../morfo/components/collapsible';
createAttrs(collapsibleMorfo);

@ -31,7 +31,7 @@ import type {
OnChangeFn
} from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard';
import { getDocument, isBrowser } from '../../dom';
import { getDocument, isBrowser } from '$libs/dom';
import { isNumberString } from '../../types/guards';
import { useId } from '../../id';
import { Soma } from '../../core/soma.svelte';

@ -19,7 +19,7 @@ import { ScrollLock } from '../../layers/scroll-lock.svelte';
import { TextSelection } from '../../layers/text-selection.svelte';
import { dialogMorfo } from '../../../morfo/components/dialog';
import { createMorfoRuntime, type MorfoRuntime } from '../../morfo/runtime.svelte';
import { createMorfoRuntime, type MorfoRuntime } from '$uix/morfo';
// ── Provider (root) ─────────────────────────────────────────────────────────

@ -37,7 +37,7 @@ import type { DragDirection } from '../../layers/gesture';
import { DRAWER_LANGS } from './langs';
import { drawerMorfo } from '../../../morfo/components/drawer';
import { createMorfoRuntime, type MorfoRuntime } from '../../morfo/runtime.svelte';
import { createMorfoRuntime, type MorfoRuntime } from '$uix/morfo';
const attrs = createAttrs(drawerMorfo);
registerContract(drawerMorfo);

@ -2,7 +2,7 @@
import type { Snippet } from 'svelte';
import { getAllContexts, mount, unmount } from 'svelte';
import { watch } from 'runed';
import { isBrowser } from '../../dom';
import { isBrowser } from '$libs/dom';
import { Soma } from '../../core/soma.svelte';
import PortalConsumer from './portal-consumer.svelte';

@ -36,7 +36,7 @@ import {
} from '../../layers/floating';
import { popoverMorfo } from '../../../morfo/components/popover';
import { createMorfoRuntime, type MorfoRuntime } from '../../morfo/runtime.svelte';
import { createMorfoRuntime, type MorfoRuntime } from '$uix/morfo';
const attrs = createAttrs(popoverMorfo);
registerContract(popoverMorfo);

@ -4,7 +4,7 @@ import { readableActive, state, type Active, type ActiveProps } from '../../reac
import type { Direction } from '../../types';
import { Soma } from '../../core/soma.svelte';
import { SCROLL_AREA_LANGS } from './langs';
import { isBrowser } from '../../dom';
import { isBrowser } from '$libs/dom';
import { scrollAreaMorfo } from '../../../morfo/components/scroll-area';

@ -3,7 +3,7 @@ import { createAttrs, registerContract } from '../../attrs';
import type { OptsFromProps } from '../../reactive';
import type { OnChangeFn, SomaMouseEvent } from '../../types';
import { Soma } from '../../core/soma.svelte';
import { createMorfoRuntime, type MorfoRuntime } from '../../morfo';
import { createMorfoRuntime, type MorfoRuntime } from '$uix/morfo';
import { switchMorfo } from '../../../morfo/components/switch';
import type { SwitchProps } from './types';

@ -21,7 +21,7 @@ import type {
OnChangeFn
} from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard';
import { getDocument, isBrowser } from '../../dom';
import { getDocument, isBrowser } from '$libs/dom';
import { isNumberString } from '../../types/guards';
import { useId } from '../../id';
import { Soma } from '../../core/soma.svelte';

@ -5,8 +5,8 @@ import { createAttrs, registerContract } from '../../attrs';
import { readableActive, state, type ActiveProps } from '../../reactive';
import type { SomaMouseEvent } from '../../types';
import { Soma } from '../../core/soma.svelte';
import { isBrowser } from '../../dom';
import { createMorfoRuntime, type MorfoRuntime } from '../../morfo';
import { isBrowser } from '$libs/dom';
import { createMorfoRuntime, type MorfoRuntime } from '$uix/morfo';
import { Presence } from '../../layers/presence.svelte';
import type { Toaster, ToastData, SwipeDirection } from './toaster.svelte';

@ -3,7 +3,7 @@ import { createAttrs, registerContract } from '../../attrs';
import type { OptsFromProps } from '../../reactive';
import type { OnChangeFn, SomaMouseEvent } from '../../types';
import { Soma } from '../../core/soma.svelte';
import { createMorfoRuntime, type MorfoRuntime } from '../../morfo';
import { createMorfoRuntime, type MorfoRuntime } from '$uix/morfo';
import { FieldProvider } from '../field/field-provider.svelte';
import { toggleMorfo } from '../../../morfo/components/toggle';

@ -1,5 +1,5 @@
import { srOnlyStylesString } from '../css';
import { isBrowser, isHTMLElement } from '../dom';
import { isBrowser, isHTMLElement } from '$libs/dom';
/**
* Creates or gets an announcer element used to announce messages to screen

@ -20,7 +20,7 @@ import {
EDITABLE_SEGMENT_PARTS,
EDITABLE_TIME_SEGMENT_PARTS
} from '../external/dates';
import { isBrowser } from '../dom';
import { isBrowser } from '$libs/dom';
import { isNumberString } from '../types/guards';
import { styleToString } from '../css';
import { KEYS } from '../keyboard';

@ -4,7 +4,7 @@
* emitted by the Input provider.
*/
import { isHTMLElement } from '../dom';
import { isHTMLElement } from '$libs/dom';
import { KEYS } from '../keyboard';
/**

@ -1,76 +0,0 @@
// ── Environment ──────────────────────────────────────────────────────────────
export const isBrowser = typeof document !== 'undefined';
export const isIOS =
isBrowser && typeof navigator !== 'undefined' && /iP(hone|od|ad)/.test(navigator.userAgent);
export function isTouch(e: PointerEvent): boolean {
return e.pointerType === 'touch';
}
// ── Type guards ──────────────────────────────────────────────────────────────
export function isHTMLElement(node: unknown): node is HTMLElement {
return node instanceof HTMLElement;
}
export function isDocument(node: unknown): node is Document {
return (
node != null && typeof node === 'object' && 'nodeType' in node && (node as Node).nodeType === 9
);
}
export function isWindow(node: unknown): node is Window {
return node != null && typeof node === 'object' && 'visualViewport' in node;
}
export function isNode(node: unknown): node is Node {
return node != null && typeof node === 'object' && 'nodeType' in node;
}
export function isShadowRoot(node: unknown): node is ShadowRoot {
return node instanceof ShadowRoot;
}
// ── DOM traversal ────────────────────────────────────────────────────────────
export function getNodeName(node: Node): string {
return isHTMLElement(node) ? node.localName : '';
}
/** Contains check that handles shadow DOM. */
export function contains(parent: Node | null | undefined, child: Node | null | undefined): boolean {
if (!parent || !child) return false;
if (parent.contains(child)) return true;
const root = child.getRootNode?.();
if (root && isShadowRoot(root)) return parent === root.host || parent.contains(root.host);
return false;
}
export function getDocument(node?: Node | null): Document {
if (!node) return document;
return node.ownerDocument ?? document;
}
export function getWindow(node?: Node | null): Window {
return getDocument(node).defaultView ?? window;
}
/** Get active element, handling shadow DOM nesting. */
export function getActiveElement(root?: Node | null): Element | null {
let active = getDocument(root).activeElement;
while (active?.shadowRoot) {
const inner = active.shadowRoot.activeElement;
if (!inner) break;
active = inner;
}
return active;
}
export function getParentNode(node: Node): Node {
if (getNodeName(node as HTMLElement) === 'html') return node;
const parent = node.parentNode;
if (parent && isShadowRoot(parent)) return parent.host;
return parent ?? node;
}

@ -1,67 +0,0 @@
import { isElementHidden, isSelectableInput } from './guards';
export type FocusableTarget = string | HTMLElement | SVGElement | null;
/** Focus an element without scrolling the page. */
export function focusWithoutScroll(element: HTMLElement | null): void {
if (!element) return;
element.focus({ preventScroll: true });
}
/** Focus an element, optionally selecting its content. */
export function focus(element: HTMLElement | null, opts?: { select?: boolean }): void {
if (!element) return;
element.focus({ preventScroll: true });
if (opts?.select && isSelectableInput(element)) {
element.select();
}
}
/** Focus the first focusable element in a list of candidates. */
export function focusFirst(candidates: HTMLElement[], opts?: { select?: boolean }): boolean {
const first = candidates.find((el) => {
focus(el, opts);
return document.activeElement === el;
});
return !!first;
}
const FOCUSABLE_SELECTOR = [
'a[href]',
'area[href]',
'button:not(:disabled)',
'input:not(:disabled)',
'select:not(:disabled)',
'textarea:not(:disabled)',
'[tabindex]',
'[contenteditable]',
'audio[controls]',
'video[controls]',
'details > summary'
].join(',');
/** Get all tabbable candidates in a container using TreeWalker. */
export function getTabbableCandidates(container: HTMLElement): HTMLElement[] {
const candidates: HTMLElement[] = [];
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
acceptNode(node) {
const el = node as HTMLElement;
if (el.matches(FOCUSABLE_SELECTOR) && !isElementHidden(el, container) && el.tabIndex >= 0) {
return NodeFilter.FILTER_ACCEPT;
}
return NodeFilter.FILTER_SKIP;
}
});
while (walker.nextNode()) {
candidates.push(walker.currentNode as HTMLElement);
}
return candidates;
}
/** Get the first and last tabbable elements in a container. */
export function getTabbableEdges(container: HTMLElement): [HTMLElement | null, HTMLElement | null] {
const candidates = getTabbableCandidates(container);
return [candidates[0] ?? null, candidates[candidates.length - 1] ?? null];
}

@ -1,36 +0,0 @@
/** Check if an element is hidden (walks up DOM tree). */
export function isElementHidden(node: HTMLElement, stopAt?: HTMLElement): boolean {
if (getComputedStyle(node).visibility === 'hidden') return true;
while (node) {
if (stopAt && node === stopAt) return false;
if (getComputedStyle(node).display === 'none') return true;
node = node.parentElement as HTMLElement;
}
return false;
}
/** Check if input element supports text selection. */
export function isSelectableInput(
element: unknown
): element is HTMLInputElement & { type: string } {
if (!(element instanceof HTMLInputElement)) return false;
const nonSelectable = new Set([
'button',
'checkbox',
'file',
'image',
'radio',
'reset',
'submit'
]);
return !nonSelectable.has(element.type);
}
/** Check if the element's focus is visible (keyboard navigation). */
export function isFocusVisible(element: Element): boolean {
try {
return element.matches(':focus-visible');
} catch {
return false;
}
}

@ -1,27 +0,0 @@
export {
isBrowser,
isIOS,
isTouch,
isHTMLElement,
isDocument,
isWindow,
isNode,
isShadowRoot,
getNodeName,
contains,
getDocument,
getWindow,
getActiveElement,
getParentNode
} from './dom';
export {
type FocusableTarget,
focusWithoutScroll,
focus,
focusFirst,
getTabbableCandidates,
getTabbableEdges
} from './focus/focus';
export { isElementHidden, isSelectableInput, isFocusVisible } from './focus/guards';

@ -60,7 +60,7 @@ export {
getTabbableEdges,
isElementHidden,
isFocusVisible
} from './dom';
} from '$libs/dom';
// ── Events ───────────────────────────────────────────────────────────────────
export { addEventListener, type EventCallback } from './events';

@ -31,7 +31,7 @@ import { tick } from 'svelte';
import { watch } from 'runed';
import { on } from 'svelte/events';
import { readableActive, type Active } from '$soma/reactive';
import { isHTMLElement, contains, getDocument } from '$soma/dom';
import { isHTMLElement, contains, getDocument } from '$libs/dom';
import { KEYS } from '$soma/keyboard';
// ── Types ────────────────────────────────────────────────────────────────────

@ -12,7 +12,7 @@ import {
} from '@floating-ui/dom';
import { attachRef, type RefAttachment } from '$soma/provider';
import { cssToStyleObj, styleToString } from '$soma/css';
import { getWindow } from '$soma/dom';
import { getWindow } from '$libs/dom';
import { state, readableActive, type Active, type ActiveProps } from '$soma/reactive';
import { isNotNull } from '$soma/types';
import { useId } from '$soma/id';

@ -1,5 +1,5 @@
import { on } from 'svelte/events';
import { getDocument } from '$soma/dom';
import { getDocument } from '$libs/dom';
import { watch } from 'runed';
import type { Side } from '@floating-ui/dom';

@ -1,11 +1,11 @@
/**
* Soma wrapper over `uix/adom` body scroll lock.
* Soma wrapper over `arts/adom` body scroll lock.
*
* Keeps the current `ScrollLock` API for providers while delegating the
* actual body-lock implementation to the higher DOM runtime layer.
* actual body-lock implementation to the ecosystem-level DOM runtime.
*/
import { BodyScrollLock, type BodyScrollLockOption } from '$uix/adom'
import { BodyScrollLock, type BodyScrollLockOption } from '$adom'
export type ScrollLockOption = BodyScrollLockOption

@ -19,7 +19,7 @@
import { watch } from 'runed';
import { on } from 'svelte/events';
import { type Active } from '$soma/reactive';
import { isHTMLElement, contains, getDocument } from '$soma/dom';
import { isHTMLElement, contains, getDocument } from '$libs/dom';
// ── Types ────────────────────────────────────────────────────────────────────

@ -1,17 +0,0 @@
export {
createMorfoRuntime,
type MorfoRuntime,
type MorfoRuntimeSources,
type RegisterPartOpts,
type SourceMap
} from './runtime.svelte';
export {
findMorfoPart,
resolvePartAttrs,
resolveMorfoSource,
resolveMorfoDataValue,
resolveMorfoAriaValue,
shouldEmitMorfoEntry,
type MorfoBindings
} from './resolver';

@ -84,7 +84,7 @@ import type {
MorfoValueSource,
MorfoData,
MorfoAriaEntry
} from '../../morfo/types';
} from '$uix/morfo';
// ---- Ref attachment ----

Loading…
Cancel
Save

Powered by TurnKey Linux.