@ambientcss/components 2.0.1 → 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 +1563 -384
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +763 -48
- package/dist/index.d.ts +763 -48
- package/dist/index.js +1524 -385
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1102 -256
- package/package.json +2 -2
- package/src/components/AmbientButton.tsx +25 -25
- package/src/components/AmbientFader.tsx +33 -115
- package/src/components/AmbientKnob.tsx +64 -222
- package/src/components/AmbientPanel.tsx +8 -2
- package/src/components/AmbientProvider.tsx +3 -0
- package/src/components/AmbientRack.tsx +32 -0
- package/src/components/AmbientSelect.tsx +40 -0
- package/src/components/AmbientSlider.tsx +32 -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 +127 -4
- 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 +1102 -256
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useId, useRef } from "react";
|
|
2
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { ControlStateProvider } from "../core/context";
|
|
5
|
+
import { Frames, useDevPartCheck } from "../core/frames";
|
|
6
|
+
import { sizeProps } from "../core/types";
|
|
7
|
+
import type { ControlAnimate, ControlParts, ControlSize } from "../core/types";
|
|
8
|
+
import { useTravel } from "../core/useTravel";
|
|
9
|
+
import type { UseTravelOptions } from "../core/useTravel";
|
|
10
|
+
|
|
11
|
+
export type AmbientTravelProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue"> &
|
|
12
|
+
UseTravelOptions & {
|
|
13
|
+
parts?: ControlParts | undefined;
|
|
14
|
+
size?: ControlSize | undefined;
|
|
15
|
+
animate?: ControlAnimate | undefined;
|
|
16
|
+
label?: ReactNode | undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** A value on a straight track, with no appearance of its own. */
|
|
20
|
+
export function AmbientTravel({
|
|
21
|
+
parts,
|
|
22
|
+
size,
|
|
23
|
+
animate = "auto",
|
|
24
|
+
label,
|
|
25
|
+
className,
|
|
26
|
+
value,
|
|
27
|
+
defaultValue,
|
|
28
|
+
min,
|
|
29
|
+
max,
|
|
30
|
+
step,
|
|
31
|
+
detents,
|
|
32
|
+
orientation = "horizontal",
|
|
33
|
+
invert,
|
|
34
|
+
disabled,
|
|
35
|
+
onChange,
|
|
36
|
+
...rest
|
|
37
|
+
}: AmbientTravelProps) {
|
|
38
|
+
const labelId = useId();
|
|
39
|
+
const stackRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
const { state, rootProps } = useTravel({
|
|
41
|
+
value,
|
|
42
|
+
defaultValue,
|
|
43
|
+
min,
|
|
44
|
+
max,
|
|
45
|
+
step,
|
|
46
|
+
detents,
|
|
47
|
+
orientation,
|
|
48
|
+
invert,
|
|
49
|
+
disabled,
|
|
50
|
+
onChange
|
|
51
|
+
});
|
|
52
|
+
useDevPartCheck(stackRef, "AmbientTravel");
|
|
53
|
+
|
|
54
|
+
const sized = sizeProps("travel", size);
|
|
55
|
+
const { style: restStyle, ...restProps } = rest;
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="ambx-stack" ref={stackRef}>
|
|
59
|
+
<div
|
|
60
|
+
{...restProps}
|
|
61
|
+
{...rootProps}
|
|
62
|
+
aria-labelledby={label ? labelId : rest["aria-labelledby"]}
|
|
63
|
+
data-animate={animate}
|
|
64
|
+
className={cn(
|
|
65
|
+
"ambx-control ambx-travel",
|
|
66
|
+
`ambx-travel-${orientation}`,
|
|
67
|
+
sized.className,
|
|
68
|
+
className
|
|
69
|
+
)}
|
|
70
|
+
style={{ ...rootProps.style, ...sized.style, ...restStyle }}
|
|
71
|
+
>
|
|
72
|
+
<ControlStateProvider value={state}>
|
|
73
|
+
<Frames parts={parts} />
|
|
74
|
+
</ControlStateProvider>
|
|
75
|
+
</div>
|
|
76
|
+
{label ? (
|
|
77
|
+
<span id={labelId} className="ambx-label">
|
|
78
|
+
{label}
|
|
79
|
+
</span>
|
|
80
|
+
) : null}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import type { ControlState } from "./types";
|
|
3
|
+
import type { BankOption } from "./useBank";
|
|
4
|
+
|
|
5
|
+
const ControlStateContext = createContext<ControlState | null>(null);
|
|
6
|
+
|
|
7
|
+
export const ControlStateProvider = ControlStateContext.Provider;
|
|
8
|
+
|
|
9
|
+
/** Read the enclosing control's state from inside a part.
|
|
10
|
+
*
|
|
11
|
+
* This is the third outlet of the state channel, and the one to reach for
|
|
12
|
+
* last: the custom properties on the control root are canonical, and a
|
|
13
|
+
* part that can be styled from CSS should be. Use this when a part needs
|
|
14
|
+
* the value as a JS number — a readout, a tick ring that has to emit N
|
|
15
|
+
* children, an SVG whose path data depends on the value. */
|
|
16
|
+
export function useControlState(): ControlState {
|
|
17
|
+
const state = useContext(ControlStateContext);
|
|
18
|
+
if (!state) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
"useControlState() must be called from inside a control's parts. " +
|
|
21
|
+
"Pass the component through `parts` on AmbientRotary, AmbientTravel, " +
|
|
22
|
+
"AmbientPress, AmbientLatch or AmbientBank."
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return state;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A bank key carries more than a number: its legend, its accessible name
|
|
29
|
+
* and its own lamp colour all belong to the option, not to the state. So a
|
|
30
|
+
* key's parts get this alongside `useControlState()`. */
|
|
31
|
+
export type BankKeyState = { option: BankOption; on: boolean; index: number };
|
|
32
|
+
|
|
33
|
+
const BankKeyContext = createContext<BankKeyState | null>(null);
|
|
34
|
+
|
|
35
|
+
export const BankKeyProvider = BankKeyContext.Provider;
|
|
36
|
+
|
|
37
|
+
export function useBankKey(): BankKeyState {
|
|
38
|
+
const key = useContext(BankKeyContext);
|
|
39
|
+
if (!key) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"useBankKey() must be called from inside a bank key's parts. Pass the " +
|
|
42
|
+
"component through `keyParts` on AmbientBank."
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return key;
|
|
46
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/** One controlled/uncontrolled convention for every control.
|
|
4
|
+
*
|
|
5
|
+
* Pass `value` to drive it from outside; pass `defaultValue` (or neither)
|
|
6
|
+
* to let the control hold its own. `onChange` fires either way, so a
|
|
7
|
+
* caller can read an uncontrolled control without taking it over.
|
|
8
|
+
*
|
|
9
|
+
* Before v3 this was inconsistent: AmbientSwitch and AmbientSelect took a
|
|
10
|
+
* default, while knob, slider and fader were controlled-only and would
|
|
11
|
+
* sit frozen if you forgot `onChange`. */
|
|
12
|
+
export function useControllableValue<T>(
|
|
13
|
+
controlled: T | undefined,
|
|
14
|
+
defaultValue: T,
|
|
15
|
+
onChange?: (next: T) => void
|
|
16
|
+
): [T, (next: T) => void] {
|
|
17
|
+
const isControlled = controlled !== undefined;
|
|
18
|
+
const [internal, setInternal] = useState<T>(defaultValue);
|
|
19
|
+
|
|
20
|
+
/* The setter is called from pointer handlers that run many times per
|
|
21
|
+
drag, so it must not change identity between renders. */
|
|
22
|
+
const onChangeRef = useRef(onChange);
|
|
23
|
+
onChangeRef.current = onChange;
|
|
24
|
+
const controlledRef = useRef(isControlled);
|
|
25
|
+
controlledRef.current = isControlled;
|
|
26
|
+
|
|
27
|
+
const set = useCallback((next: T) => {
|
|
28
|
+
if (!controlledRef.current) setInternal(next);
|
|
29
|
+
onChangeRef.current?.(next);
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
return [isControlled ? (controlled as T) : internal, set];
|
|
33
|
+
}
|
package/src/core/dev.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
declare const process: { env: Record<string, string | undefined> };
|
|
2
|
+
|
|
3
|
+
/** True outside a production build.
|
|
4
|
+
*
|
|
5
|
+
* Written as a bare `process.env.NODE_ENV` comparison so bundlers replace
|
|
6
|
+
* it textually and drop the guarded code from production output, and
|
|
7
|
+
* wrapped in try/catch so an unbundled ESM consumer — a CDN import with no
|
|
8
|
+
* `process` shim — gets `false` rather than a ReferenceError. */
|
|
9
|
+
export const isDev: boolean = (() => {
|
|
10
|
+
try {
|
|
11
|
+
return process.env.NODE_ENV !== "production";
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
})();
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import type { RefObject } from "react";
|
|
3
|
+
import { isDev } from "./dev";
|
|
4
|
+
import { FRAME_ORDER } from "./types";
|
|
5
|
+
import type { ControlParts } from "./types";
|
|
6
|
+
|
|
7
|
+
/** Render the frames a control has parts for, in paint order.
|
|
8
|
+
*
|
|
9
|
+
* Frames are markers by default (`display: contents` in styles.css) and
|
|
10
|
+
* become boxes only where the control has a size of its own — a rotary's
|
|
11
|
+
* rotating face, a travel thumb. That distinction is not cosmetic: a
|
|
12
|
+
* button takes its width from `min-width` plus its cap's legend, so an
|
|
13
|
+
* absolutely-positioned actuator would collapse it to nothing. */
|
|
14
|
+
export function Frames({ parts }: { parts?: ControlParts | undefined }) {
|
|
15
|
+
if (!parts) return null;
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
{FRAME_ORDER.map((name) =>
|
|
19
|
+
parts[name] == null ? null : (
|
|
20
|
+
<div key={name} data-frame={name} className={`ambx-frame ambx-frame-${name}`}>
|
|
21
|
+
{parts[name]}
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
)}
|
|
25
|
+
</>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const FOCUSABLE =
|
|
30
|
+
"a[href], button, input, select, textarea, [tabindex], [contenteditable=true]," +
|
|
31
|
+
"[role=button], [role=checkbox], [role=radio], [role=slider], [role=switch], [role=link]";
|
|
32
|
+
|
|
33
|
+
/** Development-only enforcement of the rule that parts are presentational.
|
|
34
|
+
*
|
|
35
|
+
* The control root owns `role`, `aria-value*`, `tabIndex` and the keyboard
|
|
36
|
+
* handler. A part that smuggles in its own focusable element gives the
|
|
37
|
+
* control two tab stops and, usually, a second conflicting role.
|
|
38
|
+
*
|
|
39
|
+
* Scoped to inside `[data-frame]` subtrees rather than to the whole root,
|
|
40
|
+
* because a bank's key IS a `<button>` that the mechanism renders and the
|
|
41
|
+
* frames sit inside it. Querying from the root would flag every preset we
|
|
42
|
+
* ship. */
|
|
43
|
+
export function useDevPartCheck(ref: RefObject<HTMLElement | null>, control: string): void {
|
|
44
|
+
const warned = useRef(false);
|
|
45
|
+
/* Mount only, and at most once. A part set is fixed by the code that wrote
|
|
46
|
+
it, so re-querying on every render would buy nothing and cost a DOM walk
|
|
47
|
+
on every frame of a drag. */
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!isDev || warned.current) return;
|
|
50
|
+
const root = ref.current;
|
|
51
|
+
if (!root) return;
|
|
52
|
+
const offenders = root.querySelectorAll(`[data-frame] :is(${FOCUSABLE})`);
|
|
53
|
+
if (offenders.length === 0) return;
|
|
54
|
+
warned.current = true;
|
|
55
|
+
const tags = Array.from(offenders, (node) => `<${node.tagName.toLowerCase()}>`).join(", ");
|
|
56
|
+
console.warn(
|
|
57
|
+
`[@ambientcss/components] ${control}: a part contains a focusable element ` +
|
|
58
|
+
`(${tags}). Parts are presentational — the control root already owns the ` +
|
|
59
|
+
`role, the tab stop and the keyboard handler, so this creates a second ` +
|
|
60
|
+
`tab stop and usually a conflicting role.`
|
|
61
|
+
);
|
|
62
|
+
}, [ref, control]);
|
|
63
|
+
}
|
package/src/core/kit.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import type { PropsWithChildren } from "react";
|
|
3
|
+
import { isDev } from "./dev";
|
|
4
|
+
import type { ControlParts } from "./types";
|
|
5
|
+
import type { RotaryInput, RotaryTravel } from "./useRotary";
|
|
6
|
+
import type { ControlAnimate } from "./types";
|
|
7
|
+
|
|
8
|
+
/** The five control families a kit can dress. */
|
|
9
|
+
export type ControlFamily = "rotary" | "travel" | "press" | "latch" | "bank";
|
|
10
|
+
|
|
11
|
+
/** What a preset hands its kit: the look options the caller asked for.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately loose. Look props are *kit vocabulary* — `knurling` and
|
|
14
|
+
* `markers` are words the grounded kit made up — so a kit reads the keys it
|
|
15
|
+
* understands and ignores the rest, exactly as a stylesheet that never
|
|
16
|
+
* implemented a class simply does not react to it. See `looks` below for
|
|
17
|
+
* how that stays honest rather than silent. */
|
|
18
|
+
export type KitLook = Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
/** A dressed control: what goes in the frames, and what the root wears.
|
|
21
|
+
*
|
|
22
|
+
* The class comes from the kit rather than the preset because it is part of
|
|
23
|
+
* the look — `.amb-knob` carries the grounded knob's own token table, and a
|
|
24
|
+
* kit that replaces the parts has no use for it. */
|
|
25
|
+
export type KitDress = {
|
|
26
|
+
parts: ControlParts;
|
|
27
|
+
className?: string | undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Presentation defaults a visual identity may legitimately set.
|
|
31
|
+
*
|
|
32
|
+
* Narrow on purpose: a kit says how its controls should *feel* to turn, not
|
|
33
|
+
* what they are worth. Anything touching value, range or handlers stays with
|
|
34
|
+
* the caller. */
|
|
35
|
+
export type KitDefaults = {
|
|
36
|
+
rotary?: { travel?: RotaryTravel; input?: RotaryInput; animate?: ControlAnimate } | undefined;
|
|
37
|
+
travel?: { animate?: ControlAnimate } | undefined;
|
|
38
|
+
latch?: { animate?: ControlAnimate } | undefined;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type ControlKit = {
|
|
42
|
+
name: string;
|
|
43
|
+
rotary?: ((look: KitLook) => KitDress) | undefined;
|
|
44
|
+
travel?: ((look: KitLook) => KitDress) | undefined;
|
|
45
|
+
press?: ((look: KitLook) => KitDress) | undefined;
|
|
46
|
+
latch?: ((look: KitLook) => KitDress) | undefined;
|
|
47
|
+
bank?: ((look: KitLook) => KitDress) | undefined;
|
|
48
|
+
defaults?: KitDefaults | undefined;
|
|
49
|
+
/** Look keys each family honours. Used only to warn in development when a
|
|
50
|
+
* caller passes a prop the active kit is going to drop on the floor —
|
|
51
|
+
* the one real cost of letting look props pass through untyped. */
|
|
52
|
+
looks?: Partial<Record<ControlFamily, readonly string[]>> | undefined;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const KitContext = createContext<ControlKit | null>(null);
|
|
56
|
+
|
|
57
|
+
/** Dress every control below this point in `kit`.
|
|
58
|
+
*
|
|
59
|
+
* A kit is a plain object, so shipping one is shipping a module: no
|
|
60
|
+
* registry, no lifecycle, nothing to initialise. A kit that leaves a family
|
|
61
|
+
* undefined falls through to the default, which is what a real third-party
|
|
62
|
+
* kit will do for most of them. */
|
|
63
|
+
export function AmbientKitProvider({ kit, children }: PropsWithChildren<{ kit: ControlKit }>) {
|
|
64
|
+
return <KitContext.Provider value={kit}>{children}</KitContext.Provider>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function useKit(): ControlKit | null {
|
|
68
|
+
return useContext(KitContext);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const warned = new Set<string>();
|
|
72
|
+
|
|
73
|
+
/** Resolve a family's dressing: the active kit if it dresses this family,
|
|
74
|
+
* otherwise the fallback the preset was built around. */
|
|
75
|
+
export function useDress<F extends ControlFamily>(
|
|
76
|
+
family: F,
|
|
77
|
+
look: KitLook,
|
|
78
|
+
fallback: (look: KitLook) => KitDress
|
|
79
|
+
): { dress: KitDress; defaults: F extends keyof KitDefaults ? KitDefaults[F] : undefined } {
|
|
80
|
+
const kit = useKit();
|
|
81
|
+
const dressed = kit?.[family];
|
|
82
|
+
|
|
83
|
+
if (isDev && kit && dressed) {
|
|
84
|
+
const honoured = kit.looks?.[family];
|
|
85
|
+
if (honoured) {
|
|
86
|
+
for (const key of Object.keys(look)) {
|
|
87
|
+
if (look[key] === undefined || honoured.includes(key)) continue;
|
|
88
|
+
const id = `${kit.name}:${family}:${key}`;
|
|
89
|
+
if (warned.has(id)) continue;
|
|
90
|
+
warned.add(id);
|
|
91
|
+
console.warn(
|
|
92
|
+
`[@ambientcss/components] the "${kit.name}" kit's ${family} does not use ` +
|
|
93
|
+
`\`${key}\` — it is a look prop from another kit's vocabulary, so it will ` +
|
|
94
|
+
`have no effect here.`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
dress: (dressed ?? fallback)(look),
|
|
102
|
+
/* Defaults come from the kit that actually dressed the control: a kit
|
|
103
|
+
that falls through to grounded for a family has no say in how that
|
|
104
|
+
family behaves either. */
|
|
105
|
+
defaults: (dressed ? kit?.defaults?.[family as keyof KitDefaults] : undefined) as never
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** The grounded surface finishes from @ambientcss/css.
|
|
2
|
+
*
|
|
3
|
+
* Lives on parts and presets, never on a mechanism: which element a
|
|
4
|
+
* material belongs on is a fact about a particular control's construction,
|
|
5
|
+
* and a mechanism cannot know that once the parts are yours.
|
|
6
|
+
*
|
|
7
|
+
* `brushed`, `brushed-round` and `blasted` are micro-relief materials: they
|
|
8
|
+
* paint their grain into BOTH of the host's pseudo-elements, so a part that
|
|
9
|
+
* already spends one of its own has to give them an inner layer rather than
|
|
10
|
+
* wear them directly. `ButtonCap` is the only part in this package that does.
|
|
11
|
+
*
|
|
12
|
+
* `brushed-round` is the same aluminium as `brushed` with the grain spun
|
|
13
|
+
* round the element's centre, so it belongs on round faces — a knob cap, a
|
|
14
|
+
* round button — where the centre it turns about is a real feature. */
|
|
15
|
+
export type AmbientMaterial =
|
|
16
|
+
| "matte"
|
|
17
|
+
| "shiny"
|
|
18
|
+
| "glass"
|
|
19
|
+
| "brushed"
|
|
20
|
+
| "brushed-round"
|
|
21
|
+
| "blasted";
|
|
22
|
+
|
|
23
|
+
/** Whether a finish carries micro-relief, and therefore needs both
|
|
24
|
+
* pseudo-elements of whatever it is put on. */
|
|
25
|
+
export function isRelief(material: AmbientMaterial | undefined): boolean {
|
|
26
|
+
return material === "brushed" || material === "brushed-round" || material === "blasted";
|
|
27
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/* Value maths shared by every control.
|
|
2
|
+
|
|
3
|
+
These four functions were previously inlined — identically — in
|
|
4
|
+
AmbientKnob, AmbientSlider and AmbientFader. The keyboard handler below
|
|
5
|
+
was copied three times with the same `pageStep = step * 10`, the same
|
|
6
|
+
Home/End edges and the same snap-then-clamp order. */
|
|
7
|
+
|
|
8
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
9
|
+
return Math.min(max, Math.max(min, value));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Quantise to the step grid, anchored at `min` rather than at zero so a
|
|
13
|
+
* range like 10..100 step 25 rests on 10/35/60/85 instead of 25/50/75. */
|
|
14
|
+
export function snap(value: number, min: number, step: number): number {
|
|
15
|
+
if (!(step > 0)) return value;
|
|
16
|
+
return min + Math.round((value - min) / step) * step;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Value to its 0-1 position in the range. */
|
|
20
|
+
export function normalise(value: number, min: number, max: number): number {
|
|
21
|
+
return (value - min) / (max - min || 1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A 0-1 position back to a value. */
|
|
25
|
+
export function denormalise(t: number, min: number, max: number): number {
|
|
26
|
+
return min + t * (max - min);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Snap and clamp in the order every control needs: quantise first, then
|
|
30
|
+
* hold the result inside the range, so a step that does not divide the
|
|
31
|
+
* range still cannot push the value past `max`. */
|
|
32
|
+
export function commit(value: number, min: number, max: number, step: number): number {
|
|
33
|
+
return clamp(snap(value, min, max === min ? 1 : step), min, max);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type ValueKeysOptions = {
|
|
37
|
+
value: number;
|
|
38
|
+
min: number;
|
|
39
|
+
max: number;
|
|
40
|
+
step: number;
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
/** Swap the direction of the arrow keys along the control's own axis. */
|
|
43
|
+
invert?: boolean;
|
|
44
|
+
onChange: (next: number) => void;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The Arrow/Page/Home/End contract, in one place.
|
|
48
|
+
*
|
|
49
|
+
* Both arrow axes act on every control: a knob has no "left", and a
|
|
50
|
+
* horizontal slider has no "up", but a keyboard user should not have to
|
|
51
|
+
* know which. Page steps are ten of `step`, the figure all three
|
|
52
|
+
* components already used. */
|
|
53
|
+
export function valueKeyHandler(options: ValueKeysOptions) {
|
|
54
|
+
return (event: { key: string; preventDefault(): void }): boolean => {
|
|
55
|
+
const { value, min, max, step, invert, onChange, disabled } = options;
|
|
56
|
+
if (disabled) return false;
|
|
57
|
+
const dir = invert ? -1 : 1;
|
|
58
|
+
const set = (next: number) => {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
onChange(commit(next, min, max, step));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
switch (event.key) {
|
|
64
|
+
case "ArrowUp":
|
|
65
|
+
case "ArrowRight":
|
|
66
|
+
set(value + step * dir);
|
|
67
|
+
return true;
|
|
68
|
+
case "ArrowDown":
|
|
69
|
+
case "ArrowLeft":
|
|
70
|
+
set(value - step * dir);
|
|
71
|
+
return true;
|
|
72
|
+
case "PageUp":
|
|
73
|
+
set(value + step * 10 * dir);
|
|
74
|
+
return true;
|
|
75
|
+
case "PageDown":
|
|
76
|
+
set(value - step * 10 * dir);
|
|
77
|
+
return true;
|
|
78
|
+
case "Home":
|
|
79
|
+
set(min);
|
|
80
|
+
return true;
|
|
81
|
+
case "End":
|
|
82
|
+
set(max);
|
|
83
|
+
return true;
|
|
84
|
+
default:
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** The four frames every control renders, in paint order.
|
|
4
|
+
*
|
|
5
|
+
* - `panel` static, behind the control, allowed to overflow its box
|
|
6
|
+
* - `base` static, the control's own footprint
|
|
7
|
+
* - `actuator` the moving part: the frame the control transforms
|
|
8
|
+
* - `fixture` static, above the actuator
|
|
9
|
+
*
|
|
10
|
+
* A part dropped into a frame is ordinary markup. The control moves the
|
|
11
|
+
* frame; the part only has to look like something. */
|
|
12
|
+
export type ControlParts = {
|
|
13
|
+
panel?: ReactNode | undefined;
|
|
14
|
+
base?: ReactNode | undefined;
|
|
15
|
+
actuator?: ReactNode | undefined;
|
|
16
|
+
fixture?: ReactNode | undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type FrameName = keyof ControlParts;
|
|
20
|
+
|
|
21
|
+
export const FRAME_ORDER: FrameName[] = ["panel", "base", "actuator", "fixture"];
|
|
22
|
+
|
|
23
|
+
/** `"sm" | "md" | "lg"` picks a size from the family's table; any other
|
|
24
|
+
* string is used as a CSS length for `--ambx-size`. */
|
|
25
|
+
export type ControlSize = "sm" | "md" | "lg" | (string & {});
|
|
26
|
+
|
|
27
|
+
/** How the actuator moves to a new position.
|
|
28
|
+
*
|
|
29
|
+
* - `follow` 1:1 with the pointer, no transition — right while dragging
|
|
30
|
+
* - `ease` transitions, right for keyboard and click-to-set
|
|
31
|
+
* - `snap` instant, for a detented control that should read as clicking
|
|
32
|
+
*
|
|
33
|
+
* The default is contextual: follow while `data-dragging` is on the root,
|
|
34
|
+
* ease otherwise. That needs no JS — see `.ambx-control` in styles.css. */
|
|
35
|
+
export type ControlAnimate = "auto" | "follow" | "ease" | "snap";
|
|
36
|
+
|
|
37
|
+
/** What a part can read, via `useControlState()`. The same values are on
|
|
38
|
+
* the control root as custom properties, which are canonical; this is a
|
|
39
|
+
* typed view of them for parts that genuinely need JS. */
|
|
40
|
+
export type ControlState = {
|
|
41
|
+
/** The raw value. Booleans arrive as 0/1, selections as the index. */
|
|
42
|
+
value: number;
|
|
43
|
+
min: number;
|
|
44
|
+
max: number;
|
|
45
|
+
/** Normalised position, 0-1. */
|
|
46
|
+
percent: number;
|
|
47
|
+
/** Degrees clockwise from 12 o'clock. 0 for non-rotary controls. */
|
|
48
|
+
angle: number;
|
|
49
|
+
/** Sweep origin and extent in degrees. 0 for non-rotary controls. */
|
|
50
|
+
travelStart: number;
|
|
51
|
+
travelSweep: number;
|
|
52
|
+
/** Rest positions along the travel; 0 means continuous. */
|
|
53
|
+
detents: number;
|
|
54
|
+
dragging: boolean;
|
|
55
|
+
disabled: boolean;
|
|
56
|
+
atMin: boolean;
|
|
57
|
+
atMax: boolean;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const IDLE_STATE: ControlState = {
|
|
61
|
+
value: 0,
|
|
62
|
+
min: 0,
|
|
63
|
+
max: 1,
|
|
64
|
+
percent: 0,
|
|
65
|
+
angle: 0,
|
|
66
|
+
travelStart: 0,
|
|
67
|
+
travelSweep: 0,
|
|
68
|
+
detents: 0,
|
|
69
|
+
dragging: false,
|
|
70
|
+
disabled: false,
|
|
71
|
+
atMin: true,
|
|
72
|
+
atMax: false
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The state channel, written onto the control root.
|
|
76
|
+
*
|
|
77
|
+
* Every property is declared here even when a family does not use it,
|
|
78
|
+
* because these inherit: a control nested inside another control's frame
|
|
79
|
+
* would otherwise read its ancestor's angle. `.ambx-control` carries the
|
|
80
|
+
* neutral defaults and this overrides them per instance. */
|
|
81
|
+
export function stateStyle(state: ControlState): CSSProperties {
|
|
82
|
+
return {
|
|
83
|
+
"--ambx-value": state.value,
|
|
84
|
+
"--ambx-percent": state.percent,
|
|
85
|
+
"--ambx-angle": `${state.angle}deg`,
|
|
86
|
+
"--ambx-travel-start": `${state.travelStart}deg`,
|
|
87
|
+
"--ambx-travel-sweep": `${state.travelSweep}deg`,
|
|
88
|
+
"--ambx-detents": state.detents
|
|
89
|
+
} as CSSProperties;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The discrete half of the state channel: conditions a part can style
|
|
93
|
+
* against but cannot detect for itself. */
|
|
94
|
+
export function stateData(state: ControlState): Record<string, string | undefined> {
|
|
95
|
+
return {
|
|
96
|
+
"data-dragging": state.dragging ? "" : undefined,
|
|
97
|
+
"data-disabled": state.disabled ? "" : undefined,
|
|
98
|
+
"data-at-min": state.atMin ? "" : undefined,
|
|
99
|
+
"data-at-max": state.atMax ? "" : undefined
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Resolve `size` into the class suffix and/or the `--ambx-size` override.
|
|
104
|
+
* A named size picks the family's table; a length goes straight through,
|
|
105
|
+
* which is what lets a part scale off one property instead of matching a
|
|
106
|
+
* class it cannot know about. */
|
|
107
|
+
export function sizeProps(
|
|
108
|
+
family: string,
|
|
109
|
+
size: ControlSize | undefined
|
|
110
|
+
): { className?: string; style?: CSSProperties } {
|
|
111
|
+
if (size === undefined) return {};
|
|
112
|
+
if (size === "sm" || size === "md" || size === "lg") {
|
|
113
|
+
return { className: `ambx-${family}-${size}` };
|
|
114
|
+
}
|
|
115
|
+
return { style: { "--ambx-size": size } as CSSProperties };
|
|
116
|
+
}
|