diff --git a/src/uix/sema/components/tree-view.ts b/src/uix/sema/components/tree-view.ts index 7dedaf779..b4b6a2d0f 100644 --- a/src/uix/sema/components/tree-view.ts +++ b/src/uix/sema/components/tree-view.ts @@ -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 } } ] }; 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 21e8b417b..d75ebd09a 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 @@ -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) => { + readonly onclick = (e: SomaMouseEvent) => { 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(); } }; 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 75b368655..2d4bca42c 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 @@ -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) => { + readonly onclick = (e: SomaMouseEvent) => { 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(); } }; diff --git a/src/uix/soma/components/menubar/menubar-provider.svelte.ts b/src/uix/soma/components/menubar/menubar-provider.svelte.ts index 154d28f74..1a95bb0ed 100644 --- a/src/uix/soma/components/menubar/menubar-provider.svelte.ts +++ b/src/uix/soma/components/menubar/menubar-provider.svelte.ts @@ -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. */ diff --git a/src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts b/src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts index 76fbb5f10..c7a53df28 100644 --- a/src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts +++ b/src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts @@ -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 diff --git a/src/uix/soma/components/tag-group/tag-group-provider.svelte.test.ts b/src/uix/soma/components/tag-group/tag-group-provider.svelte.test.ts index 10510ef91..f0ee4a0b7 100644 --- a/src/uix/soma/components/tag-group/tag-group-provider.svelte.test.ts +++ b/src/uix/soma/components/tag-group/tag-group-provider.svelte.test.ts @@ -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(); diff --git a/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts b/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts index 934ed057b..90117e7d7 100644 --- a/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts +++ b/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts @@ -160,9 +160,17 @@ export class ToggleGroupItemProvider { return items[0] === el; }); - readonly onclick = (_e: SomaMouseEvent) => { + readonly onclick = (e: SomaMouseEvent) => { 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) => { diff --git a/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts b/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts index 634f2500f..4ba2363df 100644 --- a/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts +++ b/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts @@ -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( + `[${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( + `[${attrs.row}][data-value="${CSS.escape(value)}"]` + ); + if (rowEl) { + void this.runtime.trigger('commit-select', { fallbackTarget: rowEl }); + } } if (mode !== 'range') this.anchor = value; } diff --git a/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts b/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts index e63d94150..698147be0 100644 --- a/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts +++ b/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts @@ -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( + `[${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) => { 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(() =>