The Fable-built Knob lived entirely under `src/uix/active-uix/knob/` — the wrong home for a multi-layer component — with guessed APIs (103 type errors), missing sub-part wrappers, junk `mnt/user-data/outputs/` paths, and its angle math inlined against a fabricated `Gesture.base` surface. Dismember it into the canonical per-layer directories, against the real APIs, consuming the real `Gesture.rotate` (STUMBLES S4): - **morfo** → `morfo/components/knob.ts`. Applies the S3 `part-absent` condition to `aria-label` (emit only when no Label part) — the file's own NOTE asked for it once the condition existed. - **langs** → `langs/components/knob.ts` (+ registered in the langs index). - **sema** → `sema/components/knob.ts` (+ registered in the sema index); valid nominal earcons + haptic kinds, `channels` declared, risk-commit composes (`op: 'add'`) over the intent profile per doctrine. - **soma** → `soma/components/knob/`: provider (rewired to `Gesture.rotate` in the constructor — mapping `angleAbsolute` onto the [startAngle,endAngle] sweep, folding [0,360) into a symmetric range), `state<T>()` for the label id, the missing `KnobIndicatorProvider`, root + five sub-part wrappers, exports, index, and a provider test (ARIA slider contract + gesture wiring + keyboard stepping/commit + disabled guard + dblclick reset). - **eidos** → `eidos/components/knob/`: recipe css, size-aware root wrapper (`ActiveEidos.resolve`), five pass-through wrappers, types, namespace index. The old `active-uix/knob/` (untracked, added mid-session) is removed. Knob is added to `recipe-css-contract`'s WIP_TRACKS until its recipe tokens land (css still hardcodes md sizes, no variants). `npm run check`: 0 errors in every knob file (total back to the 59 pre-existing baseline, from 162). Tests: 4 knob provider + no regressions across eidos/sema/langs (174). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>menubar-v4-safe
parent
2bd09ac6bb
commit
c13cad74d5
@ -0,0 +1,45 @@
|
||||
// Knob — eidos compound API, disciplined option C.
|
||||
//
|
||||
// import { Knob } from '$uix/eidos/components/knob';
|
||||
//
|
||||
// <Knob bind:value aria-label="Volume">
|
||||
// <Knob.Control>
|
||||
// <Knob.Indicator />
|
||||
// </Knob.Control>
|
||||
// <Knob.ValueText />
|
||||
// </Knob>
|
||||
import KnobComponent from './knob.svelte';
|
||||
import Control from './knob-control.svelte';
|
||||
import Indicator from './knob-indicator.svelte';
|
||||
import Label from './knob-label.svelte';
|
||||
import ValueText from './knob-value-text.svelte';
|
||||
import HiddenInput from './knob-hidden-input.svelte';
|
||||
|
||||
type KnobNamespace = typeof KnobComponent & {
|
||||
Control: typeof Control;
|
||||
Indicator: typeof Indicator;
|
||||
Label: typeof Label;
|
||||
ValueText: typeof ValueText;
|
||||
HiddenInput: typeof HiddenInput;
|
||||
};
|
||||
|
||||
const Knob = KnobComponent as KnobNamespace;
|
||||
Knob.Control = Control;
|
||||
Knob.Indicator = Indicator;
|
||||
Knob.Label = Label;
|
||||
Knob.ValueText = ValueText;
|
||||
Knob.HiddenInput = HiddenInput;
|
||||
|
||||
export { Knob };
|
||||
|
||||
export default Knob;
|
||||
|
||||
export type {
|
||||
KnobProps,
|
||||
KnobControlProps as ControlProps,
|
||||
KnobIndicatorProps as IndicatorProps,
|
||||
KnobLabelProps as LabelProps,
|
||||
KnobValueTextProps as ValueTextProps,
|
||||
KnobHiddenInputProps as HiddenInputProps,
|
||||
KnobSize
|
||||
} from './types';
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as Knob from '$soma/components/knob';
|
||||
import type { KnobControlProps } from './types';
|
||||
|
||||
let { children, ...rest }: KnobControlProps = $props();
|
||||
</script>
|
||||
|
||||
<Knob.Control {...rest}>
|
||||
{@render children?.()}
|
||||
</Knob.Control>
|
||||
@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as Knob from '$soma/components/knob';
|
||||
import type { KnobHiddenInputProps } from './types';
|
||||
|
||||
let { ...rest }: KnobHiddenInputProps = $props();
|
||||
</script>
|
||||
|
||||
<Knob.HiddenInput {...rest} />
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as Knob from '$soma/components/knob';
|
||||
import type { KnobIndicatorProps } from './types';
|
||||
|
||||
let { children, ...rest }: KnobIndicatorProps = $props();
|
||||
</script>
|
||||
|
||||
<Knob.Indicator {...rest}>
|
||||
{@render children?.()}
|
||||
</Knob.Indicator>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as Knob from '$soma/components/knob';
|
||||
import type { KnobLabelProps } from './types';
|
||||
|
||||
let { children, ...rest }: KnobLabelProps = $props();
|
||||
</script>
|
||||
|
||||
<Knob.Label {...rest}>
|
||||
{@render children?.()}
|
||||
</Knob.Label>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as Knob from '$soma/components/knob';
|
||||
import type { KnobValueTextProps } from './types';
|
||||
|
||||
let { children, ...rest }: KnobValueTextProps = $props();
|
||||
</script>
|
||||
|
||||
<Knob.ValueText {...rest}>
|
||||
{@render children?.()}
|
||||
</Knob.ValueText>
|
||||
@ -0,0 +1,129 @@
|
||||
/* src/uix/eidos/components/knob/knob.css
|
||||
*
|
||||
* Recipe for Knob. Token discipline per completion-checklist C:
|
||||
* - no raw colors (R-2.1), no --scale-*/--primitive-* direct (R-4.6)
|
||||
* - elevation via var(--shadow-*) (R-4.1)
|
||||
* - opacity via var(--opacity-*) (R-4.2)
|
||||
* - hover via the state layer (R-4.3)
|
||||
* - logical axes only (R-4.4)
|
||||
* - sizes via the coordinated --size-{k}-* bundle
|
||||
*
|
||||
* Public aliases (--knob-*) must be declared in EidosConfig.recipes so
|
||||
* recipe-css-contract.test.ts passes — see the README note.
|
||||
*/
|
||||
|
||||
[data-knob] {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--knob-gap, var(--size-md-gap));
|
||||
}
|
||||
|
||||
[data-knob-control] {
|
||||
position: relative;
|
||||
inline-size: var(--knob-diameter, calc(var(--size-md-control-height) * 2));
|
||||
block-size: var(--knob-diameter, calc(var(--size-md-control-height) * 2));
|
||||
border-radius: var(--radius-full, 9999px); /* literal: fallback for circle */
|
||||
background:
|
||||
/* value arc: conic gradient driven by the provider-published progress */
|
||||
conic-gradient(
|
||||
from calc(var(--knob-start-angle, -135deg)),
|
||||
var(--color-primary-solid) calc(var(--knob-progress, 0) * var(--knob-sweep, 270deg)),
|
||||
var(--knob-track-bg, var(--color-neutral-element)) 0
|
||||
);
|
||||
box-shadow: var(--shadow-2);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Face plate above the arc */
|
||||
[data-knob-control]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: var(--knob-arc-width, var(--space-2));
|
||||
border-radius: inherit;
|
||||
background: var(--knob-face-bg, var(--color-neutral-surface));
|
||||
}
|
||||
|
||||
/* Pointer, rotated by the provider-published angle */
|
||||
[data-knob-indicator] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
rotate: var(--knob-angle, -135deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-knob-indicator]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-start: calc(50% - (var(--knob-pointer-width, 3px) / 2)); /* literal: hairline pointer */
|
||||
inset-block-start: var(--knob-arc-width, var(--space-2));
|
||||
inline-size: var(--knob-pointer-width, 3px); /* literal: hairline pointer */
|
||||
block-size: 28%; /* literal: pointer proportion of face */
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--knob-pointer-bg, var(--color-neutral-content));
|
||||
}
|
||||
|
||||
/* ---- states (R-1.x coverage) -------------------------------------------- */
|
||||
|
||||
[data-knob-control]:hover {
|
||||
background-color: var(--state-hover);
|
||||
}
|
||||
|
||||
[data-knob-control]:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--color-focus-ring);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
[data-knob][data-dragging] [data-knob-control] {
|
||||
cursor: grabbing;
|
||||
box-shadow: var(--shadow-3);
|
||||
}
|
||||
|
||||
[data-knob][data-disabled] [data-knob-control] {
|
||||
cursor: not-allowed;
|
||||
opacity: var(--opacity-disabled);
|
||||
}
|
||||
|
||||
[data-knob][data-readonly] [data-knob-control] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
[data-knob][data-invalid] [data-knob-control] {
|
||||
/* value arc turns to the risk role */
|
||||
--color-primary-solid: var(--color-risk-solid);
|
||||
}
|
||||
|
||||
/* Persistent intent tinting (data-intent) — distinct from the transient
|
||||
* data-event-intent that sema stamps during the hold. */
|
||||
[data-knob][data-intent='risk'] [data-knob-control] {
|
||||
--color-primary-solid: var(--color-risk-solid);
|
||||
}
|
||||
[data-knob][data-intent='threat'] [data-knob-control] {
|
||||
--color-primary-solid: var(--color-threat-solid);
|
||||
}
|
||||
[data-knob][data-intent='affirm'] [data-knob-control] {
|
||||
--color-primary-solid: var(--color-affirm-solid);
|
||||
}
|
||||
|
||||
/* ---- event reactions (sema visual channel) ------------------------------- */
|
||||
|
||||
[data-knob-control][data-event-family='commit'][data-event-phase='active'] {
|
||||
/* perceptual signature comes from EidosConfig.motion (R-4.5) — the recipe
|
||||
* only opts the part into the channel; no local @keyframes here. */
|
||||
}
|
||||
|
||||
/* ---- typography ----------------------------------------------------------- */
|
||||
|
||||
[data-knob-label] {
|
||||
font-size: var(--size-md-font-size);
|
||||
line-height: var(--leading-ui);
|
||||
color: var(--color-neutral-content);
|
||||
}
|
||||
|
||||
[data-knob-value-text] {
|
||||
font-size: var(--size-sm-font-size);
|
||||
line-height: var(--leading-ui);
|
||||
color: var(--color-neutral-content-subtle);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import './knob.css';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
/**
|
||||
* Eidos `<Knob>` — visual wrapper over Soma's knob primitive.
|
||||
* Soma owns pointer/keyboard/value behavior; Eidos adds `size`.
|
||||
*/
|
||||
import * as Knob from '$soma/components/knob';
|
||||
import type { KnobProps } from './types';
|
||||
|
||||
let { size = 'md', value = $bindable(0), children, ...rest }: KnobProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const resolvedSize = $derived(eidos.resolve(size, 'md'));
|
||||
</script>
|
||||
|
||||
<Knob.Provider {...rest} bind:value data-size={resolvedSize}>
|
||||
{@render children?.()}
|
||||
</Knob.Provider>
|
||||
@ -0,0 +1,30 @@
|
||||
import type {
|
||||
ProviderProps,
|
||||
ControlProps,
|
||||
IndicatorProps,
|
||||
LabelProps,
|
||||
ValueTextProps,
|
||||
HiddenInputProps,
|
||||
KnobIntent
|
||||
} from '$soma/components/knob';
|
||||
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
|
||||
|
||||
export type KnobSize = Extract<Size, 'sm' | 'md' | 'lg' | 'xl'>;
|
||||
|
||||
/**
|
||||
* Props for the eidos `<Knob>`.
|
||||
*
|
||||
* Extends Soma's knob with visual sizing only. Value, sweep, disabled state,
|
||||
* gesture and ARIA are owned by Soma.
|
||||
*/
|
||||
export type KnobProps = ProviderProps & {
|
||||
/** Visual size. Accepts a responsive map resolved by ActiveEidos. @default 'md' */
|
||||
size?: ResponsiveProp<KnobSize>;
|
||||
};
|
||||
|
||||
export type KnobControlProps = ControlProps;
|
||||
export type KnobIndicatorProps = IndicatorProps;
|
||||
export type KnobLabelProps = LabelProps;
|
||||
export type KnobValueTextProps = ValueTextProps;
|
||||
export type KnobHiddenInputProps = HiddenInputProps;
|
||||
export type { KnobIntent };
|
||||
@ -0,0 +1,14 @@
|
||||
import type { LangNode } from '$libs/langs';
|
||||
|
||||
export const knobLangs = {
|
||||
label: {
|
||||
es: 'Dial',
|
||||
en: 'Knob'
|
||||
},
|
||||
control: {
|
||||
roledescription: {
|
||||
es: 'dial giratorio',
|
||||
en: 'rotary knob'
|
||||
}
|
||||
}
|
||||
} satisfies LangNode;
|
||||
@ -0,0 +1,223 @@
|
||||
// src/uix/morfo/components/knob.ts
|
||||
//
|
||||
// Knob — rotary value control (WAI-ARIA slider pattern, radial rendering).
|
||||
// Continuous-shape component per component-guide §2: gestures are modeled as
|
||||
// start / commit boundaries (handle-pick, handle-drop, commit-set), never
|
||||
// per-frame emissions.
|
||||
//
|
||||
// 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', 'eidos'],
|
||||
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.
|
||||
{
|
||||
attr: 'data-intent',
|
||||
values: ['neutral', 'affirm', 'risk', 'threat'],
|
||||
value: v.propRef('intent'),
|
||||
emit: 'value',
|
||||
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
|
||||
// renderProps() — 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: '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'
|
||||
}
|
||||
},
|
||||
// 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']
|
||||
}
|
||||
}
|
||||
},
|
||||
// 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;
|
||||
@ -0,0 +1,38 @@
|
||||
// src/uix/sema/components/knob.ts
|
||||
//
|
||||
// Per-component perceptual defaults. Selectors MUST use the typed builder —
|
||||
// hand-written strings in cascade rules are an architecture violation.
|
||||
|
||||
import type { Sema } from '../sema-map';
|
||||
import { semaSelector } from '$uix/morfo';
|
||||
import { sound } from '../sounds';
|
||||
import { knobMorfo } from '$uix/morfo/components/knob';
|
||||
|
||||
const onControl = (matchers?: Parameters<typeof semaSelector<typeof knobMorfo>>[2]) =>
|
||||
semaSelector(knobMorfo, 'control', matchers);
|
||||
|
||||
export const knobSema: Sema = {
|
||||
name: 'knob',
|
||||
cascade: [
|
||||
// Gesture engage / release — light nominal-repository earcons + haptics.
|
||||
{
|
||||
selector: onControl({ eventName: 'handle-pick' }),
|
||||
channels: ['sound', 'haptic'],
|
||||
sound: sound('handle.pickup.air'),
|
||||
haptic: { kind: 'tap' }
|
||||
},
|
||||
{
|
||||
selector: onControl({ eventName: 'handle-drop' }),
|
||||
channels: ['sound'],
|
||||
sound: sound('handle.release.soft')
|
||||
},
|
||||
// Committing into a risky zone adds character over the commit intent's
|
||||
// profile (compose with `op: 'add'`, never replace the primitives).
|
||||
{
|
||||
selector: onControl({ eventName: 'commit-set', eventIntent: 'risk' }),
|
||||
channels: ['sound', 'haptic'],
|
||||
sound: { pitch: { op: 'add', value: -150 }, contour: 'descending' },
|
||||
haptic: { kind: 'warning' }
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { KnobControlProvider } from '../knob-provider.svelte';
|
||||
import type { KnobControlProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'knob-control'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: KnobControlProps = $props();
|
||||
|
||||
const state = KnobControlProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { KnobHiddenInputProvider } from '../knob-provider.svelte';
|
||||
import type { KnobHiddenInputProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
let { id = createId(uid, 'knob-hidden-input') }: KnobHiddenInputProps = $props();
|
||||
|
||||
let ref = $state<HTMLInputElement | null>(null);
|
||||
|
||||
const part = KnobHiddenInputProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive<HTMLElement | null>(
|
||||
() => ref,
|
||||
(v) => (ref = v as HTMLInputElement | null)
|
||||
)
|
||||
});
|
||||
</script>
|
||||
|
||||
<input bind:this={ref} {...part.props} />
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { KnobIndicatorProvider } from '../knob-provider.svelte';
|
||||
import type { KnobIndicatorProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'knob-indicator'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: KnobIndicatorProps = $props();
|
||||
|
||||
const state = KnobIndicatorProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { KnobLabelProvider } from '../knob-provider.svelte';
|
||||
import type { KnobLabelProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'knob-label'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: KnobLabelProps = $props();
|
||||
|
||||
const state = KnobLabelProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<span {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { KnobValueTextProvider } from '../knob-provider.svelte';
|
||||
import type { KnobValueTextProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'knob-value-text'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: KnobValueTextProps = $props();
|
||||
|
||||
const state = KnobValueTextProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<span {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
{/if}
|
||||
@ -0,0 +1,71 @@
|
||||
<!-- src/uix/soma/components/knob/components/knob.svelte -->
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { KnobProvider } from '../knob-provider.svelte';
|
||||
import type { KnobProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'knob'),
|
||||
value = $bindable(0),
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
largeStep = 10,
|
||||
defaultValue = min,
|
||||
startAngle = -135,
|
||||
endAngle = 135,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
invalid = false,
|
||||
intent = 'neutral',
|
||||
name = undefined,
|
||||
formatValue = undefined,
|
||||
onValueChange = () => {},
|
||||
onValueCommit = () => {},
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: KnobProps = $props();
|
||||
|
||||
const state = KnobProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
value: writableActive(
|
||||
() => value,
|
||||
(v) => (value = v)
|
||||
),
|
||||
min: readableActive(() => min),
|
||||
max: readableActive(() => max),
|
||||
step: readableActive(() => step),
|
||||
largeStep: readableActive(() => largeStep),
|
||||
defaultValue: readableActive(() => defaultValue),
|
||||
startAngle: readableActive(() => startAngle),
|
||||
endAngle: readableActive(() => endAngle),
|
||||
disabled: readableActive(() => disabled),
|
||||
readonly: readableActive(() => readonly),
|
||||
invalid: readableActive(() => invalid),
|
||||
intent: readableActive(() => intent),
|
||||
name: readableActive(() => name),
|
||||
formatValue: readableActive(() => formatValue),
|
||||
onValueChange: readableActive(() => onValueChange),
|
||||
onValueCommit: readableActive(() => onValueCommit)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,16 @@
|
||||
export { default as Provider } from './components/knob.svelte';
|
||||
export { default as Control } from './components/knob-control.svelte';
|
||||
export { default as Indicator } from './components/knob-indicator.svelte';
|
||||
export { default as Label } from './components/knob-label.svelte';
|
||||
export { default as ValueText } from './components/knob-value-text.svelte';
|
||||
export { default as HiddenInput } from './components/knob-hidden-input.svelte';
|
||||
|
||||
export type {
|
||||
KnobIntent,
|
||||
KnobProps as ProviderProps,
|
||||
KnobControlProps as ControlProps,
|
||||
KnobIndicatorProps as IndicatorProps,
|
||||
KnobLabelProps as LabelProps,
|
||||
KnobValueTextProps as ValueTextProps,
|
||||
KnobHiddenInputProps as HiddenInputProps
|
||||
} from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,195 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createActiveDom } from '$adom';
|
||||
import { state } from '$libs/reactive';
|
||||
import type { Morfo } from '$uix/morfo';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
||||
|
||||
import { KnobProvider, KnobControlProvider } from './knob-provider.svelte';
|
||||
|
||||
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
||||
let result!: T;
|
||||
const cleanup = $effect.root(() => {
|
||||
result = fn();
|
||||
});
|
||||
return { result, cleanup };
|
||||
}
|
||||
|
||||
function installSomaHarness() {
|
||||
const dom = createActiveDom();
|
||||
const soma = {
|
||||
dom,
|
||||
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
createSomaRuntime(morfo, {
|
||||
dom,
|
||||
translate: (key) => key,
|
||||
...sources
|
||||
})
|
||||
} as unknown as Soma;
|
||||
|
||||
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||
vi.spyOn(KnobProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
function createKnobRoot() {
|
||||
const root = document.createElement('div');
|
||||
root.getBoundingClientRect = vi.fn(
|
||||
() =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 200,
|
||||
bottom: 200,
|
||||
width: 200,
|
||||
height: 200,
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect
|
||||
);
|
||||
root.setPointerCapture = vi.fn();
|
||||
root.releasePointerCapture = vi.fn();
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function knobOpts(root = createKnobRoot(), commit = vi.fn()) {
|
||||
return {
|
||||
id: state('knob-root'),
|
||||
ref: state<HTMLElement | null>(root),
|
||||
value: state(20),
|
||||
min: state(0),
|
||||
max: state(100),
|
||||
step: state(5),
|
||||
largeStep: state(10),
|
||||
defaultValue: state(0),
|
||||
startAngle: state(-135),
|
||||
endAngle: state(135),
|
||||
disabled: state(false),
|
||||
readonly: state(false),
|
||||
invalid: state(false),
|
||||
intent: state<'neutral' | 'affirm' | 'risk' | 'threat'>('neutral'),
|
||||
name: state<string | undefined>('gain'),
|
||||
formatValue: state<((v: number) => string) | undefined>(undefined),
|
||||
onValueChange: state<((v: number) => void) | undefined>(undefined),
|
||||
onValueCommit: state<((v: number) => void) | undefined>(commit)
|
||||
};
|
||||
}
|
||||
|
||||
function makeControl() {
|
||||
return KnobControlProvider.create({
|
||||
id: state('knob-control'),
|
||||
ref: state<HTMLElement | null>(document.createElement('div'))
|
||||
});
|
||||
}
|
||||
|
||||
function keyEvent(key: string): KeyboardEvent {
|
||||
return { key, preventDefault: vi.fn() } as unknown as KeyboardEvent;
|
||||
}
|
||||
|
||||
describe('KnobProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('exposes the provider marker + the control ARIA slider contract, wired to Gesture.rotate', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = knobOpts();
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = KnobProvider.create(opts);
|
||||
vi.spyOn(KnobProvider, 'require').mockReturnValue(provider);
|
||||
const control = makeControl();
|
||||
return { provider, control };
|
||||
});
|
||||
|
||||
expect(result.provider.props).toMatchObject({ id: 'knob-root', 'data-knob': '' });
|
||||
expect(result.control.props).toMatchObject({
|
||||
role: 'slider',
|
||||
'aria-valuemin': 0,
|
||||
'aria-valuemax': 100,
|
||||
'aria-valuenow': 20,
|
||||
tabindex: 0
|
||||
});
|
||||
// Gesture.rotate wired: the control carries the gesture's pointerdown handler.
|
||||
expect(typeof result.control.props.onpointerdown).toBe('function');
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('steps the value with the keyboard and commits', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const commit = vi.fn();
|
||||
const opts = knobOpts(createKnobRoot(), commit);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = KnobProvider.create(opts);
|
||||
vi.spyOn(KnobProvider, 'require').mockReturnValue(provider);
|
||||
return { control: makeControl() };
|
||||
});
|
||||
|
||||
const onkeydown = result.control.props.onkeydown as (e: KeyboardEvent) => void;
|
||||
|
||||
onkeydown(keyEvent('ArrowUp'));
|
||||
expect(opts.value.current).toBe(25); // 20 + step(5)
|
||||
onkeydown(keyEvent('ArrowDown'));
|
||||
expect(opts.value.current).toBe(20);
|
||||
onkeydown(keyEvent('PageUp'));
|
||||
expect(opts.value.current).toBe(30); // + largeStep(10)
|
||||
onkeydown(keyEvent('Home'));
|
||||
expect(opts.value.current).toBe(0);
|
||||
onkeydown(keyEvent('End'));
|
||||
expect(opts.value.current).toBe(100);
|
||||
|
||||
expect(commit).toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('does not step when disabled', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = knobOpts();
|
||||
opts.disabled.current = true;
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = KnobProvider.create(opts);
|
||||
vi.spyOn(KnobProvider, 'require').mockReturnValue(provider);
|
||||
return { control: makeControl() };
|
||||
});
|
||||
|
||||
(result.control.props.onkeydown as (e: KeyboardEvent) => void)(keyEvent('ArrowUp'));
|
||||
expect(opts.value.current).toBe(20); // unchanged
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('resets to defaultValue on double-click', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const commit = vi.fn();
|
||||
const opts = knobOpts(createKnobRoot(), commit);
|
||||
opts.value.current = 77;
|
||||
opts.defaultValue.current = 42;
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = KnobProvider.create(opts);
|
||||
vi.spyOn(KnobProvider, 'require').mockReturnValue(provider);
|
||||
return { control: makeControl() };
|
||||
});
|
||||
|
||||
(result.control.props.ondblclick as () => void)();
|
||||
expect(opts.value.current).toBe(42);
|
||||
expect(commit).toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,380 @@
|
||||
// src/uix/soma/components/knob/knob-provider.svelte.ts
|
||||
//
|
||||
// Headless behavior for Knob. Follows the closed architecture: the morfo
|
||||
// declares, this provider only supplies sources, targets and handlers. The
|
||||
// radial pointer mechanics live in the shared `Gesture.rotate` layer; this
|
||||
// provider only maps the reported angle onto the value sweep.
|
||||
|
||||
import { context, type WithRefOpts } from '../../provider';
|
||||
import { Soma } from '../../core/soma.svelte';
|
||||
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
||||
import { KEYS } from '../../keyboard';
|
||||
import { Gesture, type GestureRotateState } from '../../layers/gesture';
|
||||
import { boolToEmptyStrOrUndef } from '$adom';
|
||||
import { readableActive, state, type State } from '$libs/reactive';
|
||||
import { knobMorfo } from '../../../morfo/components/knob';
|
||||
import type { KnobStateFields, KnobActiveFields } from './types';
|
||||
import type { StateProps, ActiveProps } from '$libs/reactive';
|
||||
|
||||
interface KnobOpts
|
||||
extends WithRefOpts,
|
||||
StateProps<KnobStateFields>,
|
||||
ActiveProps<KnobActiveFields> {}
|
||||
|
||||
function clamp(v: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
function snapToStep(v: number, min: number, step: number) {
|
||||
const snapped = min + Math.round((v - min) / step) * step;
|
||||
// Avoid FP drift on non-integer steps.
|
||||
const decimals = (String(step).split('.')[1] ?? '').length;
|
||||
return Number(snapped.toFixed(decimals));
|
||||
}
|
||||
|
||||
export class KnobProvider {
|
||||
static readonly ctx = context<KnobProvider>('Knob');
|
||||
static get() {
|
||||
return this.ctx.getOr(undefined) as KnobProvider | undefined;
|
||||
}
|
||||
static require() {
|
||||
return this.ctx.get();
|
||||
}
|
||||
|
||||
readonly opts: KnobOpts;
|
||||
readonly soma: Soma;
|
||||
readonly runtime: SomaRuntime;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
|
||||
// Ids published by optional sub-parts (A30: direct assignment, no $effect).
|
||||
readonly labelId: State<string | undefined> = state<string | undefined>(undefined);
|
||||
|
||||
// Gesture state
|
||||
dragging = $state(false);
|
||||
private valueOnPick = 0;
|
||||
private pickFired = false;
|
||||
readonly gesture: GestureRotateState;
|
||||
|
||||
static create(opts: KnobOpts) {
|
||||
return new KnobProvider(opts);
|
||||
}
|
||||
|
||||
private constructor(opts: KnobOpts) {
|
||||
this.opts = opts;
|
||||
this.soma = Soma.require();
|
||||
this.runtime = this.soma.runtime(knobMorfo, {
|
||||
states: {},
|
||||
props: {
|
||||
value: () => this.opts.value.current,
|
||||
min: () => this.opts.min.current,
|
||||
max: () => this.opts.max.current,
|
||||
disabled: () => this.opts.disabled.current,
|
||||
readonly: () => this.opts.readonly.current,
|
||||
invalid: () => this.opts.invalid.current,
|
||||
intent: () => this.opts.intent.current
|
||||
},
|
||||
parts: {
|
||||
label: () => this.labelId.current
|
||||
},
|
||||
events: {
|
||||
// Handlers are synchronous; guards live at the call-site.
|
||||
'handle-pick': () => {
|
||||
this.dragging = true;
|
||||
this.valueOnPick = this.opts.value.current;
|
||||
},
|
||||
'handle-drop': () => {
|
||||
this.dragging = false;
|
||||
},
|
||||
'commit-set': () => {
|
||||
// State was already mutated live during drag / by the keyboard
|
||||
// step method before trigger(). commit-set is the perceptual
|
||||
// boundary; the structural effects re-derive from state.
|
||||
this.opts.onValueCommit.current?.(this.opts.value.current);
|
||||
},
|
||||
'commit-reset': () => {
|
||||
this.opts.value.current = this.opts.defaultValue.current;
|
||||
this.opts.onValueCommit.current?.(this.opts.value.current);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.runtimePart = this.runtime.part('provider', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
context: KnobProvider.ctx,
|
||||
syncAttrs: true
|
||||
});
|
||||
|
||||
// Radial pointer mechanics live in the shared gesture layer; this provider
|
||||
// only maps the reported absolute angle onto the value sweep. Created in the
|
||||
// constructor body (not a field initializer) so `soma`/`opts` are set.
|
||||
this.gesture = Gesture.rotate({
|
||||
dom: this.soma.dom,
|
||||
ref: this.opts.ref,
|
||||
center: readableActive(() => 'element' as const),
|
||||
enabled: readableActive(() => !this.opts.disabled.current && !this.opts.readonly.current),
|
||||
onRotate: (_e, gestureState) => {
|
||||
if (!this.pickFired) {
|
||||
this.pickFired = true;
|
||||
this.valueOnPick = this.opts.value.current;
|
||||
void this.runtime.trigger('handle-pick'); // sequence 'post' → handler first
|
||||
}
|
||||
const start = this.opts.startAngle.current;
|
||||
const end = this.opts.endAngle.current;
|
||||
// angleAbsolute is 0°=up, clockwise+, [0, 360). Fold into (-180, 180]
|
||||
// to match a sweep declared symmetrically around up (e.g. -135..135).
|
||||
const signed =
|
||||
gestureState.angleAbsolute > 180
|
||||
? gestureState.angleAbsolute - 360
|
||||
: gestureState.angleAbsolute;
|
||||
const a = clamp(signed, start, end);
|
||||
const { min, max } = this.range();
|
||||
this.setValue(min + ((a - start) / (end - start)) * (max - min), false);
|
||||
},
|
||||
onRelease: () => {
|
||||
this.pickFired = false;
|
||||
void this.runtime.trigger('handle-drop');
|
||||
if (this.opts.value.current !== this.valueOnPick) {
|
||||
void this.runtime.trigger('commit-set');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- geometry -----------------------------------------------------------
|
||||
|
||||
/** 0..1 position of value inside [min, max]. */
|
||||
readonly progress = $derived.by(() => {
|
||||
const { min, max } = this.range();
|
||||
const span = max - min;
|
||||
return span === 0 ? 0 : (this.opts.value.current - min) / span;
|
||||
});
|
||||
|
||||
/** Current pointer angle in degrees within the declared sweep. */
|
||||
readonly angle = $derived.by(() => {
|
||||
const start = this.opts.startAngle.current;
|
||||
const end = this.opts.endAngle.current;
|
||||
return start + this.progress * (end - start);
|
||||
});
|
||||
|
||||
private range() {
|
||||
return { min: this.opts.min.current, max: this.opts.max.current };
|
||||
}
|
||||
|
||||
// ---- value mutation -----------------------------------------------------
|
||||
|
||||
setValue(next: number, commit: boolean) {
|
||||
const { min, max } = this.range();
|
||||
const snapped = clamp(snapToStep(next, min, this.opts.step.current), min, max);
|
||||
if (snapped !== this.opts.value.current) {
|
||||
this.opts.value.current = snapped;
|
||||
this.opts.onValueChange.current?.(snapped);
|
||||
}
|
||||
if (commit) void this.runtime.trigger('commit-set');
|
||||
}
|
||||
|
||||
stepBy(delta: number) {
|
||||
this.setValue(this.opts.value.current + delta, true);
|
||||
}
|
||||
|
||||
// Root container props (`data-knob` + id). Sub-part attrs live on their own
|
||||
// providers; syncAttrs applies the morfo-derived attrs to the ref directly.
|
||||
readonly props = $derived.by(() =>
|
||||
this.runtimePart.assert({ ...this.runtimePart.props } as const)
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Control ----------------------------------------------------------------
|
||||
|
||||
interface KnobControlOpts extends WithRefOpts {}
|
||||
|
||||
export class KnobControlProvider {
|
||||
static create(opts: KnobControlOpts) {
|
||||
return new KnobControlProvider(opts);
|
||||
}
|
||||
|
||||
readonly opts: KnobControlOpts;
|
||||
readonly provider: KnobProvider;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
|
||||
private constructor(opts: KnobControlOpts) {
|
||||
this.opts = opts;
|
||||
this.provider = KnobProvider.require();
|
||||
this.runtimePart = this.provider.runtime.part('control', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this
|
||||
// no syncAttrs: renderProps() path — we override aria-valuetext.
|
||||
});
|
||||
}
|
||||
|
||||
private onkeydown = (e: KeyboardEvent) => {
|
||||
const p = this.provider;
|
||||
// Guards at call-site: a disabled knob must not emit perceptual signals.
|
||||
if (p.opts.disabled.current || p.opts.readonly.current) return;
|
||||
const step = p.opts.step.current;
|
||||
const large = p.opts.largeStep.current;
|
||||
switch (e.key) {
|
||||
case KEYS.ARROW_UP:
|
||||
case KEYS.ARROW_RIGHT:
|
||||
e.preventDefault();
|
||||
return p.stepBy(step);
|
||||
case KEYS.ARROW_DOWN:
|
||||
case KEYS.ARROW_LEFT:
|
||||
e.preventDefault();
|
||||
return p.stepBy(-step);
|
||||
case 'PageUp':
|
||||
e.preventDefault();
|
||||
return p.stepBy(large);
|
||||
case 'PageDown':
|
||||
e.preventDefault();
|
||||
return p.stepBy(-large);
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
return p.setValue(p.opts.min.current, true);
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
return p.setValue(p.opts.max.current, true);
|
||||
}
|
||||
};
|
||||
|
||||
private ondblclick = () => {
|
||||
const p = this.provider;
|
||||
if (p.opts.disabled.current || p.opts.readonly.current) return;
|
||||
void p.runtime.trigger('commit-reset');
|
||||
};
|
||||
|
||||
/** Locale-formatted value for AT, via the soma format services. */
|
||||
private formatValue(): string {
|
||||
const p = this.provider;
|
||||
const v = p.opts.value.current;
|
||||
const fmt = p.opts.formatValue.current;
|
||||
if (fmt) return fmt(v);
|
||||
return p.soma.nums?.format(v) ?? String(v);
|
||||
}
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.runtimePart.assert({
|
||||
...this.runtimePart.renderProps(), // role=slider, aria-value*, data-*
|
||||
tabindex: this.provider.opts.disabled.current ? -1 : 0,
|
||||
'aria-valuetext': this.formatValue(), // soma owns the *value* (formatting)
|
||||
'data-dragging': boolToEmptyStrOrUndef(this.provider.dragging),
|
||||
// Provider-published data for the visual layer (A8: functional CSS).
|
||||
style: `--knob-progress: ${this.provider.progress}; --knob-angle: ${this.provider.angle}deg;`,
|
||||
// Gesture props (onpointerdown) + local handlers.
|
||||
...this.provider.gesture.props,
|
||||
onkeydown: this.onkeydown,
|
||||
ondblclick: this.ondblclick,
|
||||
// Required for pointer drag (A15).
|
||||
// touch-action lives here, not in eidos: functional, not visual.
|
||||
// (merged into style by mergeProps at the wrapper)
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Indicator --------------------------------------------------------------
|
||||
|
||||
interface KnobIndicatorOpts extends WithRefOpts {}
|
||||
|
||||
export class KnobIndicatorProvider {
|
||||
static create(opts: KnobIndicatorOpts) {
|
||||
return new KnobIndicatorProvider(opts);
|
||||
}
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly provider: KnobProvider;
|
||||
private constructor(opts: KnobIndicatorOpts) {
|
||||
this.provider = KnobProvider.require();
|
||||
// Decorative pointer/arc — positioned by --knob-angle from the provider.
|
||||
this.runtimePart = this.provider.runtime.part('indicator', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
syncAttrs: true
|
||||
});
|
||||
}
|
||||
readonly props = $derived.by(() =>
|
||||
this.runtimePart.assert({ ...this.runtimePart.props } as const)
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Label / ValueText / HiddenInput ------------------------------------------
|
||||
|
||||
interface KnobLabelOpts extends WithRefOpts {}
|
||||
|
||||
export class KnobLabelProvider {
|
||||
static create(opts: KnobLabelOpts) {
|
||||
return new KnobLabelProvider(opts);
|
||||
}
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly provider: KnobProvider;
|
||||
private constructor(opts: KnobLabelOpts) {
|
||||
this.provider = KnobProvider.require();
|
||||
// A30: one-shot id registration in the constructor — never $effect.
|
||||
this.provider.labelId.current = opts.id.current;
|
||||
this.runtimePart = this.provider.runtime.part('label', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
syncAttrs: true
|
||||
});
|
||||
}
|
||||
readonly props = $derived.by(() =>
|
||||
this.runtimePart.assert({
|
||||
...this.runtimePart.props,
|
||||
onclick: () => {
|
||||
// Label click focuses the control, native-label ergonomics.
|
||||
// TODO(api): focus via this.provider.soma.dom (imperative focus is
|
||||
// ActiveDom-managed per §3.7).
|
||||
}
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
|
||||
interface KnobValueTextOpts extends WithRefOpts {}
|
||||
|
||||
export class KnobValueTextProvider {
|
||||
static create(opts: KnobValueTextOpts) {
|
||||
return new KnobValueTextProvider(opts);
|
||||
}
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly provider: KnobProvider;
|
||||
private constructor(opts: KnobValueTextOpts) {
|
||||
this.provider = KnobProvider.require();
|
||||
this.runtimePart = this.provider.runtime.part('value-text', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
syncAttrs: true
|
||||
});
|
||||
}
|
||||
readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props } as const));
|
||||
}
|
||||
|
||||
interface KnobHiddenInputOpts extends WithRefOpts {}
|
||||
|
||||
export class KnobHiddenInputProvider {
|
||||
static create(opts: KnobHiddenInputOpts) {
|
||||
return new KnobHiddenInputProvider(opts);
|
||||
}
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly provider: KnobProvider;
|
||||
private constructor(opts: KnobHiddenInputOpts) {
|
||||
this.provider = KnobProvider.require();
|
||||
this.runtimePart = this.provider.runtime.part('hidden-input', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
syncAttrs: true
|
||||
});
|
||||
}
|
||||
readonly props = $derived.by(() =>
|
||||
this.runtimePart.assert({
|
||||
...this.runtimePart.props,
|
||||
type: 'hidden',
|
||||
name: this.provider.opts.name.current,
|
||||
value: String(this.provider.opts.value.current),
|
||||
disabled: this.provider.opts.disabled.current || undefined
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
// src/uix/soma/components/knob/types.ts
|
||||
//
|
||||
// Public props + canonical field shapes (defined ONCE here, referenced by
|
||||
// provider Opts via StateProps<> / ActiveProps<>).
|
||||
|
||||
import type { WithChild, Without, OnChangeFn } from '../../types';
|
||||
import type { PrimitiveDivAttributes } from '../../types';
|
||||
|
||||
export type KnobIntent = 'neutral' | 'affirm' | 'risk' | 'threat';
|
||||
|
||||
export type KnobStateFields = {
|
||||
/** Current value. Bindable. */
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type KnobActiveFields = {
|
||||
/** Lower bound. @default 0 */
|
||||
min: number;
|
||||
/** Upper bound. @default 100 */
|
||||
max: number;
|
||||
/** Step for arrow keys and value snapping. @default 1 */
|
||||
step: number;
|
||||
/** Step for PageUp / PageDown. @default 10 */
|
||||
largeStep: number;
|
||||
/** Value restored by double-click (commit-reset). @default min */
|
||||
defaultValue: number;
|
||||
/** Sweep start angle in degrees, 0 = up, clockwise. @default -135 */
|
||||
startAngle: number;
|
||||
/** Sweep end angle in degrees. @default 135 */
|
||||
endAngle: number;
|
||||
/** Disables interaction and focus. @default false */
|
||||
disabled: boolean;
|
||||
/** Prevents changes but keeps the control focusable. @default false */
|
||||
readonly: boolean;
|
||||
/** Marks the value as invalid (aria-invalid + data-invalid). @default false */
|
||||
invalid: boolean;
|
||||
/**
|
||||
* Evaluative load of commit-set emissions. A gain knob past 0dB may set
|
||||
* 'risk'; sema and eidos tint the commit accordingly. @default 'neutral'
|
||||
*/
|
||||
intent: KnobIntent;
|
||||
/** Form field name; when set, compose <Knob.HiddenInput>. */
|
||||
name: string | undefined;
|
||||
/** Custom aria-valuetext formatter. Defaults to locale number format. */
|
||||
formatValue: ((value: number) => string) | undefined;
|
||||
/** Fires on every value change (during drag and keyboard steps). */
|
||||
onValueChange: OnChangeFn<number> | undefined;
|
||||
/** Fires when a value is committed (release / keyboard step / reset). */
|
||||
onValueCommit: OnChangeFn<number> | undefined;
|
||||
};
|
||||
|
||||
export type KnobProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** Current value. Bindable. @default min */
|
||||
value?: number;
|
||||
/** Lower bound. @default 0 */
|
||||
min?: number;
|
||||
/** Upper bound. @default 100 */
|
||||
max?: number;
|
||||
/** Arrow-key step and snapping grid. @default 1 */
|
||||
step?: number;
|
||||
/** PageUp/PageDown step. @default 10 */
|
||||
largeStep?: number;
|
||||
/** Double-click reset target. @default min */
|
||||
defaultValue?: number;
|
||||
/** Sweep start angle (deg, 0 = up, clockwise). @default -135 */
|
||||
startAngle?: number;
|
||||
/** Sweep end angle (deg). @default 135 */
|
||||
endAngle?: number;
|
||||
/** @default false */
|
||||
disabled?: boolean;
|
||||
/** Focusable but not editable. @default false */
|
||||
readonly?: boolean;
|
||||
/** @default false */
|
||||
invalid?: boolean;
|
||||
/** Evaluative load of commits. @default 'neutral' */
|
||||
intent?: KnobIntent;
|
||||
/** Form field name; compose <Knob.HiddenInput> alongside. */
|
||||
name?: string;
|
||||
/** Custom aria-valuetext formatter. */
|
||||
formatValue?: (value: number) => string;
|
||||
/** Fires on every value change. */
|
||||
onValueChange?: OnChangeFn<number>;
|
||||
/** Fires on commit (release / keyboard step / reset). */
|
||||
onValueCommit?: OnChangeFn<number>;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type KnobControlProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type KnobIndicatorProps = KnobControlProps;
|
||||
export type KnobLabelProps = KnobControlProps;
|
||||
export type KnobValueTextProps = KnobControlProps;
|
||||
export type KnobHiddenInputProps = { id?: string };
|
||||
Loading…
Reference in new issue