@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
@@ -1,127 +1,42 @@
1
- import { useId, useRef } from "react";
2
- import type { HTMLAttributes, KeyboardEvent, PointerEvent } from "react";
3
1
  import { cn } from "../lib/cn";
2
+ import { AmbientTravel } from "../controls/AmbientTravel";
3
+ import type { AmbientTravelProps } from "../controls/AmbientTravel";
4
+ import { useDress } from "../core/kit";
5
+ import type { KitLook } from "../core/kit";
6
+ import { groundedKit } from "../kits/grounded";
7
+ import type { AmbientMaterial } from "../core/material";
4
8
 
5
9
  export type AmbientSliderSize = "sm" | "md" | "lg";
6
10
 
7
- export type AmbientSliderProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange"> & {
8
- value: number;
9
- min?: number;
10
- max?: number;
11
- step?: number;
12
- label?: string;
13
- material?: "matte" | "shiny" | "glass";
14
- size?: AmbientSliderSize;
15
- onChange?: (nextValue: number) => void;
11
+ export type AmbientSliderProps = Omit<AmbientTravelProps, "parts" | "size" | "orientation"> & {
12
+ material?: AmbientMaterial | undefined;
13
+ size?: AmbientSliderSize | undefined;
14
+ /** Look options in the active kit's vocabulary. */
15
+ look?: KitLook | undefined;
16
16
  };
17
17
 
18
- function clamp(value: number, min: number, max: number): number {
19
- return Math.min(max, Math.max(min, value));
20
- }
21
-
18
+ /** A domed disc gliding over a shallow concave channel. */
22
19
  export function AmbientSlider({
23
- value,
24
- min = 0,
25
- max = 100,
26
- step = 1,
27
- label,
28
20
  material,
21
+ look,
29
22
  size = "md",
30
- onChange,
23
+ animate,
31
24
  className,
32
- ...props
25
+ ...rest
33
26
  }: AmbientSliderProps) {
34
- const id = useId();
35
- const trackRef = useRef<HTMLDivElement | null>(null);
36
- const safeStep = step > 0 ? step : 1;
37
-
38
- const percent = ((value - min) / (max - min || 1)) * 100;
39
-
40
- const updateFromClientX = (clientX: number) => {
41
- const track = trackRef.current;
42
- if (!track) return;
43
-
44
- const rect = track.getBoundingClientRect();
45
- const ratio = (clientX - rect.left) / rect.width;
46
- const nextValue = min + clamp(ratio, 0, 1) * (max - min);
47
- const snapped = Math.round(nextValue / safeStep) * safeStep;
48
- onChange?.(clamp(snapped, min, max));
49
- };
50
-
51
- const setValue = (nextValue: number) => {
52
- const snapped = Math.round(nextValue / safeStep) * safeStep;
53
- onChange?.(clamp(snapped, min, max));
54
- };
55
-
56
- const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
57
- const pageStep = safeStep * 10;
58
- switch (event.key) {
59
- case "ArrowRight":
60
- case "ArrowUp":
61
- event.preventDefault();
62
- setValue(value + safeStep);
63
- break;
64
- case "ArrowLeft":
65
- case "ArrowDown":
66
- event.preventDefault();
67
- setValue(value - safeStep);
68
- break;
69
- case "PageUp":
70
- event.preventDefault();
71
- setValue(value + pageStep);
72
- break;
73
- case "PageDown":
74
- event.preventDefault();
75
- setValue(value - pageStep);
76
- break;
77
- case "Home":
78
- event.preventDefault();
79
- setValue(min);
80
- break;
81
- case "End":
82
- event.preventDefault();
83
- setValue(max);
84
- break;
85
- default:
86
- break;
87
- }
88
- };
89
-
90
- const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
91
- if (event.buttons !== 1) return;
92
- updateFromClientX(event.clientX);
93
- };
94
-
27
+ const { dress, defaults } = useDress(
28
+ "travel",
29
+ { material, orientation: "horizontal", ...look },
30
+ groundedKit.travel!
31
+ );
95
32
  return (
96
- <div className={cn("ambx-stack", className)} {...props}>
97
- <div
98
- className={cn("amb-slider amb-groove ambx-slider", `ambx-slider-${size}`)}
99
- ref={trackRef}
100
- role="slider"
101
- aria-label={label}
102
- aria-labelledby={label ? id : undefined}
103
- aria-valuemin={min}
104
- aria-valuemax={max}
105
- aria-valuenow={value}
106
- aria-orientation="horizontal"
107
- tabIndex={0}
108
- onPointerDown={(event) => {
109
- event.currentTarget.setPointerCapture(event.pointerId);
110
- updateFromClientX(event.clientX);
111
- }}
112
- onPointerMove={onPointerMove}
113
- onKeyDown={onKeyDown}
114
- >
115
- <div
116
- className={cn("amb-slider-thumb ambient amb-fillet ambx-slider-thumb", material !== "glass" && "amb-surface-convex", material && `amb-mat-${material}`)}
117
- style={{ left: `${percent}%` }}
118
- />
119
- </div>
120
- {label ? (
121
- <span id={id} className="ambx-label">
122
- {label}
123
- </span>
124
- ) : null}
125
- </div>
33
+ <AmbientTravel
34
+ {...rest}
35
+ orientation="horizontal"
36
+ size={size}
37
+ animate={animate ?? defaults?.animate}
38
+ className={cn(dress.className, className)}
39
+ parts={dress.parts}
40
+ />
126
41
  );
