@ambientcss/components 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/dist/index.cjs +1540 -391
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +749 -52
- package/dist/index.d.ts +749 -52
- package/dist/index.js +1501 -391
- package/dist/index.js.map +1 -1
- package/dist/styles.css +998 -291
- package/package.json +2 -2
- package/src/components/AmbientButton.tsx +23 -27
- package/src/components/AmbientFader.tsx +29 -115
- package/src/components/AmbientKnob.tsx +58 -220
- package/src/components/AmbientPanel.tsx +8 -2
- package/src/components/AmbientProvider.tsx +3 -0
- package/src/components/AmbientSelect.tsx +40 -0
- package/src/components/AmbientSlider.tsx +28 -113
- package/src/components/AmbientSwitch.tsx +58 -58
- package/src/controls/AmbientBank.tsx +138 -0
- package/src/controls/AmbientLatch.tsx +78 -0
- package/src/controls/AmbientPress.tsx +74 -0
- package/src/controls/AmbientRotary.tsx +94 -0
- package/src/controls/AmbientTravel.tsx +83 -0
- package/src/core/context.tsx +46 -0
- package/src/core/controllable.ts +33 -0
- package/src/core/dev.ts +15 -0
- package/src/core/frames.tsx +63 -0
- package/src/core/kit.tsx +107 -0
- package/src/core/material.ts +27 -0
- package/src/core/numeric.ts +88 -0
- package/src/core/types.ts +116 -0
- package/src/core/useBank.ts +163 -0
- package/src/core/useLatch.ts +54 -0
- package/src/core/usePress.ts +120 -0
- package/src/core/useRotary.ts +253 -0
- package/src/core/useTravel.ts +141 -0
- package/src/index.ts +122 -2
- package/src/kits/console.tsx +80 -0
- package/src/kits/grounded.tsx +113 -0
- package/src/parts/bank.tsx +32 -0
- package/src/parts/console.tsx +99 -0
- package/src/parts/knob.tsx +203 -0
- package/src/parts/latch.tsx +33 -0
- package/src/parts/press.tsx +56 -0
- package/src/parts/travel.tsx +70 -0
- package/src/styles.css +998 -291
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
import type { KeyboardEvent, ReactNode } from "react";
|
|
3
|
+
import { useControllableValue } from "./controllable";
|
|
4
|
+
|
|
5
|
+
export type BankOption = {
|
|
6
|
+
value: string;
|
|
7
|
+
/** Key legend — a numeral in the referent, but any node works. */
|
|
8
|
+
label?: ReactNode | undefined;
|
|
9
|
+
/** Accessible name, and the hover title. Needed whenever the legend is a
|
|
10
|
+
* glyph or an icon: a key reading "*" has no name otherwise. */
|
|
11
|
+
ariaLabel?: string | undefined;
|
|
12
|
+
/** Overrides the bank's lamp colour for this key only. */
|
|
13
|
+
color?: string | undefined;
|
|
14
|
+
disabled?: boolean | undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type BankOrientation = "vertical" | "horizontal";
|
|
18
|
+
|
|
19
|
+
export type UseBankOptions = {
|
|
20
|
+
options: BankOption[];
|
|
21
|
+
value?: string | string[] | undefined;
|
|
22
|
+
defaultValue?: string | string[] | undefined;
|
|
23
|
+
onChange?: ((value: string | string[]) => void) | undefined;
|
|
24
|
+
/** Let more than one lamp be lit at a time. */
|
|
25
|
+
multiple?: boolean | undefined;
|
|
26
|
+
orientation?: BankOrientation | undefined;
|
|
27
|
+
disabled?: boolean | undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function toArray(value: string | string[] | undefined): string[] {
|
|
31
|
+
if (value === undefined) return [];
|
|
32
|
+
return Array.isArray(value) ? value : [value];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** N presses sharing a selection model.
|
|
36
|
+
*
|
|
37
|
+
* The roving tabindex, the radiogroup-versus-checkbox-group keyboard
|
|
38
|
+
* difference and the selection-follows-focus rule are the reason this
|
|
39
|
+
* mechanism exists: they are the part nobody should have to re-derive in
|
|
40
|
+
* order to change what a key looks like. */
|
|
41
|
+
export function useBank(options: UseBankOptions) {
|
|
42
|
+
const { options: items, multiple = false, orientation = "vertical", disabled = false } = options;
|
|
43
|
+
|
|
44
|
+
const [raw, setRaw] = useControllableValue<string | string[]>(
|
|
45
|
+
options.value,
|
|
46
|
+
options.defaultValue ?? (multiple ? [] : ""),
|
|
47
|
+
options.onChange
|
|
48
|
+
);
|
|
49
|
+
const selected = toArray(raw);
|
|
50
|
+
const keyRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
51
|
+
|
|
52
|
+
const commit = useCallback(
|
|
53
|
+
(next: string[]) => setRaw(multiple ? next : (next[0] ?? "")),
|
|
54
|
+
[setRaw, multiple]
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const select = useCallback(
|
|
58
|
+
(option: BankOption | undefined) => {
|
|
59
|
+
if (!option || option.disabled || disabled) return;
|
|
60
|
+
if (!multiple) return commit([option.value]);
|
|
61
|
+
commit(
|
|
62
|
+
selected.includes(option.value)
|
|
63
|
+
? selected.filter((v) => v !== option.value)
|
|
64
|
+
: [...selected, option.value]
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
[commit, multiple, selected, disabled]
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const enabled = items.filter((o) => !o.disabled);
|
|
71
|
+
/* Roving tabindex: exactly one key is tab-reachable. The lit one owns the
|
|
72
|
+
stop, so Tab lands where the eye already is; with nothing lit (or in
|
|
73
|
+
multiple mode, where every lamp is independent) the first enabled key
|
|
74
|
+
takes it. */
|
|
75
|
+
const litIndex = items.findIndex((o) => selected.includes(o.value) && !o.disabled);
|
|
76
|
+
const firstEnabled = items.findIndex((o) => !o.disabled);
|
|
77
|
+
const tabStop = multiple ? -1 : litIndex >= 0 ? litIndex : firstEnabled;
|
|
78
|
+
|
|
79
|
+
const focusAt = (index: number) => keyRefs.current[index]?.focus();
|
|
80
|
+
|
|
81
|
+
const step = (from: number, delta: number) => {
|
|
82
|
+
if (enabled.length === 0) return;
|
|
83
|
+
let i = from;
|
|
84
|
+
for (let guard = 0; guard < items.length; guard += 1) {
|
|
85
|
+
i = (i + delta + items.length) % items.length;
|
|
86
|
+
if (!items[i]?.disabled) break;
|
|
87
|
+
}
|
|
88
|
+
focusAt(i);
|
|
89
|
+
/* A radio group moves selection with focus (WAI-ARIA radiogroup
|
|
90
|
+
pattern); a checkbox group only moves focus, since each lamp toggles
|
|
91
|
+
on its own. */
|
|
92
|
+
if (!multiple) select(items[i]);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const edge = (which: "first" | "last") => {
|
|
96
|
+
const i =
|
|
97
|
+
which === "first" ? firstEnabled : items.map((o) => !o.disabled).lastIndexOf(true);
|
|
98
|
+
if (i < 0) return;
|
|
99
|
+
focusAt(i);
|
|
100
|
+
if (!multiple) select(items[i]);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
|
104
|
+
const back = orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
|
|
105
|
+
const forward = orientation === "vertical" ? "ArrowDown" : "ArrowRight";
|
|
106
|
+
switch (event.key) {
|
|
107
|
+
case back:
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
step(index, -1);
|
|
110
|
+
break;
|
|
111
|
+
case forward:
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
step(index, 1);
|
|
114
|
+
break;
|
|
115
|
+
case "Home":
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
edge("first");
|
|
118
|
+
break;
|
|
119
|
+
case "End":
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
edge("last");
|
|
122
|
+
break;
|
|
123
|
+
case " ":
|
|
124
|
+
case "Enter":
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
select(items[index]);
|
|
127
|
+
break;
|
|
128
|
+
default:
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const rootProps = {
|
|
134
|
+
role: multiple ? ("group" as const) : ("radiogroup" as const),
|
|
135
|
+
"aria-orientation": multiple ? undefined : orientation,
|
|
136
|
+
"aria-disabled": disabled || undefined,
|
|
137
|
+
"data-orientation": orientation,
|
|
138
|
+
"data-disabled": disabled ? "" : undefined
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Props for the key at `index`, including its own state channel. */
|
|
142
|
+
const keyProps = (option: BankOption, index: number) => {
|
|
143
|
+
const on = selected.includes(option.value);
|
|
144
|
+
return {
|
|
145
|
+
key: option.value,
|
|
146
|
+
type: "button" as const,
|
|
147
|
+
ref: (node: HTMLButtonElement | null) => {
|
|
148
|
+
keyRefs.current[index] = node;
|
|
149
|
+
},
|
|
150
|
+
role: multiple ? ("checkbox" as const) : ("radio" as const),
|
|
151
|
+
"aria-checked": on,
|
|
152
|
+
"aria-label": option.ariaLabel,
|
|
153
|
+
title: option.ariaLabel,
|
|
154
|
+
disabled: option.disabled || disabled,
|
|
155
|
+
tabIndex: multiple ? 0 : index === tabStop ? 0 : -1,
|
|
156
|
+
"data-on": on ? "" : undefined,
|
|
157
|
+
onClick: () => select(option),
|
|
158
|
+
onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => onKeyDown(event, index)
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return { selected, select, rootProps, keyProps };
|
|
163
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { CSSProperties } from "react";
|
|
3
|
+
import { useControllableValue } from "./controllable";
|
|
4
|
+
import { stateData, stateStyle } from "./types";
|
|
5
|
+
import type { ControlState } from "./types";
|
|
6
|
+
|
|
7
|
+
export type UseLatchOptions = {
|
|
8
|
+
value?: boolean | undefined;
|
|
9
|
+
defaultValue?: boolean | undefined;
|
|
10
|
+
onChange?: ((next: boolean) => void) | undefined;
|
|
11
|
+
disabled?: boolean | undefined;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** A two-position travel. Kept distinct from Press because its actuator
|
|
15
|
+
* genuinely slides rather than sinking, and because its ARIA is
|
|
16
|
+
* `role="switch"` — a switch is a state, not an action. */
|
|
17
|
+
export function useLatch(options: UseLatchOptions) {
|
|
18
|
+
const { disabled = false } = options;
|
|
19
|
+
const [on, setOn] = useControllableValue(
|
|
20
|
+
options.value,
|
|
21
|
+
options.defaultValue ?? false,
|
|
22
|
+
options.onChange
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const state: ControlState = useMemo(
|
|
26
|
+
() => ({
|
|
27
|
+
value: on ? 1 : 0,
|
|
28
|
+
min: 0,
|
|
29
|
+
max: 1,
|
|
30
|
+
percent: on ? 1 : 0,
|
|
31
|
+
angle: 0,
|
|
32
|
+
travelStart: 0,
|
|
33
|
+
travelSweep: 0,
|
|
34
|
+
detents: 2,
|
|
35
|
+
dragging: false,
|
|
36
|
+
disabled,
|
|
37
|
+
atMin: !on,
|
|
38
|
+
atMax: on
|
|
39
|
+
}),
|
|
40
|
+
[on, disabled]
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const rootProps = {
|
|
44
|
+
type: "button" as const,
|
|
45
|
+
role: "switch" as const,
|
|
46
|
+
"aria-checked": on,
|
|
47
|
+
disabled,
|
|
48
|
+
style: stateStyle(state) as CSSProperties,
|
|
49
|
+
...stateData(state),
|
|
50
|
+
onClick: () => setOn(!on)
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return { state, rootProps, on, setOn };
|
|
54
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { CSSProperties, PointerEvent } from "react";
|
|
3
|
+
import { useControllableValue } from "./controllable";
|
|
4
|
+
import { stateData, stateStyle } from "./types";
|
|
5
|
+
import type { ControlState } from "./types";
|
|
6
|
+
|
|
7
|
+
/** A transport key, a latching mute and an auto-repeating nudge button are
|
|
8
|
+
* the same object with three state machines and identical paint. */
|
|
9
|
+
export type PressMode = "momentary" | "toggle" | "repeat";
|
|
10
|
+
|
|
11
|
+
export type UsePressOptions = {
|
|
12
|
+
mode?: PressMode | undefined;
|
|
13
|
+
/** `toggle` mode only: the pressed-in state. */
|
|
14
|
+
value?: boolean | undefined;
|
|
15
|
+
defaultValue?: boolean | undefined;
|
|
16
|
+
onChange?: ((next: boolean) => void) | undefined;
|
|
17
|
+
/** Fires once per activation, and repeatedly in `repeat` mode. */
|
|
18
|
+
onPress?: (() => void) | undefined;
|
|
19
|
+
/** `repeat` mode: delay before repeating starts, then its interval. */
|
|
20
|
+
repeatDelay?: number | undefined;
|
|
21
|
+
repeatInterval?: number | undefined;
|
|
22
|
+
disabled?: boolean | undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function usePress(options: UsePressOptions) {
|
|
26
|
+
const {
|
|
27
|
+
mode = "momentary",
|
|
28
|
+
repeatDelay = 400,
|
|
29
|
+
repeatInterval = 60,
|
|
30
|
+
disabled = false
|
|
31
|
+
} = options;
|
|
32
|
+
|
|
33
|
+
const [on, setOn] = useControllableValue(
|
|
34
|
+
options.value,
|
|
35
|
+
options.defaultValue ?? false,
|
|
36
|
+
options.onChange
|
|
37
|
+
);
|
|
38
|
+
const [held, setHeld] = useState(false);
|
|
39
|
+
|
|
40
|
+
const onPressRef = useRef(options.onPress);
|
|
41
|
+
onPressRef.current = options.onPress;
|
|
42
|
+
const timers = useRef<{ delay?: ReturnType<typeof setTimeout>; tick?: ReturnType<typeof setInterval> }>(
|
|
43
|
+
{}
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const stopRepeat = useCallback(() => {
|
|
47
|
+
if (timers.current.delay) clearTimeout(timers.current.delay);
|
|
48
|
+
if (timers.current.tick) clearInterval(timers.current.tick);
|
|
49
|
+
timers.current = {};
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
/* A held key that unmounts must not keep firing. */
|
|
53
|
+
useEffect(() => stopRepeat, [stopRepeat]);
|
|
54
|
+
|
|
55
|
+
const activate = useCallback(() => {
|
|
56
|
+
if (mode === "toggle") setOn(!on);
|
|
57
|
+
onPressRef.current?.();
|
|
58
|
+
}, [mode, on, setOn]);
|
|
59
|
+
|
|
60
|
+
const pressed = mode === "toggle" ? on : held;
|
|
61
|
+
|
|
62
|
+
const state: ControlState = useMemo(
|
|
63
|
+
() => ({
|
|
64
|
+
value: pressed ? 1 : 0,
|
|
65
|
+
min: 0,
|
|
66
|
+
max: 1,
|
|
67
|
+
percent: pressed ? 1 : 0,
|
|
68
|
+
angle: 0,
|
|
69
|
+
travelStart: 0,
|
|
70
|
+
travelSweep: 0,
|
|
71
|
+
detents: 2,
|
|
72
|
+
dragging: held,
|
|
73
|
+
disabled,
|
|
74
|
+
atMin: !pressed,
|
|
75
|
+
atMax: pressed
|
|
76
|
+
}),
|
|
77
|
+
[pressed, held, disabled]
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const rootProps = {
|
|
81
|
+
type: "button" as const,
|
|
82
|
+
disabled,
|
|
83
|
+
"aria-pressed": mode === "toggle" ? on : undefined,
|
|
84
|
+
style: stateStyle(state) as CSSProperties,
|
|
85
|
+
"data-pressed": pressed ? "" : undefined,
|
|
86
|
+
"data-mode": mode,
|
|
87
|
+
...stateData(state),
|
|
88
|
+
onPointerDown: (event: PointerEvent<HTMLButtonElement>) => {
|
|
89
|
+
if (disabled || event.button !== 0) return;
|
|
90
|
+
setHeld(true);
|
|
91
|
+
if (mode !== "repeat") return;
|
|
92
|
+
/* Repeat fires immediately, then accelerates into its interval —
|
|
93
|
+
the same shape a keyboard's own auto-repeat has, so a held nudge
|
|
94
|
+
button does not feel like a different kind of control. */
|
|
95
|
+
onPressRef.current?.();
|
|
96
|
+
timers.current.delay = setTimeout(() => {
|
|
97
|
+
timers.current.tick = setInterval(() => onPressRef.current?.(), repeatInterval);
|
|
98
|
+
}, repeatDelay);
|
|
99
|
+
},
|
|
100
|
+
onPointerUp: () => {
|
|
101
|
+
setHeld(false);
|
|
102
|
+
stopRepeat();
|
|
103
|
+
},
|
|
104
|
+
onPointerCancel: () => {
|
|
105
|
+
setHeld(false);
|
|
106
|
+
stopRepeat();
|
|
107
|
+
},
|
|
108
|
+
onPointerLeave: () => {
|
|
109
|
+
setHeld(false);
|
|
110
|
+
stopRepeat();
|
|
111
|
+
},
|
|
112
|
+
onClick: () => {
|
|
113
|
+
/* Repeat already fired on the press; firing again on click would
|
|
114
|
+
double every tap. */
|
|
115
|
+
if (mode !== "repeat") activate();
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return { state, rootProps, pressed, on, setOn };
|
|
120
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
|
|
3
|
+
import { useControllableValue } from "./controllable";
|
|
4
|
+
import { clamp, commit, denormalise, normalise, valueKeyHandler } from "./numeric";
|
|
5
|
+
import { stateData, stateStyle } from "./types";
|
|
6
|
+
import { isDev } from "./dev";
|
|
7
|
+
import type { ControlState } from "./types";
|
|
8
|
+
|
|
9
|
+
/** How pointer movement becomes value.
|
|
10
|
+
*
|
|
11
|
+
* These are three different mappings, not one with a parameter, and the
|
|
12
|
+
* library was ambiguous about which it implemented: the code did `angle`
|
|
13
|
+
* while knob.md documented `drag`. Naming it makes that drift impossible.
|
|
14
|
+
*
|
|
15
|
+
* - `drag` pointer DISTANCE -> value delta. The v3 default: it is what the
|
|
16
|
+
* docs always described, what most audio software does, and the
|
|
17
|
+
* only one that behaves on touch, where a finger covers the knob.
|
|
18
|
+
* - `angle` pointer POSITION -> absolute angle -> value. Needs a dead zone,
|
|
19
|
+
* and needs a sweep under a full turn to be unambiguous.
|
|
20
|
+
* - `delta` accumulated angular change. The endless-encoder mapping: no
|
|
21
|
+
* ends, no dead zone, and the value may wrap independently of
|
|
22
|
+
* where the pointer happens to be. */
|
|
23
|
+
export type RotaryInput = "drag" | "angle" | "delta";
|
|
24
|
+
|
|
25
|
+
/** The pot's travel in degrees clockwise from 12 o'clock. A bare number is
|
|
26
|
+
* a sweep centred on 12 o'clock, so `270` is the default 8-to-4 pot. */
|
|
27
|
+
export type RotaryTravel = number | { start: number; sweep: number };
|
|
28
|
+
|
|
29
|
+
export type UseRotaryOptions = {
|
|
30
|
+
value?: number | undefined;
|
|
31
|
+
defaultValue?: number | undefined;
|
|
32
|
+
min?: number | undefined;
|
|
33
|
+
max?: number | undefined;
|
|
34
|
+
/** Quantises the VALUE. `0` leaves it continuous. */
|
|
35
|
+
step?: number | undefined;
|
|
36
|
+
/** Quantises the TRAVEL: rest positions along the arc. Defaults to the
|
|
37
|
+
* step grid, but the two are independent — an endless encoder can have
|
|
38
|
+
* 24 detents per turn while its value stays continuous. */
|
|
39
|
+
detents?: number | undefined;
|
|
40
|
+
travel?: RotaryTravel | undefined;
|
|
41
|
+
input?: RotaryInput | undefined;
|
|
42
|
+
/** Pixels of drag for one full range, in `drag` mode. */
|
|
43
|
+
dragDistance?: number | undefined;
|
|
44
|
+
/** `delta` mode only: run past the ends and come round again. */
|
|
45
|
+
wrap?: boolean | undefined;
|
|
46
|
+
disabled?: boolean | undefined;
|
|
47
|
+
onChange?: ((next: number) => void) | undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const DEFAULT_TRAVEL = { start: -135, sweep: 270 };
|
|
51
|
+
|
|
52
|
+
/** Fold an angle into (-180, 180]. */
|
|
53
|
+
function wrapDeg(deg: number): number {
|
|
54
|
+
return ((((deg + 180) % 360) + 360) % 360) - 180;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveTravel(travel: RotaryTravel | undefined) {
|
|
58
|
+
if (travel === undefined) return DEFAULT_TRAVEL;
|
|
59
|
+
if (typeof travel === "number") return { start: -travel / 2, sweep: travel };
|
|
60
|
+
return travel;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let warnedFullTurn = false;
|
|
64
|
+
|
|
65
|
+
/** Pointer capture, but survivable.
|
|
66
|
+
*
|
|
67
|
+
* `setPointerCapture` throws NotFoundError for a pointer id the browser
|
|
68
|
+
* does not currently have down, and is missing outright in jsdom. Neither
|
|
69
|
+
* happens with a real finger, but both happen constantly in tests — and an
|
|
70
|
+
* exception here would abort the handler before the drag ever starts,
|
|
71
|
+
* making the control look broken rather than untestable. */
|
|
72
|
+
export function capturePointer(target: Element, pointerId: number): void {
|
|
73
|
+
try {
|
|
74
|
+
target.setPointerCapture?.(pointerId);
|
|
75
|
+
} catch {
|
|
76
|
+
/* Without capture the drag still tracks; it just stops at the edge of
|
|
77
|
+
the element instead of following the pointer off it. */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function useRotary(options: UseRotaryOptions) {
|
|
82
|
+
const {
|
|
83
|
+
min = 0,
|
|
84
|
+
max = 100,
|
|
85
|
+
step = 1,
|
|
86
|
+
dragDistance = 200,
|
|
87
|
+
wrap = false,
|
|
88
|
+
disabled = false
|
|
89
|
+
} = options;
|
|
90
|
+
|
|
91
|
+
const { start, sweep } = resolveTravel(options.travel);
|
|
92
|
+
|
|
93
|
+
/* A full turn cannot be read as an absolute angle: 0 and max land on the
|
|
94
|
+
same screen position, and the dead-zone clamp has no end to snap to.
|
|
95
|
+
Fall back rather than misbehave silently. */
|
|
96
|
+
let input = options.input ?? "drag";
|
|
97
|
+
if (input === "angle" && Math.abs(sweep) >= 360) {
|
|
98
|
+
if (isDev && !warnedFullTurn) {
|
|
99
|
+
warnedFullTurn = true;
|
|
100
|
+
console.warn(
|
|
101
|
+
`[@ambientcss/components] input="angle" needs a sweep under a full turn ` +
|
|
102
|
+
`(got ${sweep}deg): an absolute angle is ambiguous at 360deg, where the ` +
|
|
103
|
+
`first and last values share a screen position. Falling back to "drag".`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
input = "drag";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const [value, setValue] = useControllableValue(
|
|
110
|
+
options.value,
|
|
111
|
+
options.defaultValue ?? min,
|
|
112
|
+
options.onChange
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
/* `dragging` is state because the root publishes it as data-dragging, and
|
|
116
|
+
a REF because the move handler has to gate on it. Reading the state in
|
|
117
|
+
the handler drops every move that fires before React re-renders after
|
|
118
|
+
the press — which is most of them on a fast drag. */
|
|
119
|
+
const [dragging, setDragging] = useState(false);
|
|
120
|
+
const draggingRef = useRef(false);
|
|
121
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
122
|
+
/* Drag bookkeeping. Deltas are accumulated rather than measured from the
|
|
123
|
+
press point so that toggling the fine-mode modifier mid-drag changes
|
|
124
|
+
the gearing from here on instead of jumping the value. */
|
|
125
|
+
const drag = useRef({ value: 0, clientY: 0, angle: 0, accum: 0 });
|
|
126
|
+
|
|
127
|
+
const range = max - min;
|
|
128
|
+
const keyStep = step > 0 ? step : range / 100 || 1;
|
|
129
|
+
const percent = clamp(normalise(value, min, max), 0, 1);
|
|
130
|
+
const angle = start + percent * sweep;
|
|
131
|
+
|
|
132
|
+
const set = useCallback(
|
|
133
|
+
(next: number) => setValue(commit(next, min, max, step)),
|
|
134
|
+
[setValue, min, max, step]
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
/** Pointer position as an angle in degrees clockwise from 12 o'clock. */
|
|
138
|
+
const pointerAngle = (event: PointerEvent<HTMLElement>): number | null => {
|
|
139
|
+
const rect = rootRef.current?.getBoundingClientRect();
|
|
140
|
+
if (!rect) return null;
|
|
141
|
+
const dx = event.clientX - (rect.left + rect.width / 2);
|
|
142
|
+
const dy = event.clientY - (rect.top + rect.height / 2);
|
|
143
|
+
// atan2 in screen coords reads 0 at 3 o'clock and grows clockwise; +90
|
|
144
|
+
// rotates the origin to 12 o'clock.
|
|
145
|
+
return Math.atan2(dy, dx) * (180 / Math.PI) + 90;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const fromAngle = (event: PointerEvent<HTMLElement>) => {
|
|
149
|
+
const raw = pointerAngle(event);
|
|
150
|
+
if (raw === null) return;
|
|
151
|
+
/* Fold the reading into the half-turn either side of the sweep's own
|
|
152
|
+
midpoint, so a sweep whose end passes 180deg stays reachable. */
|
|
153
|
+
const mid = start + sweep / 2;
|
|
154
|
+
let a = mid + wrapDeg(raw - mid);
|
|
155
|
+
const end = start + sweep;
|
|
156
|
+
if (a < Math.min(start, end) || a > Math.max(start, end)) {
|
|
157
|
+
/* In the dead zone: hold at whichever end is angularly nearer. The
|
|
158
|
+
pre-v3 code chose by which half the value was in, which threw the
|
|
159
|
+
knob to the far end whenever a drag crossed the gap. */
|
|
160
|
+
const toStart = Math.abs(wrapDeg(a - start));
|
|
161
|
+
const toEnd = Math.abs(wrapDeg(a - end));
|
|
162
|
+
a = toStart <= toEnd ? start : end;
|
|
163
|
+
}
|
|
164
|
+
set(denormalise((a - start) / (sweep || 1), min, max));
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const fromDrag = (event: PointerEvent<HTMLElement>) => {
|
|
168
|
+
const dy = drag.current.clientY - event.clientY;
|
|
169
|
+
drag.current.clientY = event.clientY;
|
|
170
|
+
drag.current.accum += dy * (event.shiftKey ? 0.25 : 1);
|
|
171
|
+
set(drag.current.value + (drag.current.accum / dragDistance) * range);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const fromDelta = (event: PointerEvent<HTMLElement>) => {
|
|
175
|
+
const raw = pointerAngle(event);
|
|
176
|
+
if (raw === null) return;
|
|
177
|
+
drag.current.accum += wrapDeg(raw - drag.current.angle);
|
|
178
|
+
drag.current.angle = raw;
|
|
179
|
+
const next = drag.current.value + (drag.current.accum / (sweep || 360)) * range;
|
|
180
|
+
set(wrap && range > 0 ? min + (((next - min) % range) + range) % range : next);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const track = (event: PointerEvent<HTMLElement>) => {
|
|
184
|
+
if (input === "angle") fromAngle(event);
|
|
185
|
+
else if (input === "delta") fromDelta(event);
|
|
186
|
+
else fromDrag(event);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const state: ControlState = useMemo(
|
|
190
|
+
() => ({
|
|
191
|
+
value,
|
|
192
|
+
min,
|
|
193
|
+
max,
|
|
194
|
+
percent,
|
|
195
|
+
angle,
|
|
196
|
+
travelStart: start,
|
|
197
|
+
travelSweep: sweep,
|
|
198
|
+
detents: options.detents ?? (step > 0 && range > 0 ? Math.round(range / step) : 0),
|
|
199
|
+
dragging,
|
|
200
|
+
disabled,
|
|
201
|
+
atMin: value <= min,
|
|
202
|
+
atMax: value >= max
|
|
203
|
+
}),
|
|
204
|
+
[value, min, max, percent, angle, start, sweep, options.detents, step, range, dragging, disabled]
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const onKeyDown = valueKeyHandler({ value, min, max, step: keyStep, disabled, onChange: setValue });
|
|
208
|
+
|
|
209
|
+
const rootProps = {
|
|
210
|
+
ref: rootRef,
|
|
211
|
+
role: "slider" as const,
|
|
212
|
+
"aria-valuemin": min,
|
|
213
|
+
"aria-valuemax": max,
|
|
214
|
+
"aria-valuenow": value,
|
|
215
|
+
"aria-orientation": "vertical" as const,
|
|
216
|
+
"aria-disabled": disabled || undefined,
|
|
217
|
+
tabIndex: disabled ? -1 : 0,
|
|
218
|
+
style: stateStyle(state) as CSSProperties,
|
|
219
|
+
...stateData(state),
|
|
220
|
+
onPointerDown: (event: PointerEvent<HTMLDivElement>) => {
|
|
221
|
+
if (disabled || event.button !== 0) return;
|
|
222
|
+
capturePointer(event.currentTarget, event.pointerId);
|
|
223
|
+
draggingRef.current = true;
|
|
224
|
+
setDragging(true);
|
|
225
|
+
drag.current = {
|
|
226
|
+
value,
|
|
227
|
+
clientY: event.clientY,
|
|
228
|
+
angle: pointerAngle(event) ?? 0,
|
|
229
|
+
accum: 0
|
|
230
|
+
};
|
|
231
|
+
/* Only the absolute mapping jumps to the press point; the relative
|
|
232
|
+
ones would lurch, which is the whole reason to prefer them. */
|
|
233
|
+
if (input === "angle") fromAngle(event);
|
|
234
|
+
},
|
|
235
|
+
onPointerMove: (event: PointerEvent<HTMLDivElement>) => {
|
|
236
|
+
if (!draggingRef.current || disabled) return;
|
|
237
|
+
track(event);
|
|
238
|
+
},
|
|
239
|
+
onPointerUp: () => {
|
|
240
|
+
draggingRef.current = false;
|
|
241
|
+
setDragging(false);
|
|
242
|
+
},
|
|
243
|
+
onPointerCancel: () => {
|
|
244
|
+
draggingRef.current = false;
|
|
245
|
+
setDragging(false);
|
|
246
|
+
},
|
|
247
|
+
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
|
|
248
|
+
onKeyDown(event);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
return { state, rootProps, setValue: set };
|
|
253
|
+
}
|