Route Soma focus globals through ActiveDom

active-uix
dev 5 months ago
parent 48149ccf02
commit 8ca562ea36

@ -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) {

@ -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,

@ -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()
),

@ -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) {

@ -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,

@ -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();

@ -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),

@ -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');

@ -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;

@ -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,

@ -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

@ -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());
}

@ -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<boolean>;
}
@ -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 });

Loading…
Cancel
Save

Powered by TurnKey Linux.