You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
163 lines
5.5 KiB
163 lines
5.5 KiB
import { Soma } from '../../core/soma.svelte';
|
|
import { FieldProvider } from '../field/field-provider.svelte';
|
|
import { toggleMorfo } from '../../../morfo/components/toggle';
|
|
import type { OptsFromProps } from '../../provider';
|
|
import type { OnChangeFn, SomaMouseEvent } from '../../types';
|
|
import type { ToggleProps } from './types';
|
|
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
|
|
|
// ── Provider ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Provider's reactive contract derived from `ToggleProps`.
|
|
*
|
|
* - `Managed` — the SOMA-managed subset of public Props (excludes
|
|
* `children`, `child`, HTML passthroughs).
|
|
* - `'pressed'` — the bindable subset.
|
|
* - `id` and `ref` are baked in by `OptsFromProps`.
|
|
*
|
|
* Add a new prop in `types.ts` → list it in the `Managed` union here →
|
|
* pass it through `bindProps` in the wrapper. No third place to keep in sync.
|
|
*/
|
|
type ToggleOpts = OptsFromProps<
|
|
ToggleProps,
|
|
| 'pressed'
|
|
| 'disabled'
|
|
| 'readonly'
|
|
| 'required'
|
|
| 'invalid'
|
|
| 'intent'
|
|
| 'color'
|
|
| 'name'
|
|
| 'value'
|
|
| 'aria-label'
|
|
| 'onPressedChange',
|
|
'pressed'
|
|
>;
|
|
|
|
/**
|
|
* Toggle provider — runtime-direct.
|
|
*
|
|
* Earlier soma components extended a `Provider` base class that built
|
|
* render props from the morfo. Toggle is the pilot for skipping that base:
|
|
* the morfo runtime
|
|
* (`soma.runtime(toggleMorfo, sources)`) already owns the data-/aria-attr
|
|
* derivation and ref attachment; the provider only needs handler wiring
|
|
* and a few derived values (`isDisabled`, `snippetProps`).
|
|
*
|
|
* No inheritance, no local prop-contract helper, no module-level contract
|
|
* registration. Eidos's `lint.ts` and the morfo type system already catch
|
|
* contract violations at design time.
|
|
*/
|
|
export class ToggleProvider {
|
|
static create(opts: ToggleOpts) {
|
|
return new ToggleProvider(opts);
|
|
}
|
|
|
|
readonly opts: ToggleOpts;
|
|
readonly field = FieldProvider.get();
|
|
readonly runtime: SomaRuntime;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
readonly soma: Soma;
|
|
|
|
private constructor(opts: ToggleOpts) {
|
|
this.opts = opts;
|
|
|
|
// Register as the Field's primary input so `Field.Label`'s `for`
|
|
// attribute lands on this button. Direct assign — NOT $effect (A30).
|
|
if (this.field) this.field.inputId.current = opts.id.current;
|
|
|
|
// SomaRuntime — owns the morfo-derived attrs (data-state, aria-pressed,
|
|
// aria-disabled, etc.) plus ref attachment via `part(...)`.
|
|
//
|
|
// Single doctrinal event (anexo del libro, Tabla 1):
|
|
// - `commit-toggle` (commit / neutral) — el cierre perceptivo del
|
|
// toggle. El handler flipea el estado bound. El gesto de contact
|
|
// está implícito en el botón nativo; no se descompone en dos
|
|
// eventos porque la operación es instantánea.
|
|
this.soma = Soma.require();
|
|
this.runtime = this.soma.runtime(toggleMorfo, {
|
|
states: {
|
|
on: () => opts.pressed.current
|
|
},
|
|
props: {
|
|
disabled: () => this.isDisabled,
|
|
readonly: () => this.isReadonly,
|
|
required: () => this.isRequired,
|
|
invalid: () => this.isInvalid,
|
|
intent: () => opts.intent.current ?? 'neutral',
|
|
/**
|
|
* Resolved visual color. Doctrine: aesthetic must not
|
|
* contradict semantic. When intent is evaluative
|
|
* (affirm/risk/threat), it WINS over any hierarchy
|
|
* override. When intent is neutral, `color` may promote
|
|
* the visual to primary/secondary hierarchy; if neither,
|
|
* default 'neutral'.
|
|
*/
|
|
color: () => {
|
|
const intent = opts.intent.current ?? 'neutral';
|
|
if (intent !== 'neutral') return intent;
|
|
return opts.color.current ?? 'neutral';
|
|
},
|
|
'aria-label': () => opts['aria-label'].current
|
|
},
|
|
events: {
|
|
'commit-toggle': () => {
|
|
const next = !opts.pressed.current;
|
|
opts.pressed.current = next;
|
|
opts.onPressedChange.current?.(next);
|
|
}
|
|
}
|
|
});
|
|
this.runtimePart = this.runtime.part('provider', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
syncAttrs: true
|
|
});
|
|
}
|
|
|
|
readonly isDisabled = $derived.by(
|
|
() => this.opts.disabled.current || (this.field?.isDisabled ?? false)
|
|
);
|
|
readonly isReadonly = $derived.by(
|
|
() => this.opts.readonly.current || (this.field?.isReadonly ?? false)
|
|
);
|
|
readonly isRequired = $derived.by(
|
|
() => this.opts.required.current || (this.field?.isRequired ?? false)
|
|
);
|
|
readonly isInvalid = $derived.by(
|
|
() => this.opts.invalid.current || (this.field?.isInvalid ?? false)
|
|
);
|
|
|
|
readonly snippetProps = $derived.by(() => ({
|
|
pressed: this.opts.pressed.current
|
|
}));
|
|
|
|
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
|
|
if (this.isDisabled || this.isReadonly) return;
|
|
// Single perceptual moment: the consolidation of the new state.
|
|
// `commit-toggle` is post-sequence: handler flips state first,
|
|
// effects flush, then semantic.emit runs visual + sound + haptic
|
|
// channels against the resolved DOM.
|
|
void this.runtime.trigger('commit-toggle');
|
|
};
|
|
|
|
/**
|
|
* Render-time props. Composed of:
|
|
* - identity from the runtime (`id`, `data-toggle`, ref attachment)
|
|
* - the HTML `disabled` attribute (not a morfo concern — primitive
|
|
* button semantics)
|
|
* - the `onclick` handler
|
|
*/
|
|
readonly props = $derived.by(() => ({
|
|
...this.runtimePart.props,
|
|
disabled: this.isDisabled || undefined,
|
|
onclick: this.onclick
|
|
}));
|
|
}
|
|
|
|
// `OnChangeFn` re-export — kept so consumers' editor inspection on the wrapper
|
|
// still resolves the type by the provider symbol path (legacy import shape).
|
|
export type { OnChangeFn };
|