diff --git a/continue.md b/continue.md index 82192ac32..5cbf56f38 100644 --- a/continue.md +++ b/continue.md @@ -117,6 +117,10 @@ Actualizacion 2026-05-16: - Guardia nueva: Sema no puede registrar listeners globales nativos (`document/window.addEventListener`) ni usar `console.*` en produccion; los 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 `core/soma.svelte.ts` ya no recomiendan que el propio Soma importe desde `$soma/*`. diff --git a/src/arts/adom/body-scroll-lock.svelte.ts b/src/arts/adom/body-scroll-lock.svelte.ts index b16f1a79d..5c6db06fd 100644 --- a/src/arts/adom/body-scroll-lock.svelte.ts +++ b/src/arts/adom/body-scroll-lock.svelte.ts @@ -2,6 +2,7 @@ import { SvelteMap } from 'svelte/reactivity'; import { writableActive, type State } from '$reactive'; import { isBrowser, isIOS } from '$libs/dom'; +import type { ActiveDom } from './active-dom.svelte'; /** * Options reserved for future expansion (scrollbar padding/margin compensation @@ -9,6 +10,10 @@ import { isBrowser, isIOS } from '$libs/dom'; */ export type BodyScrollLockOption = Record; +export interface BodyScrollLockRuntimeOptions { + dom?: Pick; +} + /** * Properties we mutate on `` while locked. Must all be saved so we can * restore the exact pre-lock state when the last lock releases. @@ -37,6 +42,7 @@ type ModuleState = { cleanupTimeoutId: number | null; cleanupScheduledAt: number | null; idCounter: number; + dom: BodyScrollLockRuntimeOptions['dom']; }; let _state: ModuleState | undefined; @@ -48,10 +54,34 @@ function state(): ModuleState { stopTouchMoveListener: null, cleanupTimeoutId: 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 { const s = state(); s.idCounter += 1; @@ -68,14 +98,14 @@ function isAnyLocked(): boolean { function cancelPendingCleanup() { const s = state(); if (s.cleanupTimeoutId === null || !isBrowser) return; - window.clearTimeout(s.cleanupTimeoutId); + activeWindow().clearTimeout(s.cleanupTimeoutId); s.cleanupTimeoutId = null; } function captureBodyProperties() { const s = state(); if (!isBrowser || s.savedProperties !== null) return; - const style = document.body.style; + const style = activeDocument().body.style; const saved: SavedProperties = {}; for (const prop of MANAGED_PROPERTIES) { const value = style.getPropertyValue(prop); @@ -87,7 +117,7 @@ function captureBodyProperties() { function restoreBodyProperties() { if (!isBrowser) return; const s = state(); - const style = document.body.style; + const style = activeDocument().body.style; const saved = s.savedProperties ?? {}; for (const prop of MANAGED_PROPERTIES) { const original = saved[prop]; @@ -110,16 +140,18 @@ function detachTouchMoveListener() { function attachTouchMoveListener() { const s = state(); if (!isBrowser || !isIOS || s.stopTouchMoveListener) return; + const doc = activeDocument(); const listener = (event: TouchEvent) => { - if (event.target !== document.documentElement) return; + if (event.target !== doc.documentElement) return; if (event.touches.length > 1) return; event.preventDefault(); }; - document.addEventListener('touchmove', listener, { passive: false }); + s.stopTouchMoveListener = listen(doc, 'touchmove', listener as EventListener, { passive: false }); + const teardown = s.stopTouchMoveListener; s.stopTouchMoveListener = () => { - document.removeEventListener('touchmove', listener); + teardown(); state().stopTouchMoveListener = null; }; } @@ -130,12 +162,14 @@ function applyBodyLock() { cancelPendingCleanup(); captureBodyProperties(); - const style = document.body.style; - const htmlStyle = getComputedStyle(document.documentElement); - const bodyStyle = getComputedStyle(document.body); + const doc = activeDocument(); + const win = activeWindow(); + const style = doc.body.style; + const htmlStyle = win.getComputedStyle(doc.documentElement); + const bodyStyle = win.getComputedStyle(doc.body); const hasStableGutter = 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); 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 { @@ -175,8 +209,10 @@ export class BodyScrollLock { constructor( 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); this.locked = writableActive( 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 97d4e3d40..4c4436d02 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 @@ -292,7 +292,7 @@ export class ContextMenuContentProvider { isValidEvent: readableActive(() => () => false) }); - this.scrollLock = new ScrollLock(); + this.scrollLock = new ScrollLock(this.provider.soma.dom); $effect(() => { this.scrollLock.locked.current = this.provider.opts.open.current && opts.preventScroll.current; diff --git a/src/uix/soma/components/dialog/dialog-provider.svelte.ts b/src/uix/soma/components/dialog/dialog-provider.svelte.ts index ce1639914..8dfa22601 100644 --- a/src/uix/soma/components/dialog/dialog-provider.svelte.ts +++ b/src/uix/soma/components/dialog/dialog-provider.svelte.ts @@ -358,7 +358,11 @@ export class DialogContentProvider { isValidEvent: readableActive(() => () => false) }); - this.scrollLock = new ScrollLock(undefined, () => opts.restoreScrollDelay.current); + this.scrollLock = new ScrollLock( + this.provider.soma.dom, + undefined, + () => opts.restoreScrollDelay.current + ); $effect(() => { this.scrollLock.locked.current = this.provider.opts.open.current && opts.preventScroll.current; diff --git a/src/uix/soma/components/drawer/drawer-provider.svelte.ts b/src/uix/soma/components/drawer/drawer-provider.svelte.ts index 20e976dc0..74b793a47 100644 --- a/src/uix/soma/components/drawer/drawer-provider.svelte.ts +++ b/src/uix/soma/components/drawer/drawer-provider.svelte.ts @@ -484,7 +484,11 @@ export class DrawerContentProvider { // ── Scroll lock ────────────────────────────────────────────────────── // 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(() => { this.scrollLock.locked.current = this.provider.opts.open.current && opts.preventScroll.current && this.provider.isOverlay; 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 081951ec1..84d946877 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 @@ -314,7 +314,7 @@ export class MenuContentProvider { }); // Scroll lock - this.scrollLock = new ScrollLock(); + this.scrollLock = new ScrollLock(this.provider.soma.dom); $effect(() => { this.scrollLock.locked.current = this.provider.opts.open.current && opts.preventScroll.current; diff --git a/src/uix/soma/components/popover/popover-provider.svelte.ts b/src/uix/soma/components/popover/popover-provider.svelte.ts index e7413d19f..7afa29910 100644 --- a/src/uix/soma/components/popover/popover-provider.svelte.ts +++ b/src/uix/soma/components/popover/popover-provider.svelte.ts @@ -479,7 +479,11 @@ export class PopoverContentProvider { // Scroll lock. Same modal-derived default as trapFocus — modal // popovers should freeze the page while open; non-modal popovers // 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(() => { const effectivePreventScroll = opts.preventScroll.current ?? this.provider.opts.modal.current; this.scrollLock.locked.current = this.provider.opts.open.current && effectivePreventScroll; diff --git a/src/uix/soma/components/select/select-provider.svelte.ts b/src/uix/soma/components/select/select-provider.svelte.ts index 705066382..084199d85 100644 --- a/src/uix/soma/components/select/select-provider.svelte.ts +++ b/src/uix/soma/components/select/select-provider.svelte.ts @@ -496,7 +496,7 @@ export class SelectContentProvider { }); // Scroll lock - this.scrollLock = new ScrollLock(); + this.scrollLock = new ScrollLock(this.provider.soma.dom); $effect(() => { this.scrollLock.locked.current = this.provider.opts.open.current && opts.preventScroll.current; diff --git a/src/uix/soma/layers/scroll-lock.svelte.ts b/src/uix/soma/layers/scroll-lock.svelte.ts index 82196a0f5..0ccd8f98a 100644 --- a/src/uix/soma/layers/scroll-lock.svelte.ts +++ b/src/uix/soma/layers/scroll-lock.svelte.ts @@ -6,15 +6,17 @@ */ import { BodyScrollLock, type BodyScrollLockOption } from '$adom' +import type { ActiveDom } from '$adom' export type ScrollLockOption = BodyScrollLockOption export class ScrollLock extends BodyScrollLock { constructor( + dom: ActiveDom, initialState?: boolean, restoreScrollDelay: () => number | null = () => null ) { - super(initialState, restoreScrollDelay) + super(initialState, restoreScrollDelay, { dom }) $effect(() => () => { this.destroy()