@ambientcss/components 2.1.0 → 3.0.1

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.
Files changed (45) hide show
  1. package/README.md +35 -0
  2. package/dist/index.cjs +1544 -392
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +750 -52
  5. package/dist/index.d.ts +750 -52
  6. package/dist/index.js +1505 -392
  7. package/dist/index.js.map +1 -1
  8. package/dist/styles.css +1045 -298
  9. package/package.json +10 -4
  10. package/src/components/AmbientButton.tsx +23 -27
  11. package/src/components/AmbientFader.tsx +29 -115
  12. package/src/components/AmbientKnob.tsx +58 -220
  13. package/src/components/AmbientPanel.tsx +8 -2
  14. package/src/components/AmbientProvider.tsx +3 -0
  15. package/src/components/AmbientSelect.tsx +40 -0
  16. package/src/components/AmbientSlider.tsx +28 -113
  17. package/src/components/AmbientSwitch.tsx +58 -58
  18. package/src/controls/AmbientBank.tsx +139 -0
  19. package/src/controls/AmbientLatch.tsx +78 -0
  20. package/src/controls/AmbientPress.tsx +74 -0
  21. package/src/controls/AmbientRotary.tsx +94 -0
  22. package/src/controls/AmbientTravel.tsx +83 -0
  23. package/src/core/context.tsx +46 -0
  24. package/src/core/controllable.ts +33 -0
  25. package/src/core/dev.ts +15 -0
  26. package/src/core/frames.tsx +63 -0
  27. package/src/core/kit.tsx +107 -0
  28. package/src/core/material.ts +27 -0
  29. package/src/core/numeric.ts +88 -0
  30. package/src/core/types.ts +116 -0
  31. package/src/core/useBank.ts +165 -0
  32. package/src/core/useLatch.ts +54 -0
  33. package/src/core/usePress.ts +120 -0
  34. package/src/core/useRotary.ts +253 -0
  35. package/src/core/useTravel.ts +141 -0
  36. package/src/index.ts +122 -2
  37. package/src/kits/console.tsx +80 -0
  38. package/src/kits/grounded.tsx +113 -0
  39. package/src/parts/bank.tsx +32 -0
  40. package/src/parts/console.tsx +101 -0
  41. package/src/parts/knob.tsx +203 -0
  42. package/src/parts/latch.tsx +33 -0
  43. package/src/parts/press.tsx +56 -0
  44. package/src/parts/travel.tsx +70 -0
  45. package/src/styles.css +1045 -298
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }