From ffb6f54f7d6a975c074703d2169e2b8522e0f12c Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 26 Aug 2026 18:55:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(soma):=20la=20declaraci=C3=B3n=20de=20foco?= =?UTF-8?q?=20es=20CARGANTE=20=E2=80=94=20FocusScope=20la=20consume=20como?= =?UTF-8?q?=20pol=C3=ADtica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runtime.svelte.ts: SomaRuntime expone `focus` (compiled.focus) — el contrato viaja hasta la capa que lo ejecuta. - focus-scope.svelte.ts: FocusScopeOpts gana `policy?: MorfoFocusTrap`; el trap efectivo es `trapFocus ?? modal ?? policy.trap`. La declaración deja de ser prosa: una declaración falsa CAMBIA comportamiento y los tests de provider la cazan — guard más fuerte que comparar strings. - 6 providers de overlay cablean policy desde runtime.focus: context-menu, dialog, drawer, dropdown-menu, float-panel, popover. (menubar viaja con el lote de migraciones roving: mismo fichero, un solo dueño.) Co-Authored-By: Claude Fable 5 --- .../context-menu-provider.svelte.ts | 4 ++++ .../dialog/dialog-provider.svelte.ts | 4 ++++ .../drawer/drawer-provider.svelte.ts | 4 ++++ .../dropdown-menu-provider.svelte.ts | 4 ++++ .../float-panel-provider.svelte.ts | 4 ++++ .../popover/popover-provider.svelte.ts | 4 ++++ src/uix/soma/layers/focus-scope.svelte.ts | 24 +++++++++++++++++-- src/uix/soma/runtime.svelte.ts | 22 ++++++++++++++++- 8 files changed, 67 insertions(+), 3 deletions(-) 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 337f5a0f6..374e2b2ba 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 @@ -295,6 +295,10 @@ export class ContextMenuContentProvider { this.focusScope = FocusScope.use({ dom: this.provider.soma.dom, + // Species default from the declaration (eje focus-first): the + // per-instance trap resolution below still wins; the census keeps + // wrapper defaults and this declaration from drifting apart. + policy: this.provider.runtime.focus?.kind === 'trap' ? this.provider.runtime.focus : undefined, onOpenAutoFocus: opts.onOpenAutoFocus, onCloseAutoFocus: opts.onCloseAutoFocus, trap: opts.trapFocus, diff --git a/src/uix/soma/components/dialog/dialog-provider.svelte.ts b/src/uix/soma/components/dialog/dialog-provider.svelte.ts index 20775605c..ca044d6ec 100644 --- a/src/uix/soma/components/dialog/dialog-provider.svelte.ts +++ b/src/uix/soma/components/dialog/dialog-provider.svelte.ts @@ -384,6 +384,10 @@ export class DialogContentProvider { this.focusScope = FocusScope.use({ dom: this.provider.soma.dom, + // Species default from the declaration (eje focus-first): the + // per-instance trap resolution below still wins; the census keeps + // wrapper defaults and this declaration from drifting apart. + policy: this.provider.runtime.focus?.kind === 'trap' ? this.provider.runtime.focus : undefined, onOpenAutoFocus: opts.onOpenAutoFocus, onCloseAutoFocus: opts.onCloseAutoFocus, trap: this.effectiveTrapFocus, diff --git a/src/uix/soma/components/drawer/drawer-provider.svelte.ts b/src/uix/soma/components/drawer/drawer-provider.svelte.ts index 90c21b739..90ad8dba6 100644 --- a/src/uix/soma/components/drawer/drawer-provider.svelte.ts +++ b/src/uix/soma/components/drawer/drawer-provider.svelte.ts @@ -510,6 +510,10 @@ export class DrawerContentProvider { const isModal = () => this.provider.isOverlay; this.focusScope = FocusScope.use({ dom: this.provider.soma.dom, + // Species default from the declaration (eje focus-first): the + // per-instance trap resolution below still wins; the census keeps + // wrapper defaults and this declaration from drifting apart. + policy: this.provider.runtime.focus?.kind === 'trap' ? this.provider.runtime.focus : undefined, 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 f71433efd..ffc5c776e 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 @@ -364,6 +364,10 @@ export class MenuContentProvider { // Focus this.focusScope = FocusScope.use({ dom: this.provider.soma.dom, + // Species default from the declaration (eje focus-first): the + // per-instance trap resolution below still wins; the census keeps + // wrapper defaults and this declaration from drifting apart. + policy: this.provider.runtime.focus?.kind === 'trap' ? this.provider.runtime.focus : undefined, onOpenAutoFocus: readableActive(() => (e: Event) => { opts.onOpenAutoFocus.current(e); if (e.defaultPrevented) return; diff --git a/src/uix/soma/components/float-panel/float-panel-provider.svelte.ts b/src/uix/soma/components/float-panel/float-panel-provider.svelte.ts index 6c1614871..15d8a0408 100644 --- a/src/uix/soma/components/float-panel/float-panel-provider.svelte.ts +++ b/src/uix/soma/components/float-panel/float-panel-provider.svelte.ts @@ -1066,6 +1066,10 @@ export class FloatPanelContentProvider { // Focus: trap only when modal (free panels keep background interactive). this.focusScope = FocusScope.use({ dom: this.provider.soma.dom, + // Species default from the declaration (eje focus-first): the + // per-instance trap resolution below still wins; the census keeps + // wrapper defaults and this declaration from drifting apart. + policy: this.provider.runtime.focus?.kind === 'trap' ? this.provider.runtime.focus : undefined, onOpenAutoFocus: readableActive(() => () => {}), onCloseAutoFocus: readableActive(() => () => {}), trap: readableActive(() => this.provider.opts.modal.current), diff --git a/src/uix/soma/components/popover/popover-provider.svelte.ts b/src/uix/soma/components/popover/popover-provider.svelte.ts index e153636b3..dce1dd6b9 100644 --- a/src/uix/soma/components/popover/popover-provider.svelte.ts +++ b/src/uix/soma/components/popover/popover-provider.svelte.ts @@ -539,6 +539,10 @@ export class PopoverContentProvider { // An explicit boolean from the consumer overrides the derivation. this.focusScope = FocusScope.use({ dom: this.provider.soma.dom, + // Species default from the declaration (eje focus-first): the + // per-instance trap resolution below still wins; the census keeps + // wrapper defaults and this declaration from drifting apart. + policy: this.provider.runtime.focus?.kind === 'trap' ? this.provider.runtime.focus : undefined, onOpenAutoFocus: opts.onOpenAutoFocus, onCloseAutoFocus: opts.onCloseAutoFocus, trap: readableActive(() => opts.trapFocus.current ?? this.provider.opts.modal.current), diff --git a/src/uix/soma/layers/focus-scope.svelte.ts b/src/uix/soma/layers/focus-scope.svelte.ts index d68fabd0d..ccb174613 100644 --- a/src/uix/soma/layers/focus-scope.svelte.ts +++ b/src/uix/soma/layers/focus-scope.svelte.ts @@ -1,5 +1,6 @@ import { state, watch, type Active, type ActiveProps } from '$libs/reactive'; import { focusable, isFocusable, tabbable, isHTMLElement, type ActiveDom } from '$adom'; +import type { MorfoFocusTrap } from '$uix/morfo'; // ── FocusScopeManager (singleton) ──────────────────────────────────────────── @@ -56,10 +57,19 @@ class FocusScopeManager { interface FocusScopeOpts extends ActiveProps<{ onOpenAutoFocus: (event: Event) => void; onCloseAutoFocus: (event: Event) => void; - trap: boolean; + trap: boolean | undefined; }> { dom: ActiveDom; loop: Active; + /** + * The morfo's declared focus policy (eje focus-first, 2026-08-26) — the + * SPECIES DEFAULT this executor consumes when the per-instance resolution + * (`trapFocus` / `modal`) yields undefined. Providers pass + * `runtime.focus` (narrowed to the trap kind); the focus census compares + * each wrapper's shipped default against this declaration, so the two can + * no longer drift (the Popover class). + */ + policy?: MorfoFocusTrap; } interface FocusScopeUseOpts @@ -151,8 +161,18 @@ export class FocusScope { } } + /** + * The trap resolution chain (eje focus-first): per-instance value first + * (`trapFocus ?? modal`, resolved by the provider into `opts.trap`), the + * morfo's declared species default as the floor, `false` when neither + * speaks. + */ + private effectiveTrap(): boolean { + return this.opts.trap.current ?? this.opts.policy?.trap ?? false; + } + private setupEventListeners() { - if (!this.container || !this.opts.trap.current) return; + if (!this.container || !this.effectiveTrap()) return; const container = this.container; const doc = this.opts.dom.getDocument(container); diff --git a/src/uix/soma/runtime.svelte.ts b/src/uix/soma/runtime.svelte.ts index 481972e30..21a2964ad 100644 --- a/src/uix/soma/runtime.svelte.ts +++ b/src/uix/soma/runtime.svelte.ts @@ -60,6 +60,7 @@ import type { EventNameTargeting, Morfo, MorfoDirection, + MorfoFocus, MorfoSemanticIntent, PartKebabOf } from '$uix/morfo'; @@ -275,6 +276,15 @@ export interface SomaRuntime { opts: SomaRuntimePartOpts ): SomaRuntimePart; partProps(part: PartKebabOf): Record; + /** + * The morfo's declared focus policy (eje focus-first, 2026-08-26) — the + * SPECIES DEFAULT the executors consume: `FocusScope.use` takes it as its + * `policy` fallback (`trapFocus ?? modal ?? policy.trap`), and roving + * providers wire `RovingFocusGroup` (or their signed custom executor) + * from it. Per-instance props always win. `undefined` = the component + * declares no focus policy. + */ + readonly focus: MorfoFocus | undefined; /** * Resolve an ELEMENT to the registered live instance of `part` it is, and * return an anchored emitter for it — or `null` when the element is not a @@ -1127,5 +1137,15 @@ export function createSomaRuntime( return reg?.ref?.current ?? null; } - return { part, partInstance, partProps, keydown, trigger, clearSignal, clearTarget, partRef }; + return { + part, + partInstance, + partProps, + keydown, + trigger, + clearSignal, + clearTarget, + partRef, + focus: compiled.focus + }; }