feat(soma): wire runtime.trigger for batch B (8 components)

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) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent adf89a0811
commit 0784cae578

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

@ -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<HTMLElement>(
`[${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. */

@ -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<void> => {
copy = async (triggerEl?: HTMLElement): Promise<void> => {
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<HTMLButtonElement>) => {
this.provider.copy().catch(() => {
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
this.provider.copy(e.currentTarget).catch(() => {
// onError already fired from the provider; swallow here to avoid
// unhandled-rejection noise on button click.
});

@ -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<HTMLElement>(
`[${attrs.item}][data-value="${CSS.escape(value)}"]`
);
if (itemEl) {
void this.runtime.trigger('commit-submit-invoke', { fallbackTarget: itemEl });
}
}
private onSelectHandlers = new Map<string, (v: string) => 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<HTMLElement>) => {
readonly onclick = (e: SomaMouseEvent<HTMLElement>) => {
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 = () => {

@ -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');
}
}
}
},

@ -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<HTMLElement>(
`[${attrs.row}][data-value="${CSS.escape(value)}"]`
);
if (rowEl) {
void this.runtime.trigger(eventName, { fallbackTarget: rowEl });
}
}
}
if (mode !== 'range') this.anchor = value;
}

@ -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<HTMLElement>(
`[${attrs.item}][data-value="${CSS.escape(value)}"]`
);
if (itemEl) {
void this.runtime.trigger(eventName, { fallbackTarget: itemEl });
}
}
}
}

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

Loading…
Cancel
Save

Powered by TurnKey Linux.