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.
260 lines
8.6 KiB
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;
|