feat(soma): la declaración de foco es CARGANTE — FocusScope la consume como política

- 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 <noreply@anthropic.com>
alpha-0.1-background
dev 1 month ago
parent 2c0fca4319
commit ffb6f54f7d

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

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

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

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

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

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

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

@ -60,6 +60,7 @@ import type {
EventNameTargeting,
Morfo,
MorfoDirection,
MorfoFocus,
MorfoSemanticIntent,
PartKebabOf
} from '$uix/morfo';
@ -275,6 +276,15 @@ export interface SomaRuntime<M extends Morfo> {
opts: SomaRuntimePartOpts<Owner>
): SomaRuntimePart<M, K>;
partProps(part: PartKebabOf<M>): Record<string, unknown>;
/**
* 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<M extends Morfo>(
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
};
}

Loading…
Cancel
Save

Powered by TurnKey Linux.