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.
svelte-kit-vice/src/uix/morfo/components/knob.ts

260 lines
8.6 KiB

// src/uix/morfo/components/knob.ts
//
// Knob — rotary value control (WAI-ARIA slider pattern, radial rendering).
// Continuous-shape component: like the Slider it emits a rAF-throttled
// `handle-drag` on every value change during the gesture (coincident), framed by
// the start / release / commit boundaries (handle-pick, handle-drop, commit-set).
//
// NOTE(agent-uncertainty): archetypes for `control` and `indicator` are
// omitted because ARCHETYPE_VOCABULARY lives only in code
// (src/uix/morfo/types.ts) and the docs deliberately do not copy the list.
// If the vocabulary has a fitting entry (e.g. 'control', 'handle',
// 'indicator'), add it — two layers would consume it (eidos transversal
// selectors + sema verbs by role), satisfying the 2-of-3 rule.
import type { Morfo } from '../types';
import { v } from '../types';
export const knobMorfo = {
name: 'Knob',
kebab: 'knob',
scope: ['soma', 'sema', 'eidos'],
expression: 'pack',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/slider/',
texts: {
label: '#?components.knob.label|Knob',
'control.roledescription': '#?components.knob.control.roledescription|rotary knob'
},
parts: [
{
name: 'Provider',
kebab: 'provider', // emits bare data-knob
archetype: 'provider', // container root, not the interactive element
kind: 'public',
defaultElement: 'div',
optional: false,
data: [
// Presence flags — optional so morfo-check doesn't flag legit absence.
{ attr: 'data-dragging', severity: 'optional' },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'data-invalid', value: v.propRef('invalid'), severity: 'optional' },
// Persistent evaluative state — sema's transient twin is data-event-intent.
// Enum-valued (values[]) → the compiler writes the intent directly in
// `enum` mode; `emit` is a non-enum-only knob and is ignored here (its
// presence on an enum is rejected by the schema invariant), so it's
// omitted — matching timeline / toast which declare data-intent the same way.
{
attr: 'data-intent',
values: ['neutral', 'affirm', 'risk', 'threat'],
value: v.propRef('intent'),
severity: 'optional'
}
],
aria: []
},
{
name: 'Control',
kebab: 'control',
kind: 'public',
defaultElement: 'div', // needs tabindex=0 from the provider (role=slider is not natively focusable)
role: 'slider',
optional: false,
data: [{ attr: 'data-dragging', severity: 'optional' }],
aria: [
{ attr: 'aria-valuemin', value: v.propRef('min') },
{ attr: 'aria-valuemax', value: v.propRef('max') },
{ attr: 'aria-valuenow', value: v.propRef('value') },
// The provider overrides this with a locale-formatted string via
// the render bag — soma genuinely owns the *value* (formatting).
{ attr: 'aria-valuetext', value: v.propRef('value'), severity: 'recommended' },
{
attr: 'aria-labelledby',
value: v.partRef('label'),
condition: { when: 'part-present', part: 'label' },
severity: 'recommended'
},
// aria-label ONLY when no Label part is composed — the inverse of the
// aria-labelledby above, via the `part-absent` morfo condition.
{
attr: 'aria-label',
value: v.translationRef('label', 'Knob'),
condition: { when: 'part-absent', part: 'label' },
severity: 'optional'
},
{
attr: 'aria-roledescription',
value: v.translationRef('control.roledescription', 'rotary knob'),
severity: 'recommended'
},
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
condition: { when: 'prop-truthy', prop: 'disabled' },
severity: 'optional'
},
{
attr: 'aria-readonly',
value: v.propRef('readonly'),
condition: { when: 'prop-truthy', prop: 'readonly' },
severity: 'optional'
},
{
attr: 'aria-invalid',
value: v.propRef('invalid'),
condition: { when: 'prop-truthy', prop: 'invalid' },
severity: 'optional'
}
],
keyboard: [
// Decision (needs sign-off): rotation does not mirror in RTL, so
// Left/Right stay absolute (Left=decrease, Right=increase) instead
// of going through getDirectionalKeys(). Deliberate A12 exception —
// a rotary control's increase direction is angular, not inline.
{ key: 'ArrowUp', action: 'increase' },
{ key: 'ArrowRight', action: 'increase' },
{ key: 'ArrowDown', action: 'decrease' },
{ key: 'ArrowLeft', action: 'decrease' },
{ key: 'PageUp', action: 'increase-large' },
{ key: 'PageDown', action: 'decrease-large' },
{ key: 'Home', action: 'set-min' },
{ key: 'End', action: 'set-max' }
]
},
{
name: 'Indicator',
kebab: 'indicator',
kind: 'public',
defaultElement: 'div',
role: 'presentation',
optional: true,
// A2.3 exception: purely decorative pointer/arc; positioned via the
// provider-published CSS vars (--_knob-angle / --_knob-progress), no
// state attrs of its own beyond the part marker.
data: [],
aria: []
},
{
name: 'Label',
kebab: 'label',
kind: 'public',
defaultElement: 'span',
optional: true,
data: [],
aria: []
},
{
name: 'ValueText',
kebab: 'value-text',
kind: 'public',
defaultElement: 'span',
optional: true,
data: [],
// The Control already carries aria-valuetext; the visible readout is
// redundant for AT.
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
name: 'ValueField',
kebab: 'value-field',
kind: 'public',
defaultElement: 'div',
optional: true,
// The EDITABLE twin of ValueText: composes <NumberField> wired to the
// knob's value so the centred readout can be typed. The NumberField owns
// spinbutton semantics; this part is only the positioning container + its
// marker (parallel to time-picker's `hour-slider`).
data: [],
aria: []
},
{
name: 'HiddenInput',
kebab: 'hidden-input',
kind: 'public',
defaultElement: 'input',
optional: true, // A13 form integration; composed when `name` is set
data: [],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
}
],
events: [
// Gesture engages. `post` for the same reason overlay openings are
// `post`: the handler (data-dragging + pointer capture) must not be
// gated behind the perceptual hold — same doctrine as the checkbox-lag
// fix. The pick sound/haptic plays while the drag is already live.
{
name: 'handle-pick',
semantic: {
family: 'handle',
verb: 'pick',
target: v.partRef('control'),
sequence: 'post'
}
},
// Continuous rotation. Fires (rAF-throttled) on every value change during
// the drag, `coincident` so the perceptual tick rides the motion. Carries a
// per-emit sound/haptic payload (progress + angular velocity) — the cascade
// rule only opts it into sound + haptic, mirroring the Slider's handle-drag.
{
name: 'handle-drag',
semantic: {
family: 'handle',
verb: 'drag',
target: v.partRef('control'),
sequence: 'coincident'
}
},
// Gesture releases. `pre`: the release signal belongs to the moment the
// user lets go, before the committed state settles.
{
name: 'handle-drop',
semantic: {
family: 'handle',
verb: 'drop',
target: v.partRef('control'),
sequence: 'pre'
}
},
// Value committed: pointer release with a changed value, or each
// discrete keyboard step (increase / decrease / large / min / max).
// This single event covers every state-mutating keyboard action (A-3.7).
// commit family ⇒ intent REQUIRED per SEMA_FAMILY_POLICY: bound to the
// public `intent` prop (a gain knob past 0dB can commit with 'risk').
{
name: 'commit-set',
semantic: {
family: 'commit',
verb: 'set',
target: v.partRef('control'),
sequence: 'post',
intent: {
fromProp: 'intent',
default: 'neutral',
supported: ['neutral', 'affirm', 'risk', 'threat']
}
},
// Releasing the knob fires `handle-drop` and, if the value moved,
// `commit-set` — both on `control`, back to back, and the control is
// the only surface a knob has. `queue` lets the drop be perceived
// before the commit settles the value (audit S-17 measured the
// commit's projection dying 24ms into a 240ms hold here).
regime: 'queue'
},
// Double-click returns to defaultValue (audio-console convention).
// A-3.8 satisfied.
{
name: 'commit-reset',
semantic: {
family: 'commit',
verb: 'reset',
target: v.partRef('control'),
sequence: 'post',
intent: 'neutral'
}
}
]
} as const satisfies Morfo;

Powered by TurnKey Linux.