diff --git a/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts b/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts index ad48d5c72..4631e4686 100644 --- a/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts +++ b/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts @@ -263,6 +263,7 @@ export class ContextMenuContentProvider { }); this.focusScope = FocusScope.use({ + dom: this.provider.soma.dom, onOpenAutoFocus: opts.onOpenAutoFocus, onCloseAutoFocus: opts.onCloseAutoFocus, trap: opts.trapFocus, @@ -321,8 +322,8 @@ export class ContextMenuContentProvider { if (!container) return; const items = this.provider.getItems(container); - const active = document.activeElement as HTMLElement; - const currentIndex = items.indexOf(active); + const active = this.provider.soma.dom.activeElement(container) as HTMLElement | null; + const currentIndex = active ? items.indexOf(active) : -1; const dir = this.provider.opts.dir.current; const { nextKey, prevKey } = getDirectionalKeys(dir, 'vertical'); @@ -789,6 +790,7 @@ export class ContextMenuSubProvider { }); this.safePolygon = new SafePolygon({ + dom: this.provider.soma.dom, enabled: () => opts.open.current, triggerNode: () => this.subTriggerRef.current, contentNode: () => this.contentRef.current, @@ -1008,8 +1010,8 @@ export class ContextMenuSubContentProvider { const container = this.opts.ref.current; if (!container) return; const items = this.sub.provider.getItems(container); - const active = document.activeElement as HTMLElement; - const currentIndex = items.indexOf(active); + const active = this.sub.provider.soma.dom.activeElement(container) as HTMLElement | null; + const currentIndex = active ? items.indexOf(active) : -1; let targetIndex = -1; if (e.key === downKey) { diff --git a/src/uix/soma/components/dialog/dialog-provider.svelte.ts b/src/uix/soma/components/dialog/dialog-provider.svelte.ts index db2894547..079a78fab 100644 --- a/src/uix/soma/components/dialog/dialog-provider.svelte.ts +++ b/src/uix/soma/components/dialog/dialog-provider.svelte.ts @@ -316,6 +316,7 @@ export class DialogContentProvider { }); this.focusScope = FocusScope.use({ + dom: this.provider.soma.dom, onOpenAutoFocus: opts.onOpenAutoFocus, onCloseAutoFocus: opts.onCloseAutoFocus, trap: opts.trapFocus, diff --git a/src/uix/soma/components/drawer/drawer-provider.svelte.ts b/src/uix/soma/components/drawer/drawer-provider.svelte.ts index 26efa1d5e..19f132b89 100644 --- a/src/uix/soma/components/drawer/drawer-provider.svelte.ts +++ b/src/uix/soma/components/drawer/drawer-provider.svelte.ts @@ -443,6 +443,7 @@ export class DrawerContentProvider { // Non-modal: no focus trap, no auto-focus — background must stay interactive. const isModal = () => this.provider.isOverlay; this.focusScope = FocusScope.use({ + dom: this.provider.soma.dom, onOpenAutoFocus: readableActive(() => isModal() ? opts.onOpenAutoFocus.current : (e: Event) => e.preventDefault() ), diff --git a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts index f564c700e..0732e5a8f 100644 --- a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts +++ b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts @@ -279,6 +279,7 @@ export class MenuContentProvider { // Focus this.focusScope = FocusScope.use({ + dom: this.provider.soma.dom, onOpenAutoFocus: opts.onOpenAutoFocus, onCloseAutoFocus: opts.onCloseAutoFocus, trap: opts.trapFocus, @@ -344,8 +345,8 @@ export class MenuContentProvider { if (!container) return; const items = this.provider.getItems(container); - const active = document.activeElement as HTMLElement; - const currentIndex = items.indexOf(active); + const active = this.provider.soma.dom.activeElement(container) as HTMLElement | null; + const currentIndex = active ? items.indexOf(active) : -1; const dir = this.provider.opts.dir.current; const { nextKey, prevKey } = getDirectionalKeys(dir, 'vertical'); @@ -862,6 +863,7 @@ export class MenuSubProvider { // SafePolygon handles the pointer gap between SubTrigger and SubContent this.safePolygon = new SafePolygon({ + dom: this.provider.soma.dom, enabled: () => opts.open.current, triggerNode: () => this.subTriggerRef.current, contentNode: () => this.contentRef.current, @@ -1097,8 +1099,8 @@ export class MenuSubContentProvider { const container = this.opts.ref.current; if (!container) return; const items = this.sub.provider.getItems(container); - const active = document.activeElement as HTMLElement; - const currentIndex = items.indexOf(active); + const active = this.sub.provider.soma.dom.activeElement(container) as HTMLElement | null; + const currentIndex = active ? items.indexOf(active) : -1; let targetIndex = -1; if (e.key === downKey) { diff --git a/src/uix/soma/components/link-preview/link-preview-provider.svelte.ts b/src/uix/soma/components/link-preview/link-preview-provider.svelte.ts index b149e3d24..f405c3462 100644 --- a/src/uix/soma/components/link-preview/link-preview-provider.svelte.ts +++ b/src/uix/soma/components/link-preview/link-preview-provider.svelte.ts @@ -85,6 +85,7 @@ export class LinkPreviewProvider { // SafePolygon: preserve the preview when the pointer crosses the gap // between the trigger anchor and the content. new SafePolygon({ + dom: this.soma.dom, enabled: () => opts.open.current, triggerNode: () => this.triggerRef.current, contentNode: () => this.contentRef.current, diff --git a/src/uix/soma/components/number-field/number-field-provider.svelte.ts b/src/uix/soma/components/number-field/number-field-provider.svelte.ts index 248bfc75f..0897c0443 100644 --- a/src/uix/soma/components/number-field/number-field-provider.svelte.ts +++ b/src/uix/soma/components/number-field/number-field-provider.svelte.ts @@ -1,6 +1,6 @@ import { watch } from 'runed'; import { context, type WithRefOpts } from '../../provider'; -import { boolToEmptyStrOrUndef, boolToStr } from '$adom'; +import { boolToEmptyStrOrUndef, boolToStr, isHTMLElement } from '$adom'; import { readableActive, type Active, type ActiveProps, type StateProps } from '$libs/reactive'; import type { OnChangeFn, @@ -45,7 +45,6 @@ function escapeRegex(s: string): string { import { numberFieldMorfo } from '../../../morfo/components/number-field'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; - // ── Helpers ────────────────────────────────────────────────────────────────── function getDecimalPrecision(n: number): number { @@ -361,7 +360,8 @@ export class NumberFieldProvider { readonly handleWheel = (e: WheelEvent): void => { if (!this.opts.allowMouseWheel.current || this.isDisabled || this.isReadonly) return; - if (document.activeElement !== e.currentTarget) return; + if (!isHTMLElement(e.currentTarget)) return; + if (this.soma.dom.activeElement(e.currentTarget) !== e.currentTarget) return; e.preventDefault(); if (e.deltaY < 0) { this.increment(); diff --git a/src/uix/soma/components/popover/popover-provider.svelte.ts b/src/uix/soma/components/popover/popover-provider.svelte.ts index b82906dd4..163e5e9da 100644 --- a/src/uix/soma/components/popover/popover-provider.svelte.ts +++ b/src/uix/soma/components/popover/popover-provider.svelte.ts @@ -432,6 +432,7 @@ export class PopoverContentProvider { // modal popovers must trap (they own the screen until dismissed). // An explicit boolean from the consumer overrides the derivation. this.focusScope = FocusScope.use({ + dom: this.provider.soma.dom, onOpenAutoFocus: opts.onOpenAutoFocus, onCloseAutoFocus: opts.onCloseAutoFocus, trap: readableActive(() => opts.trapFocus.current ?? this.provider.opts.modal.current), diff --git a/src/uix/soma/components/select/select-provider.svelte.ts b/src/uix/soma/components/select/select-provider.svelte.ts index 50a3acc22..775b746b1 100644 --- a/src/uix/soma/components/select/select-provider.svelte.ts +++ b/src/uix/soma/components/select/select-provider.svelte.ts @@ -516,8 +516,8 @@ export class SelectContentProvider { if (!container) return; const items = this.provider.getItems(container); - const active = document.activeElement as HTMLElement; - const currentIndex = items.indexOf(active); + const active = this.provider.soma.dom.activeElement(container) as HTMLElement | null; + const currentIndex = active ? items.indexOf(active) : -1; const loop = this.provider.opts.loop.current; const dir = this.provider.opts.dir.current; const { nextKey, prevKey } = getDirectionalKeys(dir, 'vertical'); diff --git a/src/uix/soma/components/toolbar/toolbar-provider.svelte.ts b/src/uix/soma/components/toolbar/toolbar-provider.svelte.ts index 477663980..8a5b0acee 100644 --- a/src/uix/soma/components/toolbar/toolbar-provider.svelte.ts +++ b/src/uix/soma/components/toolbar/toolbar-provider.svelte.ts @@ -94,8 +94,8 @@ export class ToolbarProvider { const items = this.getItems(); if (items.length === 0) return; - const activeEl = document.activeElement as HTMLElement; - const currentIndex = items.indexOf(activeEl); + const activeEl = this.soma.dom.activeElement(this.opts.ref.current) as HTMLElement | null; + const currentIndex = activeEl ? items.indexOf(activeEl) : -1; if (currentIndex < 0) return; const loop = this.opts.loop.current; diff --git a/src/uix/soma/components/tooltip/tooltip-provider.svelte.ts b/src/uix/soma/components/tooltip/tooltip-provider.svelte.ts index 3cf4486fd..abe69c6c9 100644 --- a/src/uix/soma/components/tooltip/tooltip-provider.svelte.ts +++ b/src/uix/soma/components/tooltip/tooltip-provider.svelte.ts @@ -160,6 +160,7 @@ export class TooltipProvider { // SafePolygon: prevents tooltip from closing when pointer traverses // the gap between trigger and content (hoverable tooltips) new SafePolygon({ + dom: this.soma.dom, enabled: () => opts.open.current && !this.hoverableDisabled, triggerNode: () => this.triggerRef.current, contentNode: () => this.contentRef.current, diff --git a/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts b/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts index b3074dca8..57510fbe5 100644 --- a/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts +++ b/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts @@ -209,8 +209,8 @@ export class TreeViewProvider { const items = this.getVisibleItems(); if (items.length === 0) return; - const activeEl = document.activeElement as HTMLElement; - const currentIndex = items.indexOf(activeEl); + const activeEl = this.soma.dom.activeElement(this.opts.ref.current) as HTMLElement | null; + const currentIndex = activeEl ? items.indexOf(activeEl) : -1; const dir = this.opts.dir.current; const { nextKey: expandKey, prevKey: collapseKey } = getDirectionalKeys(dir, 'horizontal'); @@ -229,11 +229,13 @@ export class TreeViewProvider { } case expandKey: { e.preventDefault(); + if (!activeEl) break; this.handleExpandKey(activeEl, items, currentIndex); break; } case collapseKey: { e.preventDefault(); + if (!activeEl) break; this.handleCollapseKey(activeEl); break; } @@ -250,6 +252,7 @@ export class TreeViewProvider { case KEYS.ENTER: case KEYS.SPACE: { e.preventDefault(); + if (!activeEl) break; const value = activeEl.getAttribute('data-value'); if (value) this.select(value); break; @@ -258,6 +261,7 @@ export class TreeViewProvider { // * key expands all siblings if (e.key === '*') { e.preventDefault(); + if (!activeEl) break; this.expandSiblings(activeEl); } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { // Typeahead diff --git a/src/uix/soma/layers/floating/safe-polygon.ts b/src/uix/soma/layers/floating/safe-polygon.ts index 13a6796aa..e51acfa5a 100644 --- a/src/uix/soma/layers/floating/safe-polygon.ts +++ b/src/uix/soma/layers/floating/safe-polygon.ts @@ -1,7 +1,6 @@ -import { on } from 'svelte/events'; -import { getDocument } from '$adom'; import { watch } from 'runed'; import type { Side } from '@floating-ui/dom'; +import type { ActiveDom } from '$adom'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -49,6 +48,7 @@ function getSide(triggerRect: DOMRect, contentRect: DOMRect): Side { // ─── Options ───────────────────────────────────────────────────────────────── export type SafePolygonOptions = { + dom: ActiveDom; enabled: () => boolean; triggerNode: () => HTMLElement | null; contentNode: () => HTMLElement | null; @@ -78,24 +78,24 @@ export class SafePolygon { cancelLeaveFallback() { if (this.leaveFallbackRafId !== null) { - cancelAnimationFrame(this.leaveFallbackRafId); + this.opts.dom.cancelFrame(this.leaveFallbackRafId, this.trackedTriggerNode); this.leaveFallbackRafId = null; } } scheduleLeaveFallback() { this.cancelLeaveFallback(); - this.leaveFallbackRafId = requestAnimationFrame(() => { + this.leaveFallbackRafId = this.opts.dom.requestFrame(() => { this.leaveFallbackRafId = null; if (!this.exitPoint || !this.exitTarget) return; this.clearTracking(); this.opts.onPointerExit(); - }); + }, this.trackedTriggerNode); } cancelTransitIntentTimeout() { if (this.transitIntentTimeoutId !== null) { - clearTimeout(this.transitIntentTimeoutId); + this.opts.dom.getWindow(this.trackedTriggerNode).clearTimeout(this.transitIntentTimeoutId); this.transitIntentTimeoutId = null; } } @@ -103,12 +103,14 @@ export class SafePolygon { scheduleTransitIntentTimeout() { if (this.transitIntentTimeout === null) return; this.cancelTransitIntentTimeout(); - this.transitIntentTimeoutId = window.setTimeout(() => { - this.transitIntentTimeoutId = null; - if (!this.exitPoint || !this.exitTarget) return; - this.clearTracking(); - this.opts.onPointerExit(); - }, this.transitIntentTimeout); + this.transitIntentTimeoutId = this.opts.dom + .getWindow(this.trackedTriggerNode) + .setTimeout(() => { + this.transitIntentTimeoutId = null; + if (!this.exitPoint || !this.exitTarget) return; + this.clearTracking(); + this.opts.onPointerExit(); + }, this.transitIntentTimeout); } constructor(opts: SafePolygonOptions) { @@ -135,7 +137,7 @@ export class SafePolygon { } this.trackedTriggerNode = triggerNode; - const doc = getDocument(triggerNode); + const doc = this.opts.dom.getDocument(triggerNode); const handlePointerMove = (e: PointerEvent) => { this.onPointerMove([e.clientX, e.clientY], triggerNode, contentNode); @@ -180,11 +182,11 @@ export class SafePolygon { }; const cleanups = [ - on(doc, 'pointermove', handlePointerMove), - on(triggerNode, 'pointerleave', handleTriggerLeave), - on(triggerNode, 'pointerenter', handleTriggerEnter), - on(contentNode, 'pointerenter', handleContentEnter), - on(contentNode, 'pointerleave', handleContentLeave) + this.opts.dom.listen(doc, 'pointermove', handlePointerMove), + this.opts.dom.listen(triggerNode, 'pointerleave', handleTriggerLeave), + this.opts.dom.listen(triggerNode, 'pointerenter', handleTriggerEnter), + this.opts.dom.listen(contentNode, 'pointerenter', handleContentEnter), + this.opts.dom.listen(contentNode, 'pointerleave', handleContentLeave) ]; return () => cleanups.forEach((c) => c()); } diff --git a/src/uix/soma/layers/focus-scope.svelte.ts b/src/uix/soma/layers/focus-scope.svelte.ts index 469274436..e3cb9a940 100644 --- a/src/uix/soma/layers/focus-scope.svelte.ts +++ b/src/uix/soma/layers/focus-scope.svelte.ts @@ -1,11 +1,7 @@ -import { - state, - type Active, - type ActiveProps -} from '$libs/reactive'; +import { state, type Active, type ActiveProps } from '$libs/reactive'; import { focusable, isFocusable, tabbable } from 'tabbable'; -import { on } from 'svelte/events'; import { watch } from 'runed'; +import { isHTMLElement, type ActiveDom } from '$adom'; // ── FocusScopeManager (singleton) ──────────────────────────────────────────── @@ -24,8 +20,9 @@ class FocusScopeManager { const current = this.getActive(); if (current && current !== scope) current.pause(); - const active = document.activeElement as HTMLElement; - if (active && active !== document.body) this.preFocusHistory.set(scope, active); + const doc = scope.opts.dom.getDocument(scope.container); + const active = scope.opts.dom.activeElement(doc); + if (isHTMLElement(active) && active !== doc.body) this.preFocusHistory.set(scope, active); this.scopeStack.current = this.scopeStack.current.filter((s) => s !== scope); this.scopeStack.current.unshift(scope); @@ -63,6 +60,7 @@ interface FocusScopeOpts extends ActiveProps<{ onCloseAutoFocus: (event: Event) => void; trap: boolean; }> { + dom: ActiveDom; loop: Active; } @@ -122,16 +120,17 @@ export class FocusScope { }); this.opts.onOpenAutoFocus.current(event); if (!event.defaultPrevented) { - requestAnimationFrame(() => { + const frame = this.opts.dom.requestFrame(() => { if (!this.container) return; const first = this.getFirstTabbable(); if (first) { - first.focus(); + this.opts.dom.focus(first); this.manager.setFocusMemory(this, first); } else { - this.container.focus(); + this.opts.dom.focus(this.container); } - }); + }, this.container); + this.cleanupFns.push(() => this.opts.dom.cancelFrame(frame, this.container)); } } @@ -143,11 +142,12 @@ export class FocusScope { this.opts.onCloseAutoFocus.current?.(event); if (!event.defaultPrevented) { const preFocused = this.manager.getPreFocusMemory(this); - if (preFocused && document.contains(preFocused)) { + const doc = preFocused ? this.opts.dom.getDocument(preFocused) : undefined; + if (preFocused && doc?.contains(preFocused)) { try { - preFocused.focus(); + this.opts.dom.focus(preFocused); } catch { - document.body.focus(); + this.opts.dom.focus(doc.body); } } } @@ -156,23 +156,23 @@ export class FocusScope { private setupEventListeners() { if (!this.container || !this.opts.trap.current) return; const container = this.container; - const doc = container.ownerDocument; + const doc = this.opts.dom.getDocument(container); const handleFocus = (e: FocusEvent) => { if (this.paused || !this.manager.isActiveScope(this)) return; - const target = e.target as HTMLElement; - if (!target) return; + const target = e.target; + if (!isHTMLElement(target)) return; if (container.contains(target)) { this.manager.setFocusMemory(this, target); } else { const lastFocused = this.manager.getFocusMemory(this); if (lastFocused && container.contains(lastFocused) && isFocusable(lastFocused)) { e.preventDefault(); - lastFocused.focus(); + this.opts.dom.focus(lastFocused); } else { const firstTabbable = this.getFirstTabbable(); const firstFocusable = this.getAllFocusables()[0]; - (firstTabbable || firstFocusable || container).focus(); + this.opts.dom.focus(firstTabbable || firstFocusable || container); } } }; @@ -184,18 +184,19 @@ export class FocusScope { if (tabs.length === 0) return; const first = tabs[0]!; const last = tabs[tabs.length - 1]!; - if (!e.shiftKey && doc.activeElement === last) { + const active = this.opts.dom.activeElement(doc); + if (!e.shiftKey && active === last) { e.preventDefault(); - first.focus(); - } else if (e.shiftKey && doc.activeElement === first) { + this.opts.dom.focus(first); + } else if (e.shiftKey && active === first) { e.preventDefault(); - last.focus(); + this.opts.dom.focus(last); } }; this.cleanupFns.push( - on(doc, 'focusin', handleFocus, { capture: true }), - on(container, 'keydown', handleKeydown) + this.opts.dom.listen(doc, 'focusin', handleFocus, { capture: true }), + this.opts.dom.listen(container, 'keydown', handleKeydown) ); const observer = new MutationObserver(() => { @@ -203,9 +204,9 @@ export class FocusScope { if (lastFocused && !container.contains(lastFocused)) { const el = this.getFirstTabbable() || this.getAllFocusables()[0]; if (el) { - el.focus(); + this.opts.dom.focus(el); this.manager.setFocusMemory(this, el); - } else container.focus(); + } else this.opts.dom.focus(container); } }); observer.observe(container, { childList: true, subtree: true });