From 0784cae5783d1b6ae8b842320be60811457abd92 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 26 May 2026 03:14:24 +0200 Subject: [PATCH] feat(soma): wire runtime.trigger for batch B (8 components) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continúa batch A. Estos 8 componentes tienen scope:sema y events declarados pero no usan pack propio — emiten sobre family base. - listbox: select() central → commit-select / commit-unselect en item. - grid-list: select() central → idem en row. - table: - SortTrigger.onclick/onkeydown → commit-set-sort en column-header. - Row.onclick/onkeydown → commit-select en row. - RowDetailTrigger → emerge-expand-row en row (solo transición closed → open; el morfo no declara collapse counterpart). - feed: - focusAt() (centraliza navegación de teclado) → shift-navigate-focus-item en article. - PageDown-en-final + Sentinel.onIntersect → commit-submit-load-more en provider. - command: - Item.onclick → commit-submit-invoke en item (path mouse). - selectCurrent() (Enter key) → commit-submit-invoke vía lookup por data-value en provider ref. - carousel: commit() central (next/prev/goto/autoplay) → shift-navigate-slide en item, lookup por data-index. - announce: announce() → signal-alert (assertive) o signal-announce (polite). clear() → commit-reset. - clipboard: copy() acepta optional triggerEl → commit-save-copy (success) / commit-fail-copy (catch). Trigger.onclick pasa e.currentTarget. 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 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../announce/announce-provider.svelte.ts | 7 +++++ .../carousel/carousel-provider.svelte.ts | 10 +++++++ .../clipboard/clipboard-provider.svelte.ts | 19 ++++++++++-- .../command/command-provider.svelte.ts | 16 +++++++++- .../components/feed/feed-provider.svelte.ts | 18 ++++++++++- .../grid-list/grid-list-provider.svelte.ts | 17 +++++++++++ .../listbox/listbox-provider.svelte.ts | 19 ++++++++++++ .../components/table/table-provider.svelte.ts | 30 +++++++++++++++++++ 8 files changed, 131 insertions(+), 5 deletions(-) diff --git a/src/uix/soma/components/announce/announce-provider.svelte.ts b/src/uix/soma/components/announce/announce-provider.svelte.ts index 65dbf216f..7e95bf3ab 100644 --- a/src/uix/soma/components/announce/announce-provider.svelte.ts +++ b/src/uix/soma/components/announce/announce-provider.svelte.ts @@ -84,6 +84,10 @@ export class AnnounceProvider { return; } const clearAfter = timeout ?? this.opts.defaultTimeout.current; + // Morfo declares signal-announce (polite) and signal-alert + // (assertive) on `provider`. Fire here — the runtimePart auto- + // resolves the provider element. + void this.runtime.trigger(priority === 'assertive' ? 'signal-alert' : 'signal-announce'); if (priority === 'assertive') { // Alternate between A/B so that identical consecutive messages @@ -137,6 +141,9 @@ export class AnnounceProvider { this.assertiveA = ''; this.assertiveB = ''; this.clearTimers(); + // Morfo: commit-reset on `provider`. Programmatic clear of the + // live regions — the channel still gets a chance to react. + void this.runtime.trigger('commit-reset'); }; private clearPoliteTimer() { diff --git a/src/uix/soma/components/carousel/carousel-provider.svelte.ts b/src/uix/soma/components/carousel/carousel-provider.svelte.ts index fe4b49bc7..283dca5a8 100644 --- a/src/uix/soma/components/carousel/carousel-provider.svelte.ts +++ b/src/uix/soma/components/carousel/carousel-provider.svelte.ts @@ -280,6 +280,16 @@ export class CarouselProvider { if (target === this.opts.value.current) return; this.opts.value.current = target; this.opts.onValueChange.current?.(target); + // Morfo: shift-navigate-slide on `item`. Resolve the slide element + // by data-index so the cascade matches the specific item that + // became active (next/prev/goto/autoplay all flow through this + // central commit method). + const slideEl = this.opts.ref?.current?.querySelector( + `[${attrs.item}][data-index="${target}"]` + ); + if (slideEl) { + void this.runtime.trigger('shift-navigate-slide', { fallbackTarget: slideEl }); + } } /** Mark this navigation as user-driven so `stopOnInteraction` can latch. */ diff --git a/src/uix/soma/components/clipboard/clipboard-provider.svelte.ts b/src/uix/soma/components/clipboard/clipboard-provider.svelte.ts index bedb50740..47284b88d 100644 --- a/src/uix/soma/components/clipboard/clipboard-provider.svelte.ts +++ b/src/uix/soma/components/clipboard/clipboard-provider.svelte.ts @@ -64,13 +64,22 @@ export class ClipboardProvider { * Copy the current value. Returns a promise that resolves when the * clipboard write finishes (or rejects if the API throws / is missing). * Also schedules a `copied=false` reset after `timeout` ms. + * + * Optional `triggerEl` lets the caller (typically a ClipboardTrigger) + * pass its own DOM ref so the morfo `commit-save-copy` / `commit-fail-copy` + * events stamp on the actual button. Without it the runtime falls back + * to no target (sound/haptic still play; visual stamp skipped). */ - copy = async (): Promise => { + copy = async (triggerEl?: HTMLElement): Promise => { const value = this.opts.value.current; try { await this.soma.uix.clipboard.writeText(value); this.copied = true; this.opts.onCopy.current?.(value); + void this.runtime.trigger( + 'commit-save-copy', + triggerEl ? { fallbackTarget: triggerEl } : undefined + ); this.clearResetTimer(); this.resetTimer = this.soma.uix.timers.schedule( @@ -84,6 +93,10 @@ export class ClipboardProvider { ); } catch (err) { this.opts.onError.current?.(err); + void this.runtime.trigger( + 'commit-fail-copy', + triggerEl ? { fallbackTarget: triggerEl } : undefined + ); throw err; } }; @@ -150,8 +163,8 @@ export class ClipboardTriggerProvider { : this.provider.soma.langs.ts(CLIPBOARD_LANGS.COPY); }); - readonly onclick = (_e: SomaMouseEvent) => { - this.provider.copy().catch(() => { + readonly onclick = (e: SomaMouseEvent) => { + this.provider.copy(e.currentTarget).catch(() => { // onError already fired from the provider; swallow here to avoid // unhandled-rejection noise on button click. }); diff --git a/src/uix/soma/components/command/command-provider.svelte.ts b/src/uix/soma/components/command/command-provider.svelte.ts index 94a7c636c..4b0b7d3cb 100644 --- a/src/uix/soma/components/command/command-provider.svelte.ts +++ b/src/uix/soma/components/command/command-provider.svelte.ts @@ -377,6 +377,15 @@ export class CommandProvider { const entry = this.findItem(value); if (!entry || entry.disabled || !this.isVisible(value)) return; this.onSelectHandlers.get(value)?.(value); + // Morfo: commit-submit-invoke on `item`. Look up the item element + // by data-value so the cascade matches this specific item (Enter + // key path — onclick handler emits independently for mouse path). + const itemEl = this.opts.ref?.current?.querySelector( + `[${attrs.item}][data-value="${CSS.escape(value)}"]` + ); + if (itemEl) { + void this.runtime.trigger('commit-submit-invoke', { fallbackTarget: itemEl }); + } } private onSelectHandlers = new Map void>(); @@ -682,10 +691,15 @@ export class CommandItemProvider { readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current)); readonly isVisible = $derived.by(() => this.provider.visibleSet.has(this.opts.value.current)); - readonly onclick = (_e: SomaMouseEvent) => { + readonly onclick = (e: SomaMouseEvent) => { if (this.opts.disabled.current) return; this.provider.setValue(this.opts.value.current); this.opts.onSelect.current?.(this.opts.value.current); + // Morfo: commit-submit-invoke on `item` (affirm). Fire with the + // clicked element so the cascade matches this specific item. + void this.provider.runtime.trigger('commit-submit-invoke', { + fallbackTarget: e.currentTarget + }); }; readonly onpointermove = () => { diff --git a/src/uix/soma/components/feed/feed-provider.svelte.ts b/src/uix/soma/components/feed/feed-provider.svelte.ts index 5ea7b1de2..6d433a472 100644 --- a/src/uix/soma/components/feed/feed-provider.svelte.ts +++ b/src/uix/soma/components/feed/feed-provider.svelte.ts @@ -94,7 +94,14 @@ export class FeedProvider { const articles = this.getArticles(); if (articles.length === 0) return; const clamped = Math.max(0, Math.min(articles.length - 1, index)); - this.soma.dom.focus(articles[clamped]); + const article = articles[clamped]; + this.soma.dom.focus(article); + // Morfo: shift-navigate-focus-item on `article`. Fired on every + // keyboard navigation step that moves focus to a different + // article (Page Up/Down, Ctrl+Home/End). + if (article) { + void this.runtime.trigger('shift-navigate-focus-item', { fallbackTarget: article }); + } } /** Fired on the feed root (capture Page nav) and on articles (for end-of-feed). */ @@ -110,6 +117,9 @@ export class FeedProvider { if (currentIndex >= articles.length - 1) { // End of feed — request more. this.opts.onLoadMore.current?.(); + // Morfo: commit-submit-load-more on `provider`. The + // provider's own runtimePart auto-resolves the target. + void this.runtime.trigger('commit-submit-load-more'); return; } this.focusAt(currentIndex + 1); @@ -440,6 +450,12 @@ export class FeedSentinelProvider { if (entry.isIntersecting) { const handler = opts.onIntersect.current ?? this.feed?.opts.onLoadMore.current; handler?.(); + // Morfo: commit-submit-load-more on `provider`. + // Sentinel scroll-into-view path mirrors the + // keyboard PageDown-at-end path. + if (this.feed) { + void this.feed.runtime.trigger('commit-submit-load-more'); + } } } }, diff --git a/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts b/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts index ce2c95e34..b54b1b38b 100644 --- a/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts +++ b/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts @@ -145,6 +145,7 @@ export class GridListProvider { const selectionMode = this.opts.selectionMode.current; if (selectionMode === 'none') return; const current = this.opts.value.current; + const wasSelected = current.includes(value); let next: string[]; if (mode === 'range' && selectionMode === 'multiple' && this.anchor) { @@ -159,6 +160,22 @@ export class GridListProvider { if (next !== current) { this.opts.value.current = next; this.opts.onValueChange.current?.(next); + // Morfo: commit-select / commit-unselect on `row`. Detect + // direction via prior inclusion; lookup row by data-value. + const isNowSelected = next.includes(value); + const eventName = isNowSelected + ? 'commit-select' + : wasSelected + ? 'commit-unselect' + : null; + if (eventName) { + const rowEl = this.opts.ref?.current?.querySelector( + `[${attrs.row}][data-value="${CSS.escape(value)}"]` + ); + if (rowEl) { + void this.runtime.trigger(eventName, { fallbackTarget: rowEl }); + } + } } if (mode !== 'range') this.anchor = value; } diff --git a/src/uix/soma/components/listbox/listbox-provider.svelte.ts b/src/uix/soma/components/listbox/listbox-provider.svelte.ts index 58bf7d2ae..a42bb3acf 100644 --- a/src/uix/soma/components/listbox/listbox-provider.svelte.ts +++ b/src/uix/soma/components/listbox/listbox-provider.svelte.ts @@ -151,6 +151,7 @@ export class ListboxProvider { const current = this.opts.value.current; const selectionMode = this.opts.selectionMode.current; const effectiveMode = mode ?? (selectionMode === 'single' ? 'replace' : 'toggle'); + const wasSelected = current.includes(value); let next: string[]; if (effectiveMode === 'replace') { @@ -168,6 +169,24 @@ export class ListboxProvider { if (next !== current) { this.opts.value.current = next; this.opts.onValueChange.current?.(next); + // Morfo: commit-select / commit-unselect on `item`. Detect + // direction by checking next inclusion vs prior. Look up the + // item element by data-value so the cascade matches this + // specific item (multiple items registered under same kebab). + const isNowSelected = next.includes(value); + const eventName = isNowSelected + ? 'commit-select' + : wasSelected + ? 'commit-unselect' + : null; + if (eventName) { + const itemEl = this.opts.ref?.current?.querySelector( + `[${attrs.item}][data-value="${CSS.escape(value)}"]` + ); + if (itemEl) { + void this.runtime.trigger(eventName, { fallbackTarget: itemEl }); + } + } } } diff --git a/src/uix/soma/components/table/table-provider.svelte.ts b/src/uix/soma/components/table/table-provider.svelte.ts index 533cded09..674a75377 100644 --- a/src/uix/soma/components/table/table-provider.svelte.ts +++ b/src/uix/soma/components/table/table-provider.svelte.ts @@ -215,6 +215,11 @@ export class TableSortTriggerProvider { // click-handling parents without surprises. e.stopPropagation(); this.table.toggleSort(this.header.id, e.shiftKey); + // Morfo: commit-set-sort on `column-header`. Trigger is inside + // the column-header; closest() resolves the ancestor for the + // cascade selector. + const target = e.currentTarget as HTMLElement; + void this.provider.runtime.trigger('commit-set-sort', { fallbackTarget: target }); }; readonly onkeydown = (e: SomaKeyboardEvent) => { @@ -223,6 +228,8 @@ export class TableSortTriggerProvider { e.preventDefault(); e.stopPropagation(); this.table.toggleSort(this.header.id, e.shiftKey); + const target = e.currentTarget as HTMLElement; + void this.provider.runtime.trigger('commit-set-sort', { fallbackTarget: target }); } }; @@ -292,6 +299,12 @@ export class TableRowProvider { readonly onclick = () => { if (this.table.selectionMode !== 'none') { this.table.toggleRowSelection(this.row.id); + // Morfo: commit-select on `row`. Pass this row's ref so the + // cascade matches the specific row, not the most-recent one. + const rowEl = this.opts.ref?.current; + if (rowEl) { + void this.provider.runtime.trigger('commit-select', { fallbackTarget: rowEl }); + } } }; @@ -300,6 +313,10 @@ export class TableRowProvider { if (this.table.selectionMode !== 'none') { e.preventDefault(); this.table.toggleRowSelection(this.row.id); + const rowEl = this.opts.ref?.current; + if (rowEl) { + void this.provider.runtime.trigger('commit-select', { fallbackTarget: rowEl }); + } } } }; @@ -549,14 +566,27 @@ export class TableRowDetailTriggerProvider { readonly onclick = (e: MouseEvent) => { // Prevent the row's own onclick (selection) from firing. e.stopPropagation(); + const wasOpen = this.provider.table.getIsRowDetailOpen(this.row.id); this.provider.table.toggleRowDetail(this.row.id); + // Morfo only declares `emerge-expand-row` (no collapse counterpart). + // Fire only on the closed → open transition. Trigger lives inside + // the row; closest() resolves the row ancestor for the cascade. + if (!wasOpen) { + const target = e.currentTarget as HTMLElement; + void this.provider.runtime.trigger('emerge-expand-row', { fallbackTarget: target }); + } }; readonly onkeydown = (e: KeyboardEvent) => { if (e.key === KEYS.SPACE || e.key === KEYS.ENTER) { e.preventDefault(); e.stopPropagation(); + const wasOpen = this.provider.table.getIsRowDetailOpen(this.row.id); this.provider.table.toggleRowDetail(this.row.id); + if (!wasOpen) { + const target = e.currentTarget as HTMLElement; + void this.provider.runtime.trigger('emerge-expand-row', { fallbackTarget: target }); + } } };