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
parent
a6e2c1e443
commit
9b07cead6b
@ -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 })
|
||||
}
|
||||
@ -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';
|
||||
@ -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';
|
||||
Loading…
Reference in new issue