Inject ActiveDom into Soma scroll lock

active-uix
dev 5 months ago
parent 945ef684c0
commit a4c04a00b3

@ -117,6 +117,10 @@ Actualizacion 2026-05-16:
- Guardia nueva: Sema no puede registrar listeners globales nativos - Guardia nueva: Sema no puede registrar listeners globales nativos
(`document/window.addEventListener`) ni usar `console.*` en produccion; los (`document/window.addEventListener`) ni usar `console.*` en produccion; los
canales deben pasar por servicios inyectados y logger. canales deben pasar por servicios inyectados y logger.
- Cerrado: `ScrollLock` de Soma recibe ahora `ActiveDom` del scope y lo pasa
a `BodyScrollLock`; el helper de ADom resuelve document/window/listeners
desde el runtime inyectado, con fallback nativo solo para uso standalone y
tests.
- Documentacion corregida: README, arquitectura y comentario de - Documentacion corregida: README, arquitectura y comentario de
`core/soma.svelte.ts` ya no recomiendan que el propio Soma importe desde `core/soma.svelte.ts` ya no recomiendan que el propio Soma importe desde
`$soma/*`. `$soma/*`.

@ -2,6 +2,7 @@ import { SvelteMap } from 'svelte/reactivity';
import { writableActive, type State } from '$reactive'; import { writableActive, type State } from '$reactive';
import { isBrowser, isIOS } from '$libs/dom'; import { isBrowser, isIOS } from '$libs/dom';
import type { ActiveDom } from './active-dom.svelte';
/** /**
* Options reserved for future expansion (scrollbar padding/margin compensation * Options reserved for future expansion (scrollbar padding/margin compensation
@ -9,6 +10,10 @@ import { isBrowser, isIOS } from '$libs/dom';
*/ */
export type BodyScrollLockOption = Record<string, never>; export type BodyScrollLockOption = Record<string, never>;
export interface BodyScrollLockRuntimeOptions {
dom?: Pick<ActiveDom, 'getDocument' | 'getWindow' | 'listen'>;
}
/** /**
* Properties we mutate on `<body>` while locked. Must all be saved so we can * 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. * restore the exact pre-lock state when the last lock releases.
@ -37,6 +42,7 @@ type ModuleState = {
cleanupTimeoutId: number | null; cleanupTimeoutId: number | null;
cleanupScheduledAt: number | null; cleanupScheduledAt: number | null;
idCounter: number; idCounter: number;
dom: BodyScrollLockRuntimeOptions['dom'];
}; };
let _state: ModuleState | undefined; let _state: ModuleState | undefined;
@ -48,10 +54,34 @@ function state(): ModuleState {
stopTouchMoveListener: null, stopTouchMoveListener: null,
cleanupTimeoutId: null, cleanupTimeoutId: null,
cleanupScheduledAt: null, cleanupScheduledAt: null,
idCounter: 0 idCounter: 0,
dom: undefined
}); });
} }
function activeDocument(): Document {
return state().dom?.getDocument() ?? document;
}
function activeWindow(): Window {
return state().dom?.getWindow() ?? window;
}
function listen(
target: EventTarget,
event: string | readonly string[],
handler: EventListener,
options?: boolean | AddEventListenerOptions
): () => void {
const dom = state().dom;
if (dom) return dom.listen(target, event, handler, options);
const events = Array.isArray(event) ? event : [event];
for (const e of events) target.addEventListener(e, handler, options);
return () => {
for (const e of events) target.removeEventListener(e, handler, options);
};
}
function nextId(): string { function nextId(): string {
const s = state(); const s = state();
s.idCounter += 1; s.idCounter += 1;
@ -68,14 +98,14 @@ function isAnyLocked(): boolean {
function cancelPendingCleanup() { function cancelPendingCleanup() {
const s = state(); const s = state();
if (s.cleanupTimeoutId === null || !isBrowser) return; if (s.cleanupTimeoutId === null || !isBrowser) return;
window.clearTimeout(s.cleanupTimeoutId); activeWindow().clearTimeout(s.cleanupTimeoutId);
s.cleanupTimeoutId = null; s.cleanupTimeoutId = null;
} }
function captureBodyProperties() { function captureBodyProperties() {
const s = state(); const s = state();
if (!isBrowser || s.savedProperties !== null) return; if (!isBrowser || s.savedProperties !== null) return;
const style = document.body.style; const style = activeDocument().body.style;
const saved: SavedProperties = {}; const saved: SavedProperties = {};
for (const prop of MANAGED_PROPERTIES) { for (const prop of MANAGED_PROPERTIES) {
const value = style.getPropertyValue(prop); const value = style.getPropertyValue(prop);
@ -87,7 +117,7 @@ function captureBodyProperties() {
function restoreBodyProperties() { function restoreBodyProperties() {
if (!isBrowser) return; if (!isBrowser) return;
const s = state(); const s = state();
const style = document.body.style; const style = activeDocument().body.style;
const saved = s.savedProperties ?? {}; const saved = s.savedProperties ?? {};
for (const prop of MANAGED_PROPERTIES) { for (const prop of MANAGED_PROPERTIES) {
const original = saved[prop]; const original = saved[prop];
@ -110,16 +140,18 @@ function detachTouchMoveListener() {
function attachTouchMoveListener() { function attachTouchMoveListener() {
const s = state(); const s = state();
if (!isBrowser || !isIOS || s.stopTouchMoveListener) return; if (!isBrowser || !isIOS || s.stopTouchMoveListener) return;
const doc = activeDocument();
const listener = (event: TouchEvent) => { const listener = (event: TouchEvent) => {
if (event.target !== document.documentElement) return; if (event.target !== doc.documentElement) return;
if (event.touches.length > 1) return; if (event.touches.length > 1) return;
event.preventDefault(); event.preventDefault();
}; };
document.addEventListener('touchmove', listener, { passive: false }); s.stopTouchMoveListener = listen(doc, 'touchmove', listener as EventListener, { passive: false });
const teardown = s.stopTouchMoveListener;
s.stopTouchMoveListener = () => { s.stopTouchMoveListener = () => {
document.removeEventListener('touchmove', listener); teardown();
state().stopTouchMoveListener = null; state().stopTouchMoveListener = null;
}; };
} }
@ -130,12 +162,14 @@ function applyBodyLock() {
cancelPendingCleanup(); cancelPendingCleanup();
captureBodyProperties(); captureBodyProperties();
const style = document.body.style; const doc = activeDocument();
const htmlStyle = getComputedStyle(document.documentElement); const win = activeWindow();
const bodyStyle = getComputedStyle(document.body); const style = doc.body.style;
const htmlStyle = win.getComputedStyle(doc.documentElement);
const bodyStyle = win.getComputedStyle(doc.body);
const hasStableGutter = const hasStableGutter =
htmlStyle.scrollbarGutter?.includes('stable') || bodyStyle.scrollbarGutter?.includes('stable'); htmlStyle.scrollbarGutter?.includes('stable') || bodyStyle.scrollbarGutter?.includes('stable');
const verticalScrollbarWidth = window.innerWidth - document.documentElement.clientWidth; const verticalScrollbarWidth = win.innerWidth - doc.documentElement.clientWidth;
const paddingRight = Number.parseInt(bodyStyle.paddingRight || '0', 10); const paddingRight = Number.parseInt(bodyStyle.paddingRight || '0', 10);
if (verticalScrollbarWidth > 0 && !hasStableGutter) { if (verticalScrollbarWidth > 0 && !hasStableGutter) {
@ -166,7 +200,7 @@ function scheduleCleanupIfNoNewLocks(delay: number | null, callback: () => void)
} }
}; };
s.cleanupTimeoutId = window.setTimeout(cleanupFn, delay ?? 24); s.cleanupTimeoutId = activeWindow().setTimeout(cleanupFn, delay ?? 24);
} }
export class BodyScrollLock { export class BodyScrollLock {
@ -175,8 +209,10 @@ export class BodyScrollLock {
constructor( constructor(
initialState?: boolean, initialState?: boolean,
private readonly restoreScrollDelay: () => number | null = () => null private readonly restoreScrollDelay: () => number | null = () => null,
options: BodyScrollLockRuntimeOptions = {}
) { ) {
state().dom = options.dom;
state().lockMap.set(this.id, initialState ?? false); state().lockMap.set(this.id, initialState ?? false);
this.locked = writableActive( this.locked = writableActive(

@ -292,7 +292,7 @@ export class ContextMenuContentProvider {
isValidEvent: readableActive(() => () => false) isValidEvent: readableActive(() => () => false)
}); });
this.scrollLock = new ScrollLock(); this.scrollLock = new ScrollLock(this.provider.soma.dom);
$effect(() => { $effect(() => {
this.scrollLock.locked.current = this.scrollLock.locked.current =
this.provider.opts.open.current && opts.preventScroll.current; this.provider.opts.open.current && opts.preventScroll.current;

@ -358,7 +358,11 @@ export class DialogContentProvider {
isValidEvent: readableActive(() => () => false) isValidEvent: readableActive(() => () => false)
}); });
this.scrollLock = new ScrollLock(undefined, () => opts.restoreScrollDelay.current); this.scrollLock = new ScrollLock(
this.provider.soma.dom,
undefined,
() => opts.restoreScrollDelay.current
);
$effect(() => { $effect(() => {
this.scrollLock.locked.current = this.scrollLock.locked.current =
this.provider.opts.open.current && opts.preventScroll.current; this.provider.opts.open.current && opts.preventScroll.current;

@ -484,7 +484,11 @@ export class DrawerContentProvider {
// ── Scroll lock ────────────────────────────────────────────────────── // ── Scroll lock ──────────────────────────────────────────────────────
// Non-modal: no scroll lock — background scrolling must work. // Non-modal: no scroll lock — background scrolling must work.
this.scrollLock = new ScrollLock(undefined, () => opts.restoreScrollDelay.current); this.scrollLock = new ScrollLock(
this.provider.soma.dom,
undefined,
() => opts.restoreScrollDelay.current
);
$effect(() => { $effect(() => {
this.scrollLock.locked.current = this.scrollLock.locked.current =
this.provider.opts.open.current && opts.preventScroll.current && this.provider.isOverlay; this.provider.opts.open.current && opts.preventScroll.current && this.provider.isOverlay;

@ -314,7 +314,7 @@ export class MenuContentProvider {
}); });
// Scroll lock // Scroll lock
this.scrollLock = new ScrollLock(); this.scrollLock = new ScrollLock(this.provider.soma.dom);
$effect(() => { $effect(() => {
this.scrollLock.locked.current = this.scrollLock.locked.current =
this.provider.opts.open.current && opts.preventScroll.current; this.provider.opts.open.current && opts.preventScroll.current;

@ -479,7 +479,11 @@ export class PopoverContentProvider {
// Scroll lock. Same modal-derived default as trapFocus — modal // Scroll lock. Same modal-derived default as trapFocus — modal
// popovers should freeze the page while open; non-modal popovers // popovers should freeze the page while open; non-modal popovers
// shouldn't restrict the user from scrolling around them. // shouldn't restrict the user from scrolling around them.
this.scrollLock = new ScrollLock(undefined, () => opts.restoreScrollDelay.current); this.scrollLock = new ScrollLock(
this.provider.soma.dom,
undefined,
() => opts.restoreScrollDelay.current
);
$effect(() => { $effect(() => {
const effectivePreventScroll = opts.preventScroll.current ?? this.provider.opts.modal.current; const effectivePreventScroll = opts.preventScroll.current ?? this.provider.opts.modal.current;
this.scrollLock.locked.current = this.provider.opts.open.current && effectivePreventScroll; this.scrollLock.locked.current = this.provider.opts.open.current && effectivePreventScroll;

@ -496,7 +496,7 @@ export class SelectContentProvider {
}); });
// Scroll lock // Scroll lock
this.scrollLock = new ScrollLock(); this.scrollLock = new ScrollLock(this.provider.soma.dom);
$effect(() => { $effect(() => {
this.scrollLock.locked.current = this.scrollLock.locked.current =
this.provider.opts.open.current && opts.preventScroll.current; this.provider.opts.open.current && opts.preventScroll.current;

@ -6,15 +6,17 @@
*/ */
import { BodyScrollLock, type BodyScrollLockOption } from '$adom' import { BodyScrollLock, type BodyScrollLockOption } from '$adom'
import type { ActiveDom } from '$adom'
export type ScrollLockOption = BodyScrollLockOption export type ScrollLockOption = BodyScrollLockOption
export class ScrollLock extends BodyScrollLock { export class ScrollLock extends BodyScrollLock {
constructor( constructor(
dom: ActiveDom,
initialState?: boolean, initialState?: boolean,
restoreScrollDelay: () => number | null = () => null restoreScrollDelay: () => number | null = () => null
) { ) {
super(initialState, restoreScrollDelay) super(initialState, restoreScrollDelay, { dom })
$effect(() => () => { $effect(() => () => {
this.destroy() this.destroy()

Loading…
Cancel
Save

Powered by TurnKey Linux.