@ctrl-ui/react 0.8.13 → 0.8.15

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 (52) hide show
  1. package/dist/audio-device-select.d.ts +38 -0
  2. package/dist/audio-device-select.js +183 -0
  3. package/dist/audio-visualizer-bar.d.ts +10 -0
  4. package/dist/audio-visualizer-bar.js +49 -0
  5. package/dist/audio-visualizer-line.d.ts +4 -0
  6. package/dist/audio-visualizer-line.js +75 -0
  7. package/dist/audio-visualizer.d.ts +1 -1
  8. package/dist/audio-visualizer.js +43 -16
  9. package/dist/hooks/use-audio-recorder.d.ts +1 -0
  10. package/dist/hooks/use-audio-recorder.js +27 -4
  11. package/dist/hooks/use-color-area.js +0 -3
  12. package/dist/hooks/use-textarea-trigger-menu.js +5 -2
  13. package/dist/knob-contracts/audio-device-select-knobs.d.ts +4 -0
  14. package/dist/knob-contracts/audio-device-select-knobs.js +4 -0
  15. package/dist/knob-contracts/audio-visualizer-knobs.d.ts +1 -1
  16. package/dist/knob-contracts/audio-visualizer-knobs.js +2 -0
  17. package/dist/lib/audio-visualizer-levels.d.ts +12 -0
  18. package/dist/lib/audio-visualizer-levels.js +37 -0
  19. package/dist/lib/code-tokens.js +4 -1
  20. package/dist/lib/popup-item-focus.d.ts +7 -0
  21. package/dist/lib/popup-item-focus.js +9 -0
  22. package/dist/model-switcher.d.ts +23 -0
  23. package/dist/model-switcher.js +70 -0
  24. package/dist/styles/code.css +1 -1
  25. package/dist/styles/index.css +3 -2
  26. package/dist/styles/recipes/audio-device-select.css +15 -0
  27. package/dist/styles/recipes/audio-visualizer.css +97 -2
  28. package/dist/styles/recipes/popup-controls.css +11 -0
  29. package/dist/styles/recipes/popup-motion.css +6 -0
  30. package/dist/styles/recipes/tabs-motion.css +38 -28
  31. package/dist/styles/recipes/tabs.css +1 -0
  32. package/dist/styles/theme.css +43 -0
  33. package/dist/ui/alert-dialog.d.ts +3 -0
  34. package/dist/ui/alert-dialog.js +11 -1
  35. package/dist/ui/autocomplete.d.ts +1 -1
  36. package/dist/ui/color-picker.js +1 -1
  37. package/dist/ui/combobox.d.ts +1 -1
  38. package/dist/ui/context-menu.d.ts +1 -1
  39. package/dist/ui/context-menu.js +7 -4
  40. package/dist/ui/dialog.d.ts +3 -0
  41. package/dist/ui/dialog.js +11 -1
  42. package/dist/ui/dropdown-menu.d.ts +1 -1
  43. package/dist/ui/dropdown-menu.js +7 -4
  44. package/dist/ui/gradient-editor.js +1 -1
  45. package/dist/ui/item.js +1 -1
  46. package/dist/ui/responsive-dialog.d.ts +2 -1
  47. package/dist/ui/responsive-dialog.js +5 -2
  48. package/dist/ui/select.d.ts +1 -1
  49. package/dist/ui/select.js +3 -1
  50. package/dist/ui/sidebar-resize-rail.js +2 -2
  51. package/dist/ui/tabs.js +25 -14
  52. package/package.json +1 -1
@@ -0,0 +1,38 @@
1
+ import { Button } from "./ui/button.js";
2
+ import { AudioDeviceSelectKnobStyle } from "./knob-contracts/audio-device-select-knobs.js";
3
+ import { SelectContent, SelectItem, SelectProps, SelectTriggerProps } from "./ui/select.js";
4
+ import { CSSProperties, ComponentProps, ReactNode } from "react";
5
+ //#region src/audio-device-select.d.ts
6
+ export type AudioDevice = {
7
+ deviceId: string;
8
+ label: string;
9
+ isDefault?: boolean;
10
+ status?: "available" | "unavailable" | "permission-required";
11
+ };
12
+ export type AudioDevicePermission = "granted" | "prompt" | "denied";
13
+ export type AudioDeviceSelectProps = Omit<SelectProps, "items" | "children"> & {
14
+ devices: readonly AudioDevice[];
15
+ loading?: boolean;
16
+ permission?: AudioDevicePermission;
17
+ onRequestPermission?: () => void | Promise<void>;
18
+ allowNone?: boolean;
19
+ noneLabel?: string;
20
+ label?: string;
21
+ error?: string | null;
22
+ className?: string;
23
+ children?: ReactNode;
24
+ };
25
+ export declare function AudioDeviceSelect({ devices, value, defaultValue, onValueChange, disabled, loading, permission, onRequestPermission, allowNone, noneLabel, label, error, className, children, ...props }: AudioDeviceSelectProps): import("react").JSX.Element;
26
+ export declare function AudioDeviceSelectTrigger({ children, className, ...props }: SelectTriggerProps): import("react").JSX.Element;
27
+ export declare function AudioDeviceSelectValue({ placeholder }: {
28
+ placeholder?: string;
29
+ }): import("react").JSX.Element;
30
+ export declare function AudioDeviceSelectContent({ children, ...props }: ComponentProps<typeof SelectContent>): import("react").JSX.Element;
31
+ export declare function AudioDeviceSelectItem(props: ComponentProps<typeof SelectItem>): import("react").JSX.Element;
32
+ export declare function AudioDeviceSelectPreview({ className, ...props }: ComponentProps<"div">): import("react").JSX.Element;
33
+ export type AudioDeviceSelectStatusProps = Omit<ComponentProps<"p">, "style"> & {
34
+ style?: CSSProperties & AudioDeviceSelectKnobStyle;
35
+ };
36
+ export declare function AudioDeviceSelectStatus({ className, ...props }: AudioDeviceSelectStatusProps): import("react").JSX.Element | null;
37
+ export declare function AudioDeviceSelectPermission({ children, onClick, ...props }: ComponentProps<typeof Button>): import("react").JSX.Element | null;
38
+ //#endregion
@@ -0,0 +1,183 @@
1
+ "use client";
2
+ import { cn } from "./lib/cn.js";
3
+ import { Button } from "./ui/button.js";
4
+ import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select.js";
5
+ import { createContext, use, useState } from "react";
6
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
+ import { MicIcon } from "lucide-react";
8
+ //#region src/audio-device-select.tsx
9
+ const AudioDeviceSelectContext = createContext(null);
10
+ function useAudioDeviceSelect() {
11
+ const context = use(AudioDeviceSelectContext);
12
+ if (!context) throw new Error("AudioDeviceSelect parts must be rendered inside AudioDeviceSelect.");
13
+ return context;
14
+ }
15
+ function deviceLabel(device, index = 0) {
16
+ return device.label.trim() || `Microphone ${index + 1}`;
17
+ }
18
+ function AudioDeviceSelect({ devices, value, defaultValue, onValueChange, disabled = false, loading = false, permission = "granted", onRequestPermission, allowNone = false, noneLabel = "No microphone", label = "Microphone", error = null, className, children, ...props }) {
19
+ const [internalValue, setInternalValue] = useState(() => defaultValue ?? null);
20
+ const current = value === void 0 ? internalValue : value;
21
+ const connected = devices.find((device) => device.deviceId === current) ?? null;
22
+ const [remembered, setRemembered] = useState(connected);
23
+ if (connected && (remembered?.deviceId !== connected.deviceId || remembered?.label !== connected.label)) setRemembered(connected);
24
+ const missing = current !== null && !(allowNone && current === "") && !connected && !loading;
25
+ const selected = connected ?? (remembered?.deviceId === current ? remembered : null);
26
+ function handleValueChange(next) {
27
+ setInternalValue(next);
28
+ onValueChange?.(next);
29
+ }
30
+ return /* @__PURE__ */ jsx(AudioDeviceSelectContext.Provider, {
31
+ value: {
32
+ devices,
33
+ selected,
34
+ value: current,
35
+ missing,
36
+ loading,
37
+ permission,
38
+ disabled,
39
+ allowNone,
40
+ noneLabel,
41
+ label,
42
+ error,
43
+ onRequestPermission
44
+ },
45
+ children: /* @__PURE__ */ jsx("div", {
46
+ "data-control-ui": "audio-device-select",
47
+ "data-control-family": "audio-device-select",
48
+ "data-slot": "root",
49
+ className: cn("grid min-w-0 gap-2", className),
50
+ children: /* @__PURE__ */ jsx(Select, {
51
+ value: current,
52
+ onValueChange: handleValueChange,
53
+ disabled: disabled || loading,
54
+ ...props,
55
+ children: children ?? /* @__PURE__ */ jsxs(Fragment$1, { children: [
56
+ /* @__PURE__ */ jsx(AudioDeviceSelectTrigger, {}),
57
+ /* @__PURE__ */ jsx(AudioDeviceSelectContent, {}),
58
+ /* @__PURE__ */ jsx(AudioDeviceSelectStatus, {}),
59
+ /* @__PURE__ */ jsx(AudioDeviceSelectPermission, {})
60
+ ] })
61
+ })
62
+ })
63
+ });
64
+ }
65
+ function AudioDeviceSelectTrigger({ children, className, ...props }) {
66
+ const context = useAudioDeviceSelect();
67
+ return /* @__PURE__ */ jsx(SelectTrigger, {
68
+ "data-control-ui": "audio-device-select",
69
+ "data-slot": "trigger",
70
+ "data-loading": context.loading ? "true" : void 0,
71
+ "data-permission": context.permission,
72
+ "data-missing": context.missing ? "true" : void 0,
73
+ "aria-label": context.label,
74
+ "aria-busy": context.loading || void 0,
75
+ className: cn("w-full", className),
76
+ ...props,
77
+ children: children ?? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(MicIcon, {
78
+ "aria-hidden": "true",
79
+ className: "size-4 shrink-0"
80
+ }), /* @__PURE__ */ jsx(AudioDeviceSelectValue, {})] })
81
+ });
82
+ }
83
+ function AudioDeviceSelectValue({ placeholder = "Select a microphone" }) {
84
+ const context = useAudioDeviceSelect();
85
+ let text = placeholder;
86
+ if (context.selected) text = deviceLabel(context.selected, context.devices.indexOf(context.selected));
87
+ if (context.allowNone && context.value === "") text = context.noneLabel;
88
+ if (context.missing) text = `${context.selected ? deviceLabel(context.selected) : "Unknown device"} (disconnected)`;
89
+ if (context.loading) text = "Finding devices…";
90
+ return /* @__PURE__ */ jsx("span", {
91
+ "data-control-ui": "audio-device-select",
92
+ "data-slot": "value",
93
+ className: "min-w-0 truncate",
94
+ title: text,
95
+ children: text
96
+ });
97
+ }
98
+ function AudioDeviceSelectContent({ children, ...props }) {
99
+ const context = useAudioDeviceSelect();
100
+ return /* @__PURE__ */ jsx(SelectContent, {
101
+ "data-control-ui": "audio-device-select",
102
+ "data-slot": "content",
103
+ ...props,
104
+ children: children ?? /* @__PURE__ */ jsxs(Fragment$1, { children: [
105
+ context.allowNone ? /* @__PURE__ */ jsx(SelectItem, {
106
+ value: "",
107
+ children: context.noneLabel
108
+ }) : null,
109
+ context.missing ? /* @__PURE__ */ jsx(SelectItem, {
110
+ value: context.value ?? "",
111
+ disabled: true,
112
+ children: /* @__PURE__ */ jsx(AudioDeviceSelectValue, {})
113
+ }) : null,
114
+ context.devices.filter((device) => !context.allowNone || device.deviceId !== "").map((device, index) => /* @__PURE__ */ jsxs(AudioDeviceSelectItem, {
115
+ value: device.deviceId,
116
+ label: deviceLabel(device, index),
117
+ disabled: device.status === "unavailable" || device.status === "permission-required",
118
+ children: [/* @__PURE__ */ jsx("span", {
119
+ className: "min-w-0 flex-1 truncate",
120
+ children: deviceLabel(device, index)
121
+ }), device.isDefault || device.deviceId === "default" ? /* @__PURE__ */ jsx("span", {
122
+ className: "ms-3 text-caption text-muted-foreground",
123
+ children: "Default"
124
+ }) : null]
125
+ }, device.deviceId)),
126
+ context.devices.length === 0 && !context.missing && !context.allowNone ? /* @__PURE__ */ jsx(SelectItem, {
127
+ value: "__audio-device-empty__",
128
+ disabled: true,
129
+ children: context.permission === "granted" ? "No microphones found" : "Allow microphone access to find devices"
130
+ }) : null
131
+ ] })
132
+ });
133
+ }
134
+ function AudioDeviceSelectItem(props) {
135
+ return /* @__PURE__ */ jsx(SelectItem, {
136
+ "data-control-ui": "audio-device-select",
137
+ "data-slot": "item",
138
+ ...props
139
+ });
140
+ }
141
+ function AudioDeviceSelectPreview({ className, ...props }) {
142
+ return /* @__PURE__ */ jsx("div", {
143
+ "data-control-ui": "audio-device-select",
144
+ "data-slot": "preview",
145
+ className: cn("min-w-0", className),
146
+ ...props
147
+ });
148
+ }
149
+ function AudioDeviceSelectStatus({ className, ...props }) {
150
+ const context = useAudioDeviceSelect();
151
+ let message = context.error;
152
+ if (!message && context.permission === "denied") message = "Microphone access is blocked. Allow it in your browser’s site settings.";
153
+ if (!message && context.missing) message = "This microphone is disconnected. Reconnect it or choose another device.";
154
+ if (!message) return null;
155
+ return /* @__PURE__ */ jsx("p", {
156
+ "data-control-ui": "audio-device-select",
157
+ "data-control-family": "audio-device-select",
158
+ "data-slot": "status",
159
+ role: "status",
160
+ className: cn("text-caption", className),
161
+ ...props,
162
+ children: message
163
+ });
164
+ }
165
+ function AudioDeviceSelectPermission({ children = "Allow microphone access", onClick, ...props }) {
166
+ const context = useAudioDeviceSelect();
167
+ if (context.permission !== "prompt" || !context.onRequestPermission) return null;
168
+ return /* @__PURE__ */ jsx(Button, {
169
+ "data-control-ui": "audio-device-select",
170
+ "data-slot": "permission",
171
+ variant: "surface",
172
+ size: "sm",
173
+ disabled: context.disabled || context.loading,
174
+ onClick: (event) => {
175
+ onClick?.(event);
176
+ if (!event.defaultPrevented) context.onRequestPermission?.();
177
+ },
178
+ ...props,
179
+ children
180
+ });
181
+ }
182
+ //#endregion
183
+ export { AudioDeviceSelect, AudioDeviceSelectContent, AudioDeviceSelectItem, AudioDeviceSelectPermission, AudioDeviceSelectPreview, AudioDeviceSelectStatus, AudioDeviceSelectTrigger, AudioDeviceSelectValue };
@@ -0,0 +1,10 @@
1
+ import { AudioVisualizerProps } from "./audio-visualizer.js";
2
+ //#region src/audio-visualizer-bar.d.ts
3
+ export type BarAudioVisualizerProps = AudioVisualizerProps & {
4
+ align?: "center" | "start" | "end";
5
+ mirrored?: boolean;
6
+ idle?: "static" | "pulse" | "wave";
7
+ loading?: boolean;
8
+ };
9
+ export declare function AudioVisualizer({ levels, points, active, align, mirrored, idle, loading, className, style, ...props }: BarAudioVisualizerProps): import("react").JSX.Element;
10
+ //#endregion
@@ -0,0 +1,49 @@
1
+ import { cn } from "./lib/cn.js";
2
+ import { audioVisualizerBands } from "./lib/audio-visualizer-levels.js";
3
+ import { jsx } from "react/jsx-runtime";
4
+ //#region src/audio-visualizer-bar.tsx
5
+ function AudioVisualizer({ levels, points, active = true, align = "center", mirrored = false, idle = "static", loading = false, className, style, ...props }) {
6
+ const visible = audioVisualizerBands(levels, points, active && !loading, mirrored);
7
+ const hasSignal = active && !loading && visible.some(({ level }) => level > 0);
8
+ const labelled = Boolean(props["aria-label"] || props["aria-labelledby"]);
9
+ return /* @__PURE__ */ jsx("div", {
10
+ "data-control-ui": "audio-visualizer",
11
+ "data-control-family": "audio-visualizer",
12
+ "data-slot": "root",
13
+ "data-variant": "bar",
14
+ "data-align": align,
15
+ "data-mirrored": mirrored ? "true" : void 0,
16
+ "data-active": hasSignal ? "true" : void 0,
17
+ "data-loading": loading ? "true" : void 0,
18
+ "data-idle": idle,
19
+ role: labelled ? "img" : void 0,
20
+ "aria-hidden": labelled ? void 0 : true,
21
+ "aria-busy": loading || void 0,
22
+ className: cn("shrink-0 overflow-hidden", className),
23
+ style,
24
+ ...props,
25
+ children: /* @__PURE__ */ jsx("span", {
26
+ "data-control-ui": "audio-visualizer",
27
+ "data-control-family": "audio-visualizer",
28
+ "data-slot": "track",
29
+ "data-active": hasSignal || loading ? "true" : void 0,
30
+ children: visible.map(({ key, level }, index) => {
31
+ const amplitude = Math.sqrt(level);
32
+ const barStyle = {
33
+ height: `${(.08 + amplitude * .92) * 100}%`,
34
+ "--_audio-visualizer-level-opacity": `${.48 + amplitude * .52}`,
35
+ "--_audio-visualizer-delay": `${-index * 70}ms`
36
+ };
37
+ return /* @__PURE__ */ jsx("span", {
38
+ "data-control-ui": "audio-visualizer",
39
+ "data-control-family": "audio-visualizer",
40
+ "data-slot": "bar",
41
+ "data-index": index,
42
+ style: barStyle
43
+ }, key);
44
+ })
45
+ })
46
+ });
47
+ }
48
+ //#endregion
49
+ export { AudioVisualizer };
@@ -0,0 +1,4 @@
1
+ import { AudioVisualizerProps } from "./audio-visualizer.js";
2
+ //#region src/audio-visualizer-line.d.ts
3
+ export declare function AudioVisualizer({ levels, points, active, className, style, ...props }: AudioVisualizerProps): import("react").JSX.Element;
4
+ //#endregion
@@ -0,0 +1,75 @@
1
+ import { cn } from "./lib/cn.js";
2
+ import { audioVisualizerHistory } from "./lib/audio-visualizer-levels.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/audio-visualizer-line.tsx
5
+ const LINE_VIEWBOX_WIDTH = 100;
6
+ const LINE_VIEWBOX_HEIGHT = 32;
7
+ const LINE_CENTER = LINE_VIEWBOX_HEIGHT / 2;
8
+ const CURVE_TENSION = .18;
9
+ function smoothPath(points) {
10
+ const [first, ...rest] = points;
11
+ if (!first) return "";
12
+ return rest.reduce((path, point, index) => {
13
+ const previous = points[index] ?? first;
14
+ const controlOffset = (point.x - previous.x) * CURVE_TENSION;
15
+ return `${path} C ${(previous.x + controlOffset).toFixed(2)} ${previous.y.toFixed(2)}, ${(point.x - controlOffset).toFixed(2)} ${point.y.toFixed(2)}, ${point.x.toFixed(2)} ${point.y.toFixed(2)}`;
16
+ }, `M ${first.x.toFixed(2)} ${first.y.toFixed(2)}`);
17
+ }
18
+ function envelopePath(levels) {
19
+ const envelope = levels.length === 1 ? [levels[0] ?? 0, levels[0] ?? 0] : levels;
20
+ const step = LINE_VIEWBOX_WIDTH / Math.max(1, envelope.length - 1);
21
+ const upper = envelope.map((level, index) => ({
22
+ x: index * step,
23
+ y: 16 - Math.sqrt(level) * 13
24
+ }));
25
+ const lowerReversed = [...envelope.map((level, index) => ({
26
+ x: index * step,
27
+ y: 16 + Math.sqrt(level) * 13
28
+ }))].reverse();
29
+ return `${smoothPath(upper)} L ${lowerReversed[0]?.x.toFixed(2) ?? 0} ${lowerReversed[0]?.y.toFixed(2) ?? LINE_CENTER}${smoothPath(lowerReversed).replace(/^M [^C]+/, "")} Z`;
30
+ }
31
+ function AudioVisualizer({ levels, points, active = true, className, style, ...props }) {
32
+ const path = envelopePath(audioVisualizerHistory(levels, points, active).map(({ level }) => level));
33
+ const pathStyle = { d: `path('${path}')` };
34
+ const labelled = Boolean(props["aria-label"] || props["aria-labelledby"]);
35
+ return /* @__PURE__ */ jsx("div", {
36
+ "data-control-ui": "audio-visualizer",
37
+ "data-control-family": "audio-visualizer",
38
+ "data-slot": "root",
39
+ "data-variant": "line",
40
+ "data-active": active ? "true" : void 0,
41
+ role: labelled ? "img" : void 0,
42
+ "aria-hidden": labelled ? void 0 : true,
43
+ className: cn("shrink-0 overflow-hidden", className),
44
+ style,
45
+ ...props,
46
+ children: /* @__PURE__ */ jsxs("svg", {
47
+ "data-control-ui": "audio-visualizer",
48
+ "data-control-family": "audio-visualizer",
49
+ "data-slot": "track",
50
+ "data-active": active ? "true" : void 0,
51
+ viewBox: `0 0 ${LINE_VIEWBOX_WIDTH} ${LINE_VIEWBOX_HEIGHT}`,
52
+ preserveAspectRatio: "none",
53
+ "aria-hidden": "true",
54
+ className: "size-full overflow-visible",
55
+ children: [/* @__PURE__ */ jsx("path", {
56
+ "data-control-ui": "audio-visualizer",
57
+ "data-control-family": "audio-visualizer",
58
+ "data-slot": "baseline",
59
+ d: `M 0 ${LINE_CENTER} H ${LINE_VIEWBOX_WIDTH}`,
60
+ vectorEffect: "non-scaling-stroke"
61
+ }), /* @__PURE__ */ jsx("path", {
62
+ "data-control-ui": "audio-visualizer-line",
63
+ "data-control-family": "audio-visualizer",
64
+ "data-slot": "waveform",
65
+ d: path,
66
+ style: pathStyle,
67
+ strokeWidth: "1",
68
+ strokeLinejoin: "round",
69
+ vectorEffect: "non-scaling-stroke"
70
+ })]
71
+ })
72
+ });
73
+ }
74
+ //#endregion
75
+ export { AudioVisualizer };
@@ -2,7 +2,7 @@ import { AudioVisualizerKnobStyle } from "./knob-contracts/audio-visualizer-knob
2
2
  import { CSSProperties, ComponentProps } from "react";
3
3
  //#region src/audio-visualizer.d.ts
4
4
  export type AudioVisualizerProps = Omit<ComponentProps<"div">, "children" | "style"> & {
5
- levels: readonly number[];
5
+ levels: ArrayLike<number>;
6
6
  active?: boolean;
7
7
  points?: number;
8
8
  style?: CSSProperties & AudioVisualizerKnobStyle;
@@ -1,47 +1,74 @@
1
+ "use client";
1
2
  import { cn } from "./lib/cn.js";
3
+ import { audioVisualizerHistory } from "./lib/audio-visualizer-levels.js";
4
+ import { useLayoutEffect, useRef } from "react";
2
5
  import { jsx } from "react/jsx-runtime";
3
6
  //#region src/audio-visualizer.tsx
4
- const DEFAULT_POINTS = 28;
5
- const MAX_POINTS = 128;
6
- const MIN_VISIBLE_LEVEL = .12;
7
- function resolveAudioVisualizerLevels(levels, points) {
8
- const pointCount = Math.min(MAX_POINTS, Math.max(1, Number.isFinite(points) ? Math.floor(points ?? DEFAULT_POINTS) : DEFAULT_POINTS));
9
- const visible = levels.slice(-pointCount).map((level) => Number.isFinite(level) ? Math.min(1, Math.max(0, level)) : 0);
10
- return (visible.length === pointCount ? visible : [...new Array(pointCount - visible.length).fill(0), ...visible]).map((level, position) => ({
11
- key: `bar-${position}`,
12
- level
13
- }));
7
+ const MIN_VISIBLE_LEVEL = .04;
8
+ function historyAdvanced(previous, next) {
9
+ if (!previous?.active || !next.active || previous.levels.length !== next.levels.length || next.levels.length < 2) return false;
10
+ const moved = previous.levels.slice(1).every((level, index) => level === next.levels[index]);
11
+ const changed = previous.levels.some((level, index) => level !== next.levels[index]);
12
+ return moved && changed;
14
13
  }
15
14
  function AudioVisualizer({ levels, points, active = true, className, style, ...props }) {
16
- const visible = resolveAudioVisualizerLevels(levels, points);
15
+ const visible = audioVisualizerHistory(levels, points, active);
16
+ const labelled = Boolean(props["aria-label"] || props["aria-labelledby"]);
17
+ const trackRef = useRef(null);
18
+ const firstBarRef = useRef(null);
19
+ const previousSample = useRef(null);
20
+ useLayoutEffect(() => {
21
+ const next = {
22
+ levels: visible.map(({ level }) => level),
23
+ time: performance.now(),
24
+ active
25
+ };
26
+ const previous = previousSample.current;
27
+ previousSample.current = next;
28
+ const track = trackRef.current;
29
+ const firstBar = firstBarRef.current;
30
+ if (!track || !firstBar || !historyAdvanced(previous, next) || document.hidden) return;
31
+ const duration = next.time - (previous?.time ?? next.time);
32
+ if (duration < 16 || duration > 250 || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
33
+ const direction = getComputedStyle(track).direction === "rtl" ? -1 : 1;
34
+ const pitch = (track.clientWidth - firstBar.clientWidth) / (visible.length - 1);
35
+ const animation = track.animate([{ transform: `translateX(${pitch * direction}px)` }, { transform: "translateX(0)" }], {
36
+ duration,
37
+ easing: "linear"
38
+ });
39
+ return () => animation.cancel();
40
+ }, [visible, active]);
17
41
  return /* @__PURE__ */ jsx("div", {
18
42
  "data-control-ui": "audio-visualizer",
19
43
  "data-control-family": "audio-visualizer",
20
44
  "data-slot": "root",
21
45
  "data-variant": "bars",
22
46
  "data-active": active ? "true" : void 0,
23
- "aria-hidden": "true",
24
- className: cn("shrink-0 overflow-hidden", className),
47
+ role: labelled ? "img" : void 0,
48
+ "aria-hidden": labelled ? void 0 : true,
49
+ className: cn("shrink-0 overflow-hidden mask-x-from-90%", className),
25
50
  style,
26
51
  ...props,
27
52
  children: /* @__PURE__ */ jsx("span", {
53
+ ref: trackRef,
28
54
  "data-control-ui": "audio-visualizer",
29
55
  "data-control-family": "audio-visualizer",
30
56
  "data-slot": "track",
31
57
  "data-active": active ? "true" : void 0,
32
- className: "flex size-full items-stretch justify-end mask-l-from-90% rtl:mask-l-from-100% rtl:mask-r-from-90%",
58
+ className: "flex size-full items-stretch justify-between",
33
59
  children: visible.map(({ key, level }) => {
34
60
  const perceptualLevel = Math.sqrt(level);
35
- const visibleLevel = MIN_VISIBLE_LEVEL + perceptualLevel * .88;
61
+ const visibleLevel = MIN_VISIBLE_LEVEL + perceptualLevel * .96;
36
62
  const levelStyle = {
37
63
  "--_audio-visualizer-level-opacity": `${.48 + perceptualLevel * .52}`,
38
64
  clipPath: `inset(${(1 - visibleLevel) * 50}% 0 round var(--cui-audio-visualizer-bar-radius))`
39
65
  };
40
66
  return /* @__PURE__ */ jsx("span", {
67
+ ref: key === "bar-0" ? firstBarRef : void 0,
41
68
  "data-control-ui": "audio-visualizer",
42
69
  "data-control-family": "audio-visualizer",
43
70
  "data-slot": "bar-track",
44
- className: "flex shrink-0 items-center",
71
+ className: "flex items-center",
45
72
  children: /* @__PURE__ */ jsx("span", {
46
73
  "data-control-ui": "audio-visualizer",
47
74
  "data-control-family": "audio-visualizer",
@@ -37,6 +37,7 @@ export type UseAudioInputDevicesResult = {
37
37
  loading: boolean;
38
38
  error: Error | null;
39
39
  hasPermission: boolean;
40
+ permission: "granted" | "prompt" | "denied";
40
41
  refresh: () => Promise<void>;
41
42
  requestPermission: () => Promise<void>;
42
43
  };
@@ -27,14 +27,14 @@ function useAudioInputDevices() {
27
27
  const [devices, setDevices] = useState([]);
28
28
  const [loading, setLoading] = useState(false);
29
29
  const [error, setError] = useState(null);
30
- const [hasPermission, setHasPermission] = useState(false);
30
+ const [permission, setPermission] = useState("prompt");
31
31
  async function refresh() {
32
32
  setLoading(true);
33
33
  setError(null);
34
34
  try {
35
35
  const nextDevices = await enumerateAudioInputDevices();
36
36
  setDevices(nextDevices);
37
- if (nextDevices.some((device) => device.label)) setHasPermission(true);
37
+ if (nextDevices.some((device) => device.label)) setPermission("granted");
38
38
  } catch (nextError) {
39
39
  setError(asError(nextError));
40
40
  } finally {
@@ -51,9 +51,11 @@ function useAudioInputDevices() {
51
51
  try {
52
52
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
53
53
  for (const track of stream.getTracks()) track.stop();
54
- setHasPermission(true);
54
+ setPermission("granted");
55
55
  setDevices(await enumerateAudioInputDevices());
56
56
  } catch (nextError) {
57
+ const failure = asError(nextError);
58
+ if (failure.name === "NotAllowedError" || failure.name === "SecurityError") setPermission("denied");
57
59
  setError(microphoneError(nextError));
58
60
  } finally {
59
61
  setLoading(false);
@@ -63,6 +65,26 @@ function useAudioInputDevices() {
63
65
  useEffect(() => {
64
66
  queueMicrotask(() => void refreshFromEffect());
65
67
  }, []);
68
+ useEffect(() => {
69
+ if (typeof navigator === "undefined" || !navigator.permissions?.query) return;
70
+ let disposed = false;
71
+ let status;
72
+ function updatePermission() {
73
+ if (disposed || !status) return;
74
+ setPermission(status.state);
75
+ refreshFromEffect();
76
+ }
77
+ navigator.permissions.query({ name: "microphone" }).then((nextStatus) => {
78
+ if (disposed) return;
79
+ status = nextStatus;
80
+ setPermission(nextStatus.state);
81
+ status.addEventListener("change", updatePermission);
82
+ }).catch(() => {});
83
+ return () => {
84
+ disposed = true;
85
+ status?.removeEventListener("change", updatePermission);
86
+ };
87
+ }, []);
66
88
  useEffect(() => {
67
89
  if (typeof navigator === "undefined" || !navigator.mediaDevices?.addEventListener) return;
68
90
  const handleDeviceChange = () => {
@@ -75,7 +97,8 @@ function useAudioInputDevices() {
75
97
  devices,
76
98
  loading,
77
99
  error,
78
- hasPermission,
100
+ hasPermission: permission === "granted",
101
+ permission,
79
102
  refresh,
80
103
  requestPermission
81
104
  };
@@ -23,8 +23,6 @@ function useColorArea(onChange, targetRef) {
23
23
  function onEnd() {
24
24
  setDragging(false);
25
25
  }
26
- const previousSelect = document.body.style.userSelect;
27
- document.body.style.userSelect = "none";
28
26
  window.addEventListener("pointermove", onMove);
29
27
  window.addEventListener("pointerup", onEnd);
30
28
  window.addEventListener("pointercancel", onEnd);
@@ -32,7 +30,6 @@ function useColorArea(onChange, targetRef) {
32
30
  window.removeEventListener("pointermove", onMove);
33
31
  window.removeEventListener("pointerup", onEnd);
34
32
  window.removeEventListener("pointercancel", onEnd);
35
- document.body.style.userSelect = previousSelect;
36
33
  };
37
34
  }, [dragging, areaRef]);
38
35
  function onPointerDown(event) {
@@ -48,16 +48,19 @@ function useTextareaTriggerMenu(ref, options) {
48
48
  function onKeyDown(event) {
49
49
  handleKeyDown(event);
50
50
  }
51
+ function syncAfterInput() {
52
+ window.setTimeout(syncTrigger, 0);
53
+ }
51
54
  function onBlur() {
52
55
  window.setTimeout(closeAfterBlur, 120);
53
56
  }
54
- element.addEventListener("input", syncTrigger);
57
+ element.addEventListener("input", syncAfterInput);
55
58
  element.addEventListener("keydown", onKeyDown);
56
59
  element.addEventListener("keyup", syncTrigger);
57
60
  element.addEventListener("click", syncTrigger);
58
61
  element.addEventListener("blur", onBlur);
59
62
  return () => {
60
- element.removeEventListener("input", syncTrigger);
63
+ element.removeEventListener("input", syncAfterInput);
61
64
  element.removeEventListener("keydown", onKeyDown);
62
65
  element.removeEventListener("keyup", syncTrigger);
63
66
  element.removeEventListener("click", syncTrigger);
@@ -0,0 +1,4 @@
1
+ //#region src/knob-contracts/audio-device-select-knobs.d.ts
2
+ export declare const audioDeviceSelectKnobs: readonly ["--cui-audio-device-select-status-foreground"];
3
+ export type AudioDeviceSelectKnobStyle = Partial<Record<(typeof audioDeviceSelectKnobs)[number], string>>;
4
+ //#endregion
@@ -0,0 +1,4 @@
1
+ //#region src/knob-contracts/audio-device-select-knobs.ts
2
+ const audioDeviceSelectKnobs = ["--cui-audio-device-select-status-foreground"];
3
+ //#endregion
4
+ export { audioDeviceSelectKnobs };
@@ -1,4 +1,4 @@
1
1
  //#region src/knob-contracts/audio-visualizer-knobs.d.ts
2
- export declare const audioVisualizerKnobs: readonly ["--cui-audio-visualizer-radius", "--cui-audio-visualizer-bar-radius", "--cui-audio-visualizer-bar-background", "--cui-audio-visualizer-line-fill", "--cui-audio-visualizer-line-stroke", "--cui-audio-visualizer-baseline-stroke"];
2
+ export declare const audioVisualizerKnobs: readonly ["--cui-audio-visualizer-bar-size", "--cui-audio-visualizer-bar-gap", "--cui-audio-visualizer-radius", "--cui-audio-visualizer-bar-radius", "--cui-audio-visualizer-bar-background", "--cui-audio-visualizer-line-fill", "--cui-audio-visualizer-line-stroke", "--cui-audio-visualizer-baseline-stroke"];
3
3
  export type AudioVisualizerKnobStyle = Partial<Record<(typeof audioVisualizerKnobs)[number], string>>;
4
4
  //#endregion
@@ -1,5 +1,7 @@
1
1
  //#region src/knob-contracts/audio-visualizer-knobs.ts
2
2
  const audioVisualizerKnobs = [
3
+ "--cui-audio-visualizer-bar-size",
4
+ "--cui-audio-visualizer-bar-gap",
3
5
  "--cui-audio-visualizer-radius",
4
6
  "--cui-audio-visualizer-bar-radius",
5
7
  "--cui-audio-visualizer-bar-background",
@@ -0,0 +1,12 @@
1
+ //#region src/lib/audio-visualizer-levels.d.ts
2
+ export declare function audioVisualizerPointCount(points?: number): number;
3
+ export declare function normalizeAudioLevel(level: number): number;
4
+ export declare function audioVisualizerHistory(levels: ArrayLike<number>, points?: number, active?: boolean): {
5
+ key: string;
6
+ level: number;
7
+ }[];
8
+ export declare function audioVisualizerBands(levels: ArrayLike<number>, points?: number, active?: boolean, mirrored?: boolean): {
9
+ key: string;
10
+ level: number;
11
+ }[];
12
+ //#endregion