127
42
  }
@@ -1,78 +1,78 @@
1
- import { useId, useState } from "react";
2
- import type { ButtonHTMLAttributes } from "react";
1
+ import { useId } from "react";
3
2
  import { cn } from "../lib/cn";
3
+ import { AmbientLatch } from "../controls/AmbientLatch";
4
+ import type { AmbientLatchProps } from "../controls/AmbientLatch";
5
+ import { useControllableValue } from "../core/controllable";
6
+ import { useDress } from "../core/kit";
7
+ import type { KitLook } from "../core/kit";
8
+ import { groundedKit } from "../kits/grounded";
9
+ import { Led } from "../parts/latch";
4
10
 
5
11
  export type AmbientSwitchSize = "sm" | "md" | "lg";
6
12
 
7
- export type AmbientSwitchProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> & {
8
- checked?: boolean;
9
- defaultChecked?: boolean;
10
- onCheckedChange?: (checked: boolean) => void;
11
- size?: AmbientSwitchSize;
12
- label?: string;
13
- /** Show a small LED indicator that lights up when the switch is on. Pass `true` for default green or a CSS color string. */
14
- led?: boolean | string;
13
+ export type AmbientSwitchProps = Omit<AmbientLatchProps, "parts" | "size"> & {
14
+ size?: AmbientSwitchSize | undefined;
15
+ /** A lamp above the switch. `true` for the scene's own colour, or any
16
+ * CSS colour string. */
17
+ led?: boolean | string | undefined;
18
+ /** Look options in the active kit's vocabulary. */
19
+ look?: KitLook | undefined;
15
20
  };
16
21
 
22
+ /** A pill sliding in a dark stadium recess, with an optional lamp above.
23
+ *
24
+ * The lamp is mounted beside the switch rather than inside it: the latch
25
+ * control IS the track, so anything that is neither track nor pill belongs
26
+ * to whatever composes them. That means the preset has to hold the state —
27
+ * the lamp and the pill read the same boolean, and only their common
28
+ * parent can see both.
29
+ */
17
30
  export function AmbientSwitch({
18
- className,
19
- checked,
20
- defaultChecked,
21
- onCheckedChange,
22
- onClick,
23
31
  size = "md",
24
- label,
25
32
  led,
26
- children,
27
- ...props
33
+ label,
34
+ look,
35
+ value,
36
+ defaultValue,
37
+ onChange,
38
+ animate,
39
+ className,
40
+ ...rest
28
41
  }: AmbientSwitchProps) {
29
42
  const labelId = useId();
30
- const isControlled = checked !== undefined;
31
- const [internalChecked, setInternalChecked] = useState(defaultChecked ?? false);
32
- const active = isControlled ? (checked ?? false) : internalChecked;
43
+ const [on, setOn] = useControllableValue(value, defaultValue ?? false, onChange);
44
+ const { dress, defaults } = useDress("latch", { ...look }, groundedKit.latch!);
33
45
 
34
- const button = (
35
- <button
36
- type="button"
37
- role="switch"
38
- aria-checked={active}
39
- aria-labelledby={label ? labelId : props["aria-labelledby"]}
40
- className={cn(
41
- "amb-switch",
42
- active && "amb-switch-on",
43
- "ambx-switch",
44
- `ambx-switch-${size}`,
45
- className
46
- )}
47
- onClick={(event) => {
48
- const next = !active;
49
- if (!isControlled) setInternalChecked(next);
50
- onCheckedChange?.(next);
51
- onClick?.(event);
52
- }}
53
- {...props}
54
- >
55
- {led ? (
56
- <span
57
- className={cn("amb-led", !active && "amb-led-off")}
58
- style={typeof led === "string" ? { "--amb-led-color": led } as React.CSSProperties : undefined}
59
- />
60
- ) : null}
61
- <span className="amb-switch-track amb-groove">
62
- <span className="amb-switch-pill ambient amb-fillet amb-surface-convex" />
63
- </span>
64
- {children}
65
- </button>
46
+ const control = (
47
+ <AmbientLatch
48
+ {...rest}
49
+ value={on}
50
+ onChange={setOn}
51
+ size={size}
52
+ animate={animate ?? defaults?.animate}
53
+ aria-labelledby={label ? labelId : rest["aria-labelledby"]}
54
+ className={cn(dress.className, className)}
55
+ parts={dress.parts}
56
+ />
66
57
  );
67
58
 
68
- if (!label) return button;
59
+ if (!led && !label) return control;
69
60
 
70
61
  return (
71
62
  <div className="ambx-stack">
72
- {button}
73
- <span id={labelId} className="ambx-label">
74
- {label}
75
- </span>
63
+ {led ? (
64
+ <span className="ambx-switch-mount">
65
+ <Led on={on} {...(typeof led === "string" ? { color: led } : null)} />
66
+ {control}
67
+ </span>
68
+ ) : (
69
+ control
70
+ )}
71
+ {label ? (
72
+ <span id={labelId} className="ambx-label">
73
+ {label}
74
+ </span>
75
+ ) : null}
76
76
  </div>
77
77
  );
78
78
  }
@@ -0,0 +1,139 @@
1
+ import { useId } from "react";
2
+ import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { BankKeyProvider, ControlStateProvider } from "../core/context";
5
+ import { Frames } from "../core/frames";
6
+ import { sizeProps, stateData, stateStyle } from "../core/types";
7
+ import type { ControlParts, ControlSize, ControlState } from "../core/types";
8
+ import { useBank } from "../core/useBank";
9
+ import type { BankOption, UseBankOptions } from "../core/useBank";
10
+
11
+ export type AmbientBankProps = Omit<
12
+ HTMLAttributes<HTMLDivElement>,
13
+ "onChange" | "defaultValue"
14
+ > &
15
+ UseBankOptions & {
16
+ /** Parts applied to every key. The bank's own frames go in `parts`. */
17
+ keyParts?: ControlParts | undefined;
18
+ parts?: ControlParts | undefined;
19
+ size?: ControlSize | undefined;
20
+ /** Lamp colour. Defaults to the scene's `--amb-highlight-color`. */
21
+ color?: string | undefined;
22
+ label?: ReactNode | undefined;
23
+ /** Per-key override, for a bank whose keys are not interchangeable. */
24
+ renderKey?: ((option: BankOption, on: boolean) => ReactNode) | undefined;
25
+ };
26
+
27
+ function keyState(on: boolean, disabled: boolean): ControlState {
28
+ return {
29
+ value: on ? 1 : 0,
30
+ min: 0,
31
+ max: 1,
32
+ percent: on ? 1 : 0,
33
+ angle: 0,
34
+ travelStart: 0,
35
+ travelSweep: 0,
36
+ detents: 2,
37
+ dragging: false,
38
+ disabled,
39
+ atMin: !on,
40
+ atMax: on
41
+ };
42
+ }
43
+
44
+ /** N keys sharing a selection model, with no appearance of its own.
45
+ *
46
+ * Parts apply per key rather than once, because that is what a bank is.
47
+ * The focusable element is the key `<button>`, which this mechanism
48
+ * renders — your parts go inside it, which is why the presentational-parts
49
+ * rule still holds here. */
50
+ export function AmbientBank({
51
+ options,
52
+ keyParts,
53
+ parts,
54
+ size,
55
+ color,
56
+ label,
57
+ renderKey,
58
+ className,
59
+ style,
60
+ value,
61
+ defaultValue,
62
+ onChange,
63
+ multiple,
64
+ orientation = "vertical",
65
+ disabled = false,
66
+ ...rest
67
+ }: AmbientBankProps) {
68
+ const labelId = useId();
69
+ const { selected, rootProps, keyProps } = useBank({
70
+ options,
71
+ value,
72
+ defaultValue,
73
+ onChange,
74
+ multiple,
75
+ orientation,
76
+ disabled
77
+ });
78
+
79
+ const sized = sizeProps("bank", size);
80
+
81
+ const bank = (
82
+ <div
83
+ {...rest}
84
+ {...rootProps}
85
+ aria-labelledby={label ? labelId : rest["aria-labelledby"]}
86
+ className={cn(
87
+ "ambx-control ambx-bank",
88
+ `ambx-bank-${orientation}`,
89
+ sized.className,
90
+ className
91
+ )}
92
+ style={{
93
+ ...(color ? ({ "--amb-led-color": color } as CSSProperties) : null),
94
+ ...sized.style,
95
+ ...style
96
+ }}
97
+ >
98
+ <Frames parts={parts} />
99
+ {options.map((option, index) => {
100
+ const on = selected.includes(option.value);
101
+ /* Each key publishes its own state channel, exactly as a standalone
102
+ control does. Without this a key's parts would inherit the bank
103
+ root's neutral --ambx-percent and a pure-CSS part could never see
104
+ that its lamp is lit — the one family where "the properties are
105
+ canonical" would quietly not be true. */
106
+ const ks = keyState(on, option.disabled || disabled);
107
+ return (
108
+ <button
109
+ key={option.value}
110
+ {...keyProps(option, index)}
111
+ {...stateData(ks)}
112
+ className="ambx-key"
113
+ style={{
114
+ ...(stateStyle(ks) as CSSProperties),
115
+ ...(option.color ? ({ "--amb-led-color": option.color } as CSSProperties) : null)
116
+ }}
117
+ >
118
+ <ControlStateProvider value={ks}>
119
+ <BankKeyProvider value={{ option, on, index }}>
120
+ {renderKey ? renderKey(option, on) : <Frames parts={keyParts} />}
121
+ </BankKeyProvider>
122
+ </ControlStateProvider>
123
+ </button>
124
+ );
125
+ })}
126
+ </div>
127
+ );
128
+
129
+ if (!label) return bank;
130
+
131
+ return (
132
+ <div className="ambx-stack">
133
+ {bank}
134
+ <span id={labelId} className="ambx-label">
135
+ {label}
136
+ </span>
137
+ </div>
138
+ );
139
+ }
@@ -0,0 +1,78 @@
1
+ import { useId, useRef } from "react";
2
+ import type { ButtonHTMLAttributes, 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 { useLatch } from "../core/useLatch";
9
+ import type { UseLatchOptions } from "../core/useLatch";
10
+
11
+ export type AmbientLatchProps = Omit<
12
+ ButtonHTMLAttributes<HTMLButtonElement>,
13
+ "onChange" | "value" | "defaultValue" | "type"
14
+ > &
15
+ UseLatchOptions & {
16
+ parts?: ControlParts | undefined;
17
+ size?: ControlSize | undefined;
18
+ animate?: ControlAnimate | undefined;
19
+ label?: ReactNode | undefined;
20
+ };
21
+
22
+ /** A two-position slide, with no appearance of its own. The control IS the
23
+ * track: its actuator is a pill-sized frame that travels across it, which
24
+ * is why anything sitting beside a switch — a lamp, a legend — belongs to
25
+ * whatever composes it rather than to the switch. */
26
+ export function AmbientLatch({
27
+ parts,
28
+ size,
29
+ animate = "auto",
30
+ label,
31
+ className,
32
+ value,
33
+ defaultValue,
34
+ onChange,
35
+ disabled,
36
+ children,
37
+ ...rest
38
+ }: AmbientLatchProps) {
39
+ const labelId = useId();
40
+ const ref = useRef<HTMLButtonElement>(null);
41
+ const { state, rootProps } = useLatch({ value, defaultValue, onChange, disabled });
42
+ useDevPartCheck(ref, "AmbientLatch");
43
+
44
+ const sized = sizeProps("latch", size);
45
+ const { style: restStyle, onClick, ...restProps } = rest;
46
+
47
+ const control = (
48
+ <button
49
+ {...restProps}
50
+ {...rootProps}
51
+ ref={ref}
52
+ aria-labelledby={label ? labelId : rest["aria-labelledby"]}
53
+ data-animate={animate}
54
+ className={cn("ambx-control ambx-latch", sized.className, className)}
55
+ style={{ ...rootProps.style, ...sized.style, ...restStyle }}
56
+ onClick={(event) => {
57
+ rootProps.onClick();
58
+ onClick?.(event);
59
+ }}
60
+ >
61
+ <ControlStateProvider value={state}>
62
+ <Frames parts={parts} />
63
+ {children}
64
+ </ControlStateProvider>
65
+ </button>
66
+ );
67
+
68
+ if (!label) return control;
69
+
70
+ return (
71
+ <div className="ambx-stack">
72
+ {control}
73
+ <span id={labelId} className="ambx-label">
74
+ {label}
75
+ </span>
76
+ </div>
77
+ );
78
+ }
@@ -0,0 +1,74 @@
1
+ import { useRef } from "react";
2
+ import type { ButtonHTMLAttributes } 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 { ControlParts, ControlSize } from "../core/types";
8
+ import { usePress } from "../core/usePress";
9
+ import type { UsePressOptions } from "../core/usePress";
10
+
11
+ export type AmbientPressProps = Omit<
12
+ ButtonHTMLAttributes<HTMLButtonElement>,
13
+ "onChange" | "value" | "defaultValue" | "type"
14
+ > &
15
+ UsePressOptions & {
16
+ parts?: ControlParts | undefined;
17
+ size?: ControlSize | undefined;
18
+ };
19
+
20
+ /** A key that sinks under a finger, with no appearance of its own.
21
+ *
22
+ * Its frames are `display: contents` markers rather than boxes, because a
23
+ * button is sized by its cap: the width is `min-width` plus the legend.
24
+ * Wrapping the cap in a positioned frame would collapse the control. */
25
+ export function AmbientPress({
26
+ parts,
27
+ size,
28
+ className,
29
+ mode,
30
+ value,
31
+ defaultValue,
32
+ onChange,
33
+ onPress,
34
+ repeatDelay,
35
+ repeatInterval,
36
+ disabled,
37
+ children,
38
+ ...rest
39
+ }: AmbientPressProps) {
40
+ const ref = useRef<HTMLButtonElement>(null);
41
+ const { state, rootProps } = usePress({
42
+ mode,
43
+ value,
44
+ defaultValue,
45
+ onChange,
46
+ onPress,
47
+ repeatDelay,
48
+ repeatInterval,
49
+ disabled
50
+ });
51
+ useDevPartCheck(ref, "AmbientPress");
52
+
53
+ const sized = sizeProps("press", size);
54
+ const { style: restStyle, onClick, ...restProps } = rest;
55
+
56
+ return (
57
+ <button
58
+ {...restProps}
59
+ {...rootProps}
60
+ ref={ref}
61
+ className={cn("ambx-control ambx-press", sized.className, className)}
62
+ style={{ ...rootProps.style, ...sized.style, ...restStyle }}
63
+ onClick={(event) => {
64
+ rootProps.onClick();
65
+ onClick?.(event);
66
+ }}
67
+ >
68
+ <ControlStateProvider value={state}>
69
+ <Frames parts={parts} />
70
+ {children}
71
+ </ControlStateProvider>
72
+ </button>
73
+ );
74
+ }
@@ -0,0 +1,94 @@
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 { useRotary } from "../core/useRotary";
9
+ import type { UseRotaryOptions } from "../core/useRotary";
10
+
11
+ export type AmbientRotaryProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue"> &
12
+ UseRotaryOptions & {
13
+ parts?: ControlParts | undefined;
14
+ size?: ControlSize | undefined;
15
+ animate?: ControlAnimate | undefined;
16
+ label?: ReactNode | undefined;
17
+ };
18
+
19
+ /** A rotary mechanism with no appearance of its own.
20
+ *
21
+ * It owns the value, the three pointer mappings, the sweep, the keyboard
22
+ * contract and the ARIA; what it looks like is entirely the `parts` you
23
+ * give it. There is deliberately no `material` prop: which element a
24
+ * material belongs on is a fact about a particular knob's construction —
25
+ * on the clipped face when it is knurled, on the body when it is not —
26
+ * and a mechanism cannot know that once the body is yours. Presets carry
27
+ * `material`, because a preset knows its own parts. */
28
+ export function AmbientRotary({
29
+ parts,
30
+ size,
31
+ animate = "auto",
32
+ label,
33
+ className,
34
+ value,
35
+ defaultValue,
36
+ min,
37
+ max,
38
+ step,
39
+ detents,
40
+ travel,
41
+ input,
42
+ dragDistance,
43
+ wrap,
44
+ disabled,
45
+ onChange,
46
+ ...rest
47
+ }: AmbientRotaryProps) {
48
+ const labelId = useId();
49
+ const stackRef = useRef<HTMLDivElement>(null);
50
+ const { state, rootProps } = useRotary({
51
+ value,
52
+ defaultValue,
53
+ min,
54
+ max,
55
+ step,
56
+ detents,
57
+ travel,
58
+ input,
59
+ dragDistance,
60
+ wrap,
61
+ disabled,
62
+ onChange
63
+ });
64
+ useDevPartCheck(stackRef, "AmbientRotary");
65
+
66
+ const sized = sizeProps("rotary", size);
67
+ const { style: restStyle, ...restProps } = rest;
68
+
69
+ const control = (
70
+ <div
71
+ {...restProps}
72
+ {...rootProps}
73
+ aria-labelledby={label ? labelId : rest["aria-labelledby"]}
74
+ data-animate={animate}
75
+ className={cn("ambx-control ambx-rotary", sized.className, className)}
76
+ style={{ ...rootProps.style, ...sized.style, ...restStyle }}
77
+ >
78
+ <ControlStateProvider value={state}>
79
+ <Frames parts={parts} />
80
+ </ControlStateProvider>
81
+ </div>
82
+ );
83
+
84
+ return (
85
+ <div className="ambx-stack" ref={stackRef}>
86
+ {control}
87
+ {label ? (
88
+ <span id={labelId} className="ambx-label">
89
+ {label}
90
+ </span>
91
+ ) : null}
92
+ </div>
93
+ );
94
+ }