@ctrl-ui/react 0.9.1 → 0.9.3

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.
@@ -10,8 +10,10 @@ export type AudioDevice = {
10
10
  status?: "available" | "unavailable" | "permission-required";
11
11
  };
12
12
  export type AudioDevicePermission = "granted" | "prompt" | "denied";
13
+ export type AudioDeviceKind = "input" | "output";
13
14
  export type AudioDeviceSelectProps = Omit<SelectProps, "items" | "children"> & {
14
15
  devices: readonly AudioDevice[];
16
+ kind?: AudioDeviceKind;
15
17
  loading?: boolean;
16
18
  permission?: AudioDevicePermission;
17
19
  onRequestPermission?: () => void | Promise<void>;
@@ -22,7 +24,7 @@ export type AudioDeviceSelectProps = Omit<SelectProps, "items" | "children"> & {
22
24
  className?: string;
23
25
  children?: ReactNode;
24
26
  };
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;
27
+ export declare function AudioDeviceSelect({ devices, kind, value, defaultValue, onValueChange, disabled, loading, permission, onRequestPermission, allowNone, noneLabel, label, error, className, children, ...props }: AudioDeviceSelectProps): import("react").JSX.Element;
26
28
  export declare function AudioDeviceSelectTrigger({ children, className, ...props }: SelectTriggerProps): import("react").JSX.Element;
27
29
  export declare function AudioDeviceSelectValue({ placeholder }: {
28
30
  placeholder?: string;
@@ -5,18 +5,36 @@ import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select.js
5
5
  import { Text } from "./ui/typography.js";
6
6
  import { createContext, use, useState } from "react";
7
7
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
- import { MicIcon } from "lucide-react";
8
+ import { MicIcon, Volume2Icon } from "lucide-react";
9
9
  //#region src/audio-device-select.tsx
10
+ const AUDIO_DEVICE_COPY = {
11
+ input: {
12
+ label: "Microphone",
13
+ fallbackName: "Microphone",
14
+ placeholder: "Select a microphone",
15
+ noneLabel: "No microphone",
16
+ empty: "No microphones found",
17
+ disconnected: "This microphone is disconnected. Reconnect it or choose another device."
18
+ },
19
+ output: {
20
+ label: "Output",
21
+ fallbackName: "Speaker",
22
+ placeholder: "Select an output",
23
+ noneLabel: "No output",
24
+ empty: "No outputs found",
25
+ disconnected: "This output is disconnected. Reconnect it or choose another device."
26
+ }
27
+ };
10
28
  const AudioDeviceSelectContext = createContext(null);
11
29
  function useAudioDeviceSelect() {
12
30
  const context = use(AudioDeviceSelectContext);
13
31
  if (!context) throw new Error("AudioDeviceSelect parts must be rendered inside AudioDeviceSelect.");
14
32
  return context;
15
33
  }
16
- function deviceLabel(device, index = 0) {
17
- return device.label.trim() || `Microphone ${index + 1}`;
34
+ function deviceLabel(device, kind, index = 0) {
35
+ return device.label.trim() || `${AUDIO_DEVICE_COPY[kind].fallbackName} ${index + 1}`;
18
36
  }
19
- 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 }) {
37
+ function AudioDeviceSelect({ devices, kind = "input", value, defaultValue, onValueChange, disabled = false, loading = false, permission = "granted", onRequestPermission, allowNone = false, noneLabel = AUDIO_DEVICE_COPY[kind].noneLabel, label = AUDIO_DEVICE_COPY[kind].label, error = null, className, children, ...props }) {
20
38
  const [internalValue, setInternalValue] = useState(() => defaultValue ?? null);
21
39
  const current = value === void 0 ? internalValue : value;
22
40
  const connected = devices.find((device) => device.deviceId === current) ?? null;
@@ -30,6 +48,7 @@ function AudioDeviceSelect({ devices, value, defaultValue, onValueChange, disabl
30
48
  }
31
49
  return /* @__PURE__ */ jsx(AudioDeviceSelectContext.Provider, {
32
50
  value: {
51
+ kind,
33
52
  devices,
34
53
  selected,
35
54
  value: current,
@@ -47,7 +66,7 @@ function AudioDeviceSelect({ devices, value, defaultValue, onValueChange, disabl
47
66
  "data-control-ui": "audio-device-select",
48
67
  "data-control-family": "audio-device-select",
49
68
  "data-slot": "root",
50
- className: cn("grid min-w-0 gap-2", className),
69
+ className: cn("grid min-w-0 grid-cols-[minmax(0,1fr)] gap-2", className),
51
70
  children: /* @__PURE__ */ jsx(Select, {
52
71
  value: current,
53
72
  onValueChange: handleValueChange,
@@ -75,18 +94,21 @@ function AudioDeviceSelectTrigger({ children, className, ...props }) {
75
94
  "aria-busy": context.loading || void 0,
76
95
  className: cn("w-full", className),
77
96
  ...props,
78
- children: children ?? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(MicIcon, {
97
+ children: children ?? /* @__PURE__ */ jsxs(Fragment$1, { children: [context.kind === "output" ? /* @__PURE__ */ jsx(Volume2Icon, {
98
+ "aria-hidden": "true",
99
+ className: "size-4 shrink-0"
100
+ }) : /* @__PURE__ */ jsx(MicIcon, {
79
101
  "aria-hidden": "true",
80
102
  className: "size-4 shrink-0"
81
103
  }), /* @__PURE__ */ jsx(AudioDeviceSelectValue, {})] })
82
104
  });
83
105
  }
84
- function AudioDeviceSelectValue({ placeholder = "Select a microphone" }) {
106
+ function AudioDeviceSelectValue({ placeholder }) {
85
107
  const context = useAudioDeviceSelect();
86
- let text = placeholder;
87
- if (context.selected) text = deviceLabel(context.selected, context.devices.indexOf(context.selected));
108
+ let text = placeholder ?? AUDIO_DEVICE_COPY[context.kind].placeholder;
109
+ if (context.selected) text = deviceLabel(context.selected, context.kind, context.devices.indexOf(context.selected));
88
110
  if (context.allowNone && context.value === "") text = context.noneLabel;
89
- if (context.missing) text = `${context.selected ? deviceLabel(context.selected) : "Unknown device"} (disconnected)`;
111
+ if (context.missing) text = `${context.selected ? deviceLabel(context.selected, context.kind) : "Unknown device"} (disconnected)`;
90
112
  if (context.loading) text = "Finding devices…";
91
113
  return /* @__PURE__ */ jsx("span", {
92
114
  "data-control-ui": "audio-device-select",
@@ -114,11 +136,11 @@ function AudioDeviceSelectContent({ children, ...props }) {
114
136
  }) : null,
115
137
  context.devices.filter((device) => !context.allowNone || device.deviceId !== "").map((device, index) => /* @__PURE__ */ jsxs(AudioDeviceSelectItem, {
116
138
  value: device.deviceId,
117
- label: deviceLabel(device, index),
139
+ label: deviceLabel(device, context.kind, index),
118
140
  disabled: device.status === "unavailable" || device.status === "permission-required",
119
141
  children: [/* @__PURE__ */ jsx("span", {
120
142
  className: "min-w-0 flex-1 truncate",
121
- children: deviceLabel(device, index)
143
+ children: deviceLabel(device, context.kind, index)
122
144
  }), device.isDefault || device.deviceId === "default" ? /* @__PURE__ */ jsx(Text, {
123
145
  size: "caption",
124
146
  tone: "muted",
@@ -129,7 +151,7 @@ function AudioDeviceSelectContent({ children, ...props }) {
129
151
  context.devices.length === 0 && !context.missing && !context.allowNone ? /* @__PURE__ */ jsx(SelectItem, {
130
152
  value: "__audio-device-empty__",
131
153
  disabled: true,
132
- children: context.permission === "granted" ? "No microphones found" : "Allow microphone access to find devices"
154
+ children: context.permission === "granted" ? AUDIO_DEVICE_COPY[context.kind].empty : "Allow microphone access to find devices"
133
155
  }) : null
134
156
  ] })
135
157
  });
@@ -153,7 +175,7 @@ function AudioDeviceSelectStatus({ className, ...props }) {
153
175
  const context = useAudioDeviceSelect();
154
176
  let message = context.error;
155
177
  if (!message && context.permission === "denied") message = "Microphone access is blocked. Allow it in your browser’s site settings.";
156
- if (!message && context.missing) message = "This microphone is disconnected. Reconnect it or choose another device.";
178
+ if (!message && context.missing) message = AUDIO_DEVICE_COPY[context.kind].disconnected;
157
179
  if (!message) return null;
158
180
  return /* @__PURE__ */ jsx("p", {
159
181
  "data-control-ui": "audio-device-select",
@@ -0,0 +1,4 @@
1
+ //#region src/knob-contracts/channel-strip-knobs.d.ts
2
+ export declare const channelStripKnobs: readonly ["--cui-channel-strip-size", "--cui-channel-strip-body-height", "--cui-channel-strip-separator-color", "--cui-channel-strip-value-foreground"];
3
+ export type ChannelStripKnobStyle = Partial<Record<(typeof channelStripKnobs)[number], string>>;
4
+ //#endregion
@@ -0,0 +1,9 @@
1
+ //#region src/knob-contracts/channel-strip-knobs.ts
2
+ const channelStripKnobs = [
3
+ "--cui-channel-strip-size",
4
+ "--cui-channel-strip-body-height",
5
+ "--cui-channel-strip-separator-color",
6
+ "--cui-channel-strip-value-foreground"
7
+ ];
8
+ //#endregion
9
+ export { channelStripKnobs };
@@ -1,4 +1,4 @@
1
1
  //#region src/knob-contracts/range-knobs.d.ts
2
- export declare const rangeKnobs: readonly ["--cui-range-level-warning-background", "--cui-range-level-clip-background", "--cui-range-indeterminate-size", "--cui-range-track-radius", "--cui-range-track-background", "--cui-range-indicator-radius", "--cui-range-indicator-background", "--cui-range-thumb-radius", "--cui-range-thumb-background", "--cui-range-thumb-border-color", "--cui-range-thumb-shadow"];
2
+ export declare const rangeKnobs: readonly ["--cui-range-level-warning-background", "--cui-range-level-clip-background", "--cui-range-level-size", "--cui-range-level-scale-foreground", "--cui-range-indeterminate-size", "--cui-range-track-radius", "--cui-range-track-background", "--cui-range-indicator-radius", "--cui-range-indicator-background", "--cui-range-thumb-radius", "--cui-range-thumb-background", "--cui-range-thumb-border-color", "--cui-range-thumb-shadow"];
3
3
  export type RangeKnobStyle = Partial<Record<(typeof rangeKnobs)[number], string>>;
4
4
  //#endregion
@@ -2,6 +2,8 @@
2
2
  const rangeKnobs = [
3
3
  "--cui-range-level-warning-background",
4
4
  "--cui-range-level-clip-background",
5
+ "--cui-range-level-size",
6
+ "--cui-range-level-scale-foreground",
5
7
  "--cui-range-indeterminate-size",
6
8
  "--cui-range-track-radius",
7
9
  "--cui-range-track-background",
@@ -1,7 +1,7 @@
1
1
  //#region src/lib/level-meter-ballistics.d.ts
2
2
  export declare const LEVEL_METER_RELEASE_DB_PER_SECOND = 24;
3
3
  export declare const LEVEL_METER_PEAK_HOLD_MS = 1000;
4
- export type LevelMeterScale = {
4
+ export type LevelMeterRange = {
5
5
  minDb: number;
6
6
  maxDb: number;
7
7
  };
@@ -10,8 +10,8 @@ export type LevelMeterReading = {
10
10
  peakDb: number;
11
11
  peakHeldUntilMs: number;
12
12
  };
13
- export declare function clampLevelDb(levelDb: number, { minDb, maxDb }: LevelMeterScale): number;
14
- export declare function levelMeterFraction(levelDb: number, scale: LevelMeterScale): number;
15
- export declare function advanceLevelMeterReading(reading: LevelMeterReading, targetDb: number, elapsedMs: number, nowMs: number, scale: LevelMeterScale): LevelMeterReading;
16
- export declare function levelMeterReadingSettled(reading: LevelMeterReading, targetDb: number, scale: LevelMeterScale): boolean;
13
+ export declare function clampLevelDb(levelDb: number, { minDb, maxDb }: LevelMeterRange): number;
14
+ export declare function levelMeterFraction(levelDb: number, scale: LevelMeterRange): number;
15
+ export declare function advanceLevelMeterReading(reading: LevelMeterReading, targetDb: number, elapsedMs: number, nowMs: number, scale: LevelMeterRange): LevelMeterReading;
16
+ export declare function levelMeterReadingSettled(reading: LevelMeterReading, targetDb: number, scale: LevelMeterRange): boolean;
17
17
  //#endregion
@@ -33,6 +33,7 @@
33
33
  @import "./recipes/button-group.css";
34
34
  @import "./recipes/calendar.css";
35
35
  @import "./recipes/card.css";
36
+ @import "./recipes/channel-strip.css";
36
37
  @import "./recipes/chart.css";
37
38
  @import "./recipes/chat-message.css";
38
39
  @import "./recipes/chat-message-edit.css";
@@ -0,0 +1,75 @@
1
+ @layer components {
2
+ :where([data-control-family="channel-strip"][data-slot="mixer"]) {
3
+ display: grid;
4
+ grid-auto-columns: max-content;
5
+ grid-auto-flow: column;
6
+ grid-template-rows: repeat(4, auto);
7
+ gap: calc(var(--spacing) * 2);
8
+ overflow-x: auto;
9
+ }
10
+
11
+ :where([data-control-family="channel-strip"][data-slot="root"]) {
12
+ --cui-channel-strip-size: calc(var(--spacing) * 24);
13
+ --cui-channel-strip-body-height: calc(var(--spacing) * 40);
14
+ --cui-channel-strip-separator-color: var(--border);
15
+ --cui-channel-strip-value-foreground: var(--muted-foreground);
16
+ inline-size: var(--cui-channel-strip-size);
17
+ display: grid;
18
+ grid-row: span 4;
19
+ grid-template-rows: subgrid;
20
+ row-gap: calc(var(--spacing) * 2);
21
+ }
22
+
23
+ :where([data-control-family="channel-strip"][data-slot="root"][data-variant="master"]) {
24
+ border-inline-start: 1px solid var(--cui-channel-strip-separator-color);
25
+ padding-inline-start: calc(var(--spacing) * 2);
26
+ }
27
+
28
+ :where([data-control-family="channel-strip"][data-slot="header"]) {
29
+ grid-row: 1;
30
+ gap: var(--spacing);
31
+ min-block-size: calc(var(--spacing) * 6);
32
+ }
33
+
34
+ :where([data-control-family="channel-strip"][data-slot="body"]) {
35
+ grid-row: 2;
36
+ gap: calc(var(--spacing) * 2);
37
+ block-size: var(--cui-channel-strip-body-height);
38
+ padding-block: calc(var(--spacing) * 2);
39
+ }
40
+
41
+ :where([data-control-family="channel-strip"][data-slot="value"]) {
42
+ grid-row: 3;
43
+ color: var(--cui-channel-strip-value-foreground);
44
+ text-align: center;
45
+ }
46
+
47
+ :where([data-control-family="channel-strip"][data-slot="controls"]) {
48
+ grid-row: 4;
49
+ gap: var(--spacing);
50
+ }
51
+ }
52
+
53
+ @property --cui-channel-strip-size {
54
+ syntax: "<length-percentage>";
55
+ inherits: true;
56
+ initial-value: 0px;
57
+ }
58
+
59
+ @property --cui-channel-strip-body-height {
60
+ syntax: "<length-percentage>";
61
+ inherits: true;
62
+ initial-value: 0px;
63
+ }
64
+
65
+ @property --cui-channel-strip-separator-color {
66
+ syntax: "<color>";
67
+ inherits: true;
68
+ initial-value: transparent;
69
+ }
70
+
71
+ @property --cui-channel-strip-value-foreground {
72
+ syntax: "<color>";
73
+ inherits: true;
74
+ initial-value: transparent;
75
+ }
@@ -2,12 +2,15 @@
2
2
  :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="root"]) {
3
3
  --cui-range-level-warning-background: var(--warning);
4
4
  --cui-range-level-clip-background: var(--destructive);
5
+ --cui-range-level-size: calc(var(--spacing) * 2);
6
+ --cui-range-level-scale-foreground: var(--muted-foreground);
5
7
  --_range-level-peak-size: 2px;
6
8
  --_range-level-zone-angle: 90deg;
9
+ gap: var(--spacing);
7
10
  }
8
11
 
9
12
  :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="root"][data-orientation="horizontal"]) {
10
- block-size: calc(var(--spacing) * 2);
13
+ grid-template-rows: var(--cui-range-level-size);
11
14
  }
12
15
 
13
16
  :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="root"][data-orientation="horizontal"]:dir(rtl)) {
@@ -16,17 +19,73 @@
16
19
 
17
20
  :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="root"][data-orientation="vertical"]) {
18
21
  --_range-level-zone-angle: 0deg;
19
- flex-direction: column;
20
- inline-size: calc(var(--spacing) * 2);
22
+ grid-auto-flow: column;
23
+ grid-template-columns: var(--cui-range-level-size);
24
+ grid-template-rows: minmax(0, 1fr);
25
+ inline-size: max-content;
21
26
  min-block-size: calc(var(--spacing) * 16);
22
27
  }
23
28
 
24
29
  :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="track"]) {
25
- flex: 1;
26
30
  border-radius: var(--cui-range-track-radius);
27
31
  background: var(--cui-range-track-background);
28
32
  }
29
33
 
34
+ :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="scale"]) {
35
+ block-size: calc(1lh + var(--spacing));
36
+ color: var(--cui-range-level-scale-foreground);
37
+ }
38
+
39
+ :where(
40
+ [data-range-kind="level-meter"][data-slot="root"][data-orientation="vertical"]
41
+ [data-control-family="range"][data-range-kind="level-meter"][data-slot="scale"]
42
+ ) {
43
+ block-size: auto;
44
+ inline-size: calc(var(--spacing) * 8);
45
+ }
46
+
47
+ :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="tick"]) {
48
+ position: absolute;
49
+ inset-block-start: 0;
50
+ inset-inline-start: calc(var(--_range-level-tick) * 100%);
51
+ display: flex;
52
+ flex-direction: column;
53
+ align-items: center;
54
+ translate: -50% 0;
55
+ white-space: nowrap;
56
+ }
57
+
58
+ :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="tick"]:dir(rtl)) {
59
+ translate: 50% 0;
60
+ }
61
+
62
+ :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="tick"])::before {
63
+ content: "";
64
+ inline-size: 1px;
65
+ block-size: var(--spacing);
66
+ background: currentColor;
67
+ opacity: 0.5;
68
+ }
69
+
70
+ :where(
71
+ [data-range-kind="level-meter"][data-slot="root"][data-orientation="vertical"]
72
+ [data-control-family="range"][data-range-kind="level-meter"][data-slot="tick"]
73
+ ) {
74
+ inset-block: auto calc(var(--_range-level-tick) * 100%);
75
+ inset-inline-start: 0;
76
+ flex-direction: row;
77
+ gap: calc(var(--spacing) * 1.5);
78
+ translate: 0 50%;
79
+ }
80
+
81
+ :where(
82
+ [data-range-kind="level-meter"][data-slot="root"][data-orientation="vertical"]
83
+ [data-control-family="range"][data-range-kind="level-meter"][data-slot="tick"]
84
+ )::before {
85
+ inline-size: var(--spacing);
86
+ block-size: 1px;
87
+ }
88
+
30
89
  :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="bar"]),
31
90
  :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="peak"]) {
32
91
  background-image: linear-gradient(
@@ -85,3 +144,15 @@
85
144
  inherits: true;
86
145
  initial-value: transparent;
87
146
  }
147
+
148
+ @property --cui-range-level-size {
149
+ syntax: "<length-percentage>";
150
+ inherits: true;
151
+ initial-value: 0px;
152
+ }
153
+
154
+ @property --cui-range-level-scale-foreground {
155
+ syntax: "<color>";
156
+ inherits: true;
157
+ initial-value: transparent;
158
+ }
@@ -0,0 +1,26 @@
1
+ import { ChannelStripKnobStyle } from "../knob-contracts/channel-strip-knobs.js";
2
+ import { CSSProperties, ComponentProps } from "react";
3
+ //#region src/ui/channel-strip.d.ts
4
+ export type ChannelStripVariant = "default" | "master";
5
+ type ChannelStripPartProps = Omit<ComponentProps<"div">, "style"> & {
6
+ style?: CSSProperties & ChannelStripKnobStyle;
7
+ };
8
+ export type MixerProps = ChannelStripPartProps;
9
+ export type ChannelStripProps = ChannelStripPartProps & {
10
+ variant?: ChannelStripVariant;
11
+ };
12
+ export type ChannelStripHeaderProps = ChannelStripPartProps;
13
+ export type ChannelStripTitleProps = ComponentProps<"span">;
14
+ export type ChannelStripActionsProps = ChannelStripPartProps;
15
+ export type ChannelStripBodyProps = ChannelStripPartProps;
16
+ export type ChannelStripValueProps = ChannelStripPartProps;
17
+ export type ChannelStripControlsProps = ChannelStripPartProps;
18
+ export declare function Mixer({ className, ...props }: MixerProps): import("react").JSX.Element;
19
+ export declare function ChannelStrip({ variant, className, ...props }: ChannelStripProps): import("react").JSX.Element;
20
+ export declare function ChannelStripHeader({ className, ...props }: ChannelStripHeaderProps): import("react").JSX.Element;
21
+ export declare function ChannelStripTitle({ className, ...props }: ChannelStripTitleProps): import("react").JSX.Element;
22
+ export declare function ChannelStripActions({ className, ...props }: ChannelStripActionsProps): import("react").JSX.Element;
23
+ export declare function ChannelStripBody({ className, ...props }: ChannelStripBodyProps): import("react").JSX.Element;
24
+ export declare function ChannelStripValue({ className, ...props }: ChannelStripValueProps): import("react").JSX.Element;
25
+ export declare function ChannelStripControls({ className, ...props }: ChannelStripControlsProps): import("react").JSX.Element;
26
+ //#endregion
@@ -0,0 +1,90 @@
1
+ "use client";
2
+ import { cn } from "../lib/cn.js";
3
+ import { createContext, use, useId } from "react";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/ui/channel-strip.tsx
6
+ const ChannelStripTitleIdContext = createContext(null);
7
+ function Mixer({ className, ...props }) {
8
+ return /* @__PURE__ */ jsx("div", {
9
+ role: "group",
10
+ "data-control-ui": "channel-strip",
11
+ "data-control-family": "channel-strip",
12
+ "data-slot": "mixer",
13
+ className,
14
+ ...props
15
+ });
16
+ }
17
+ function ChannelStrip({ variant = "default", className, ...props }) {
18
+ const titleId = useId();
19
+ return /* @__PURE__ */ jsx(ChannelStripTitleIdContext, {
20
+ value: titleId,
21
+ children: /* @__PURE__ */ jsx("div", {
22
+ role: "group",
23
+ "aria-labelledby": titleId,
24
+ "data-control-ui": "channel-strip",
25
+ "data-control-family": "channel-strip",
26
+ "data-slot": "root",
27
+ "data-variant": variant,
28
+ className: cn("min-w-0", className),
29
+ ...props
30
+ })
31
+ });
32
+ }
33
+ function ChannelStripHeader({ className, ...props }) {
34
+ return /* @__PURE__ */ jsx("div", {
35
+ "data-control-ui": "channel-strip",
36
+ "data-control-family": "channel-strip",
37
+ "data-slot": "header",
38
+ className: cn("flex min-w-0 items-center", className),
39
+ ...props
40
+ });
41
+ }
42
+ function ChannelStripTitle({ className, ...props }) {
43
+ const titleId = use(ChannelStripTitleIdContext);
44
+ return /* @__PURE__ */ jsx("span", {
45
+ "data-control-ui": "channel-strip",
46
+ "data-control-family": "channel-strip",
47
+ "data-slot": "title",
48
+ className: cn("min-w-0 flex-1 truncate text-caption", className),
49
+ ...props,
50
+ id: titleId ?? void 0
51
+ });
52
+ }
53
+ function ChannelStripActions({ className, ...props }) {
54
+ return /* @__PURE__ */ jsx("div", {
55
+ "data-control-ui": "channel-strip",
56
+ "data-control-family": "channel-strip",
57
+ "data-slot": "actions",
58
+ className: cn("flex shrink-0 items-center", className),
59
+ ...props
60
+ });
61
+ }
62
+ function ChannelStripBody({ className, ...props }) {
63
+ return /* @__PURE__ */ jsx("div", {
64
+ "data-control-ui": "channel-strip",
65
+ "data-control-family": "channel-strip",
66
+ "data-slot": "body",
67
+ className: cn("flex items-stretch justify-center", className),
68
+ ...props
69
+ });
70
+ }
71
+ function ChannelStripValue({ className, ...props }) {
72
+ return /* @__PURE__ */ jsx("div", {
73
+ "data-control-ui": "channel-strip",
74
+ "data-control-family": "channel-strip",
75
+ "data-slot": "value",
76
+ className: cn("text-caption tabular-nums", className),
77
+ ...props
78
+ });
79
+ }
80
+ function ChannelStripControls({ className, ...props }) {
81
+ return /* @__PURE__ */ jsx("div", {
82
+ "data-control-ui": "channel-strip",
83
+ "data-control-family": "channel-strip",
84
+ "data-slot": "controls",
85
+ className: cn("flex flex-wrap content-start items-center justify-center", className),
86
+ ...props
87
+ });
88
+ }
89
+ //#endregion
90
+ export { ChannelStrip, ChannelStripActions, ChannelStripBody, ChannelStripControls, ChannelStripHeader, ChannelStripTitle, ChannelStripValue, Mixer };
@@ -24,8 +24,13 @@ export type LevelMeterBarProps = Omit<ComponentProps<"div">, "style" | "children
24
24
  export type LevelMeterPeakProps = Omit<ComponentProps<"div">, "style" | "children"> & {
25
25
  style?: CSSProperties & RangeKnobStyle;
26
26
  };
27
+ export type LevelMeterScaleProps = Omit<ComponentProps<"div">, "style" | "children"> & {
28
+ ticks?: readonly number[];
29
+ style?: CSSProperties & RangeKnobStyle;
30
+ };
27
31
  export declare function LevelMeter({ value, source, minDb, maxDb, warningDb, clipDb, orientation, className, style, children, ref, ...props }: LevelMeterProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
28
32
  export declare function LevelMeterTrack({ className, ...props }: LevelMeterTrackProps): import("react").JSX.Element;
29
33
  export declare function LevelMeterBar({ className, ...props }: LevelMeterBarProps): import("react").JSX.Element;
30
34
  export declare function LevelMeterPeak({ className, ...props }: LevelMeterPeakProps): import("react").JSX.Element;
35
+ export declare function LevelMeterScale({ ticks, className, ...props }: LevelMeterScaleProps): import("react").JSX.Element;
31
36
  //#endregion
@@ -1,60 +1,74 @@
1
1
  "use client";
2
2
  import { cn } from "../lib/cn.js";
3
3
  import { advanceLevelMeterReading, levelMeterFraction, levelMeterReadingSettled } from "../lib/level-meter-ballistics.js";
4
- import { useEffect, useRef, useState } from "react";
4
+ import { createContext, use, useEffect, useRef, useState } from "react";
5
5
  import { useRender } from "@base-ui/react/use-render";
6
6
  import { jsx } from "react/jsx-runtime";
7
7
  //#region src/ui/level-meter.tsx
8
8
  const ARIA_UPDATE_INTERVAL_MS = 250;
9
+ const STANDARD_TICKS_DB = [
10
+ 12,
11
+ 6,
12
+ 0,
13
+ -6,
14
+ -12,
15
+ -18,
16
+ -24,
17
+ -36,
18
+ -48,
19
+ -60,
20
+ -72,
21
+ -84,
22
+ -96
23
+ ];
24
+ const LevelMeterScaleContext = createContext(null);
25
+ function formatTickDb(db) {
26
+ if (db > 0) return `+${db}`;
27
+ if (db < 0) return `\u2212${Math.abs(db)}`;
28
+ return "0";
29
+ }
9
30
  function createLevelMeterPainter() {
10
- let root = null;
11
- let scale = {
12
- minDb: -60,
13
- maxDb: 0
14
- };
15
- let announcesValue = false;
31
+ let attachment = null;
16
32
  let targetDb = Number.NEGATIVE_INFINITY;
17
33
  let reading = {
18
- levelDb: scale.minDb,
19
- peakDb: scale.minDb,
34
+ levelDb: Number.NEGATIVE_INFINITY,
35
+ peakDb: Number.NEGATIVE_INFINITY,
20
36
  peakHeldUntilMs: 0
21
37
  };
22
38
  let frame = 0;
23
39
  let lastFrameMs = 0;
24
40
  let lastAnnouncedMs = Number.NEGATIVE_INFINITY;
25
- const paint = (element, nowMs, settled) => {
26
- element.style.setProperty("--_range-level", `${levelMeterFraction(reading.levelDb, scale)}`);
27
- element.style.setProperty("--_range-level-peak", `${levelMeterFraction(reading.peakDb, scale)}`);
41
+ const paint = ({ root, scale, announcesValue }, nowMs, settled) => {
42
+ root.style.setProperty("--_range-level", `${levelMeterFraction(reading.levelDb, scale)}`);
43
+ root.style.setProperty("--_range-level-peak", `${levelMeterFraction(reading.peakDb, scale)}`);
28
44
  if (!announcesValue || !settled && nowMs - lastAnnouncedMs < ARIA_UPDATE_INTERVAL_MS) return;
29
45
  lastAnnouncedMs = nowMs;
30
46
  const roundedDb = Math.round(reading.levelDb);
31
- element.setAttribute("aria-valuenow", `${roundedDb}`);
32
- element.setAttribute("aria-valuetext", `${roundedDb} dB`);
47
+ root.setAttribute("aria-valuenow", `${roundedDb}`);
48
+ root.setAttribute("aria-valuetext", `${roundedDb} dB`);
33
49
  };
34
50
  const step = (nowMs) => {
35
51
  frame = 0;
36
- if (!root) return;
37
- reading = advanceLevelMeterReading(reading, targetDb, nowMs - lastFrameMs, nowMs, scale);
52
+ if (!attachment) return;
53
+ reading = advanceLevelMeterReading(reading, targetDb, nowMs - lastFrameMs, nowMs, attachment.scale);
38
54
  lastFrameMs = nowMs;
39
- const settled = levelMeterReadingSettled(reading, targetDb, scale);
40
- paint(root, nowMs, settled);
55
+ const settled = levelMeterReadingSettled(reading, targetDb, attachment.scale);
56
+ paint(attachment, nowMs, settled);
41
57
  if (!settled) frame = requestAnimationFrame(step);
42
58
  };
43
59
  const wake = () => {
44
- if (frame !== 0 || !root) return;
60
+ if (frame !== 0 || !attachment) return;
45
61
  lastFrameMs = performance.now();
46
62
  frame = requestAnimationFrame(step);
47
63
  };
48
64
  return {
49
- attach(element, nextScale, nextAnnouncesValue) {
50
- root = element;
51
- scale = nextScale;
52
- announcesValue = nextAnnouncesValue;
65
+ attach(nextAttachment) {
66
+ attachment = nextAttachment;
53
67
  wake();
54
68
  return () => {
55
69
  cancelAnimationFrame(frame);
56
70
  frame = 0;
57
- root = null;
71
+ attachment = null;
58
72
  };
59
73
  },
60
74
  setTarget(levelDb) {
@@ -79,10 +93,14 @@ function LevelMeter({ value, source, minDb = -60, maxDb = 0, warningDb = -18, cl
79
93
  useEffect(() => {
80
94
  const root = rootRef.current;
81
95
  if (!root) return;
82
- return painter.attach(root, {
83
- minDb,
84
- maxDb
85
- }, labelled);
96
+ return painter.attach({
97
+ root,
98
+ scale: {
99
+ minDb,
100
+ maxDb
101
+ },
102
+ announcesValue: labelled
103
+ });
86
104
  }, [
87
105
  painter,
88
106
  minDb,
@@ -112,9 +130,15 @@ function LevelMeter({ value, source, minDb = -60, maxDb = 0, warningDb = -18, cl
112
130
  "data-range-kind": "level-meter",
113
131
  "data-slot": "root",
114
132
  "data-orientation": orientation,
115
- className: cn("flex", className),
133
+ className: cn("grid", className),
116
134
  style: zoneStyle,
117
- children: children ?? /* @__PURE__ */ jsx(LevelMeterTrack, { children: /* @__PURE__ */ jsx(LevelMeterBar, {}) })
135
+ children: /* @__PURE__ */ jsx(LevelMeterScaleContext, {
136
+ value: {
137
+ minDb,
138
+ maxDb
139
+ },
140
+ children: children ?? /* @__PURE__ */ jsx(LevelMeterTrack, { children: /* @__PURE__ */ jsx(LevelMeterBar, {}) })
141
+ })
118
142
  }
119
143
  });
120
144
  }
@@ -148,5 +172,30 @@ function LevelMeterPeak({ className, ...props }) {
148
172
  ...props
149
173
  });
150
174
  }
175
+ function LevelMeterScale({ ticks, className, ...props }) {
176
+ const scale = use(LevelMeterScaleContext);
177
+ if (!scale) throw new Error("LevelMeterScale must be rendered inside LevelMeter.");
178
+ const visibleTicks = (ticks ?? STANDARD_TICKS_DB).filter((db) => db >= scale.minDb && db <= scale.maxDb);
179
+ return /* @__PURE__ */ jsx("div", {
180
+ "aria-hidden": "true",
181
+ "data-control-ui": "level-meter",
182
+ "data-control-family": "range",
183
+ "data-range-kind": "level-meter",
184
+ "data-slot": "scale",
185
+ className: cn("relative text-micro tabular-nums", className),
186
+ ...props,
187
+ children: visibleTicks.map((db) => {
188
+ const tickStyle = { "--_range-level-tick": `${levelMeterFraction(db, scale)}` };
189
+ return /* @__PURE__ */ jsx("span", {
190
+ "data-control-ui": "level-meter",
191
+ "data-control-family": "range",
192
+ "data-range-kind": "level-meter",
193
+ "data-slot": "tick",
194
+ style: tickStyle,
195
+ children: formatTickDb(db)
196
+ }, db);
197
+ })
198
+ });
199
+ }
151
200
  //#endregion
152
- export { LevelMeter, LevelMeterBar, LevelMeterPeak, LevelMeterTrack };
201
+ export { LevelMeter, LevelMeterBar, LevelMeterPeak, LevelMeterScale, LevelMeterTrack };
package/package.json CHANGED
@@ -1,7 +1,25 @@
1
1
  {
2
2
  "name": "@ctrl-ui/react",
3
- "version": "0.9.1",
4
- "description": "Control UI components, blocks, and primitives with neutral defaults and consumer-owned themes.",
3
+ "version": "0.9.3",
4
+ "description": "100+ React components for Tailwind CSS and shadcn/ui: pickers, trees, editors, canvases, app templates, and AI chat surfaces, with neutral defaults and consumer-owned themes.",
5
+ "keywords": [
6
+ "react",
7
+ "react-components",
8
+ "component-library",
9
+ "ui-components",
10
+ "shadcn",
11
+ "shadcn-ui",
12
+ "tailwindcss",
13
+ "design-system",
14
+ "base-ui",
15
+ "nextjs",
16
+ "color-picker",
17
+ "emoji-picker",
18
+ "tree-view",
19
+ "markdown-editor",
20
+ "ai-chat",
21
+ "chat-ui"
22
+ ],
5
23
  "license": "MIT",
6
24
  "repository": {
7
25
  "type": "git",