import { Soma } from '../../core/soma.svelte'; import { FieldProvider } from '../field'; import { buttonMorfo } from '../../../morfo/components/button'; import type { OptsFromProps } from '../../provider'; import type { OnChangeFn, SomaMouseEvent } from '../../types'; import type { ButtonProps, ButtonIntent } from './types'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; // ── Provider ─────────────────────────────────────────────────────────────── /** * Provider's reactive contract derived from `ButtonProps`. */ type ButtonOpts = OptsFromProps< ButtonProps, | 'intent' | 'color' | 'type' | 'disabled' | 'loading' | 'loadingText' | 'spinnerPlacement' | 'aria-label' | 'onPress', never >; /** * Button provider — runtime-direct. * * Owns the press → `commit-action` flow. Sequence is `pre` so the * perceptual signal lands at the moment of the click; the consumer's * onclick may then run async work without blocking it. */ export class ButtonProvider { static create(opts: ButtonOpts) { return new ButtonProvider(opts); } readonly opts: ButtonOpts; readonly field = FieldProvider.get(); readonly runtime: SomaRuntime; readonly runtimePart: SomaRuntimePart; readonly soma: Soma; private constructor(opts: ButtonOpts) { this.opts = opts; this.soma = Soma.require(); this.runtime = this.soma.runtime(buttonMorfo, { states: { loading: () => opts.loading.current ?? false }, props: { disabled: () => this.isDisabled, loading: () => opts.loading.current ?? false, intent: () => opts.intent.current ?? 'neutral', type: () => opts.type.current ?? 'button', /** * Resolved visual color. Doctrine: aesthetic must not * contradict semantic. When intent is evaluative * (affirm/fulfill/risk/threat/loss), it WINS over any * hierarchy override. When intent is neutral, `color` may * promote the visual to primary/secondary; if neither, * default 'primary' (Button's default is promoted because * actions are typically the focal point of their surface). */ color: () => { const intent = opts.intent.current ?? 'neutral'; if (intent !== 'neutral') return intent; return opts.color.current ?? 'primary'; }, 'aria-label': () => opts['aria-label'].current }, events: { 'commit-action': () => { // The consumer's onclick has already been (or will be) // invoked by native button click dispatch. We only fire // onPress for diagnostic / analytics consumers. opts.onPress.current?.({ intent: opts.intent.current ?? 'neutral' }); } } }); this.runtimePart = this.runtime.part('provider', { id: opts.id, ref: opts.ref, owner: this, syncAttrs: true }); } readonly isDisabled = $derived.by( () => (this.opts.disabled.current ?? false) || (this.field?.isDisabled ?? false) ); readonly isLoading = $derived.by(() => this.opts.loading.current ?? false); readonly snippetProps = $derived.by(() => ({ loading: this.isLoading, disabled: this.isDisabled })); readonly onclick = (_e: SomaMouseEvent) => { if (this.isDisabled || this.isLoading) return; // Sequence 'pre' — the perceptual signal fires synchronously with // the click; consumer's onclick runs in parallel. void this.runtime.trigger('commit-action'); }; /** * Render-time props. Composed of: * - identity from the runtime (`id`, `data-button`, ref attachment) * - the HTML `disabled` attribute * - the `onclick` handler */ readonly props = $derived.by(() => ({ ...this.runtimePart.props, disabled: this.isDisabled || undefined, onclick: this.onclick })); } export type { OnChangeFn, ButtonIntent };