feat(soma): wire runtime.trigger for batch A (7 components)

Los 11 packs sema shipped en commit 2d562f37 quedaban dormantes
porque soma no llamaba runtime.trigger en ningún sitio de estos 7
componentes. Esta es la cabling que los activa.

Patrón: emisión en el state-mutator central (no en handlers
individuales) cuando es posible, pasando fallbackTarget al elemento
DOM apropiado para que el cascade selector matchee la instancia
correcta (no la "más reciente registrada").

- toggle-group: `commit-toggle` desde Item.onclick con item button
  como fallbackTarget.
- menubar: `commit-select` desde Provider.open(value), usando
  triggerRegistry para resolver value→element. Cubre click, keyboard,
  hover-follow y arrow-nav en una sola emisión.
- navigation-menu: `commit-select` desde Provider.openNow(value),
  usando triggerRefs existente. Closest() del trigger sube al
  item ancestro.
- dropdown-menu: `open` y `close` desde handleOpen/handleClose
  (sequence: 'pre' — antes de la mutación de state); `commit-select`
  desde MenuItemProvider.onclick + onkeydown.
- context-menu: `open` desde openAt(), `close` desde handleClose,
  `commit-select` desde ContextMenuItemProvider.onclick + onkeydown.
- tree-view: `emerge-expand`/`emerge-collapse` desde toggleExpand
  (lookup branch via querySelector). `commit-select` desde select()
  con parámetro fromEl pasado por BranchControl/ItemProvider.
- tree-grid: análogos via lookup de row por data-value.

Pack tree-view actualizado: añade cascade rule para commit-select
sobre `branch` (mismo tuning que item) — branches y leaves comparten
firma perceptual.

Fix colateral: tag-group test esperaba intent='neutral' para
commit-unselect, pero el canon (f3641347) lo declara como 'affirm'.
Test actualizado.

Verificación:
- npm run morfo:vocabulary → EXIT 0
- npx vitest run src/uix/sema src/uix/morfo → 195/195 pass
- npx vitest run src/uix/soma → 419/419 pass (74 files)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 66318c4759
commit adf89a0811

@ -45,10 +45,19 @@ export const treeViewSema: Sema = {
selector: onBranch({ eventName: 'emerge-collapse' }),
sound: soundTuning('emerge.exit.soft')
},
// Commit-select can fire on either part: leaves use `item`, branches
// re-fire the same morfo event but land on the branch element. Two
// cascade rules with identical tuning keep the perceptual signature
// consistent across leaf and branch selection.
{
selector: onItem({ eventName: 'commit-select' }),
sound: soundTuning('form.commit.subtle'),
haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 }
},
{
selector: onBranch({ eventName: 'commit-select' }),
sound: soundTuning('form.commit.subtle'),
haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 }
}
]
};

@ -117,10 +117,19 @@ export class ContextMenuProvider {
openAt(x: number, y: number) {
this.anchorPoint.current = { x, y };
// Morfo declares `open` on `content` (emerge.open, sequence pre).
// Content may not be mounted yet on first open — pass it as fallback
// when available, otherwise let the runtime fire with no target.
const target = this.contentRef.current;
void this.runtime.trigger('open', target ? { fallbackTarget: target } : undefined);
this.opts.open.current = true;
}
handleClose() {
// Morfo declares `close` on `content` (emerge.close, sequence pre).
// Content still in DOM at this point; cascade matches before flip.
const target = this.contentRef.current;
void this.runtime.trigger('close', target ? { fallbackTarget: target } : undefined);
this.typeahead.reset();
this.opts.open.current = false;
}
@ -428,9 +437,14 @@ export class ContextMenuItemProvider {
});
}
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
readonly onclick = (e: SomaMouseEvent<HTMLElement>) => {
if (this.opts.disabled.current) return;
this.opts.onSelect.current();
// Morfo: `commit-select` on `item` (affirm). Fire with the
// clicked element so the cascade matches this specific item.
void this.provider.runtime.trigger('commit-select', {
fallbackTarget: e.currentTarget
});
if (this.opts.closeOnSelect.current) this.provider.handleClose();
};
@ -439,6 +453,9 @@ export class ContextMenuItemProvider {
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
this.opts.onSelect.current();
void this.provider.runtime.trigger('commit-select', {
fallbackTarget: e.currentTarget
});
if (this.opts.closeOnSelect.current) this.provider.handleClose();
}
};

@ -114,10 +114,22 @@ export class MenuProvider {
}
handleOpen() {
// Sequence: 'pre' per the morfo — fire before the structural flip
// so the perceptual signal lands at the gesture moment. If the
// content hasn't mounted yet (first open), the runtime falls back
// to no target and the channels still play (sound/haptic don't
// need a DOM target; visual will pick it up on next emit).
const target = this.contentRef.current;
void this.runtime.trigger('open', target ? { fallbackTarget: target } : undefined);
this.opts.open.current = true;
}
handleClose() {
// Sequence: 'pre' per the morfo — content is still in the DOM here,
// so the cascade can match `[data-dropdown-menu-content]` cleanly
// before the close flip unmounts it.
const target = this.contentRef.current;
void this.runtime.trigger('close', target ? { fallbackTarget: target } : undefined);
this.typeahead.reset();
this.opts.open.current = false;
}
@ -452,9 +464,16 @@ export class MenuItemProvider {
});
}
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
readonly onclick = (e: SomaMouseEvent<HTMLElement>) => {
if (this.opts.disabled.current) return;
this.opts.onSelect.current();
// Morfo declares `commit-select` on `item` (affirm). Fire with the
// clicked element as fallback target so the cascade selector
// `[data-dropdown-menu-item][data-event="commit-select"]` matches
// this specific item (not the most-recent registered one).
void this.provider.runtime.trigger('commit-select', {
fallbackTarget: e.currentTarget
});
if (this.opts.closeOnSelect.current) this.provider.handleClose();
};
@ -463,6 +482,9 @@ export class MenuItemProvider {
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
this.opts.onSelect.current();
void this.provider.runtime.trigger('commit-select', {
fallbackTarget: e.currentTarget
});
if (this.opts.closeOnSelect.current) this.provider.handleClose();
}
};

@ -99,6 +99,15 @@ export class MenubarProvider {
this.opts.value.current = value;
if (prev) this.openChangeListeners.get(prev)?.(false);
this.openChangeListeners.get(value)?.(true);
// Morfo declares `commit-select` on `trigger` (affirm). Picking a
// top-level menubar entry — by click, keyboard activate, hover-
// follow, or arrow-nav between triggers — all flow through this
// method. Firing here covers every path with one stamp on the
// trigger element that just became active.
const triggerEl = this.triggerRegistry.get(value);
if (triggerEl) {
void this.runtime.trigger('commit-select', { fallbackTarget: triggerEl });
}
}
/** Close the currently open menu. */

@ -144,6 +144,16 @@ export class NavigationMenuProvider {
if (this.opts.value.current === value) return;
this.opts.value.current = value;
this.opts.onValueChange.current?.(value);
// Morfo declares `commit-select` on `item` (affirm). Centralising
// the emission here covers every path (trigger click, keyboard
// activate, hover-follow via scheduleOpen). The fallback target
// is the trigger element; the pack selector targets `item`, but
// `closest()` walks from the trigger up to the wrapping li with
// `data-navigation-menu-item`, matching correctly.
const triggerEl = this.triggerRefs.get(value);
if (triggerEl) {
void this.runtime.trigger('commit-select', { fallbackTarget: triggerEl });
}
}
/** Close whatever is open now. Arms the skip-delay window so the next

@ -349,7 +349,10 @@ describe('TagGroupProvider', () => {
target: alphaEl,
name: 'commit-unselect',
family: 'commit',
intent: 'neutral'
// Per the canon: `commit.unselect` carries `affirm` intent (the
// natural pair of `commit.select`). Updated in commit f3641347
// when `unselect` was added to the canon.
intent: 'affirm'
});
result.beta.remove();

@ -160,9 +160,17 @@ export class ToggleGroupItemProvider {
return items[0] === el;
});
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.isDisabled) return;
this.provider.toggleItem(this.opts.value.current);
// Morfo declares `commit-toggle` on the `item` part. Pass the
// clicked button as `fallbackTarget` so the cascade selector
// `[data-toggle-group-item][data-event="commit-toggle"]` matches
// this exact item (the runtime would otherwise see N registered
// items under the same kebab and pick arbitrarily).
void this.provider.runtime.trigger('commit-toggle', {
fallbackTarget: e.currentTarget
});
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {

@ -143,9 +143,20 @@ export class TreeGridProvider {
toggleExpand(value: string) {
if (this.opts.disabled.current) return;
const current = this.opts.expanded.current;
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
const wasExpanded = current.includes(value);
const next = wasExpanded ? current.filter((v) => v !== value) : [...current, value];
this.opts.expanded.current = next;
this.opts.onExpandedChange.current?.(next);
// Morfo: emerge-expand / emerge-collapse on `row`. Find the row
// element by data-value (tree-grid uses a single Row part for both
// leaves and branches; the row itself is what reveals/hides children).
const rowEl = this.opts.ref?.current?.querySelector<HTMLElement>(
`[${attrs.row}][data-value="${CSS.escape(value)}"]`
);
if (rowEl) {
const eventName = wasExpanded ? 'emerge-collapse' : 'emerge-expand';
void this.runtime.trigger(eventName, { fallbackTarget: rowEl });
}
}
expand(value: string) {
@ -188,6 +199,15 @@ export class TreeGridProvider {
if (next !== current) {
this.opts.value.current = next;
this.opts.onValueChange.current?.(next);
// Morfo: commit-select on `row` (affirm). Look up the row
// element by value so the cascade stamp lands on the right
// row (multiple rows are registered under the same kebab).
const rowEl = this.opts.ref?.current?.querySelector<HTMLElement>(
`[${attrs.row}][data-value="${CSS.escape(value)}"]`
);
if (rowEl) {
void this.runtime.trigger('commit-select', { fallbackTarget: rowEl });
}
}
if (mode !== 'range') this.anchor = value;
}

@ -99,8 +99,20 @@ export class TreeViewProvider {
toggleExpand(value: string) {
const current = this.opts.expandedValue.current;
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
const wasExpanded = current.includes(value);
const next = wasExpanded ? current.filter((v) => v !== value) : [...current, value];
this.opts.expandedValue.current = next;
// Morfo declares `emerge-expand` and `emerge-collapse` on `branch`.
// Look up the branch DOM element by value (only branches carry
// `data-tree-view-branch`; the same DOM query pattern is used by
// `expandSiblings` above).
const branchEl = this.opts.ref?.current?.querySelector<HTMLElement>(
`[${attrs.branch}][data-value="${CSS.escape(value)}"]`
);
if (branchEl) {
const eventName = wasExpanded ? 'emerge-collapse' : 'emerge-expand';
void this.runtime.trigger(eventName, { fallbackTarget: branchEl });
}
}
expand(value: string) {
@ -134,7 +146,7 @@ export class TreeViewProvider {
return this.opts.selectedValue.current.includes(value);
}
select(value: string) {
select(value: string, fromEl?: HTMLElement) {
let next: string[];
if (this.opts.selectionMode.current === 'single') {
next = this.isSelected(value) ? [] : [value];
@ -144,6 +156,12 @@ export class TreeViewProvider {
: [...this.opts.selectedValue.current, value];
}
this.opts.selectedValue.current = next;
// Morfo declares `commit-select` on `item`. Call sites pass the
// clicked element (item or branch) as `fromEl`; the cascade pack
// matches both via separate rules.
if (fromEl) {
void this.runtime.trigger('commit-select', { fallbackTarget: fromEl });
}
}
// ── Focus & navigation ───────────────────────────────────────────────────
@ -381,7 +399,10 @@ export class TreeViewBranchControlProvider {
if (this.provider.opts.expandOnClick.current) {
this.provider.toggleExpand(this.branch.opts.value.current);
}
this.provider.select(this.branch.opts.value.current);
// Pass the branch element so the commit-select event stamps on
// the actual branch (the click landed on the control, but the
// semantic target is the branch — same value).
this.provider.select(this.branch.opts.value.current, this.branch.opts.ref?.current ?? undefined);
};
readonly props = $derived.by(() =>
@ -541,7 +562,8 @@ export class TreeViewItemProvider {
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
if (this.isDisabled) return;
this.provider.select(this.opts.value.current);
// Pass the item element so commit-select stamps on this leaf.
this.provider.select(this.opts.value.current, this.opts.ref?.current ?? undefined);
};
readonly props = $derived.by(() =>

Loading…
Cancel
Save

Powered by TurnKey Linux.