@ctrl-ui/react 0.9.0 → 0.9.2

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,
@@ -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-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
@@ -1,5 +1,9 @@
1
1
  //#region src/knob-contracts/range-knobs.ts
2
2
  const rangeKnobs = [
3
+ "--cui-range-level-warning-background",
4
+ "--cui-range-level-clip-background",
5
+ "--cui-range-level-size",
6
+ "--cui-range-level-scale-foreground",
3
7
  "--cui-range-indeterminate-size",
4
8
  "--cui-range-track-radius",
5
9
  "--cui-range-track-background",
@@ -0,0 +1,17 @@
1
+ //#region src/lib/level-meter-ballistics.d.ts
2
+ export declare const LEVEL_METER_RELEASE_DB_PER_SECOND = 24;
3
+ export declare const LEVEL_METER_PEAK_HOLD_MS = 1000;
4
+ export type LevelMeterRange = {
5
+ minDb: number;
6
+ maxDb: number;
7
+ };
8
+ export type LevelMeterReading = {
9
+ levelDb: number;
10
+ peakDb: number;
11
+ peakHeldUntilMs: number;
12
+ };
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
+ //#endregion
@@ -0,0 +1,32 @@
1
+ //#region src/lib/level-meter-ballistics.ts
2
+ const LEVEL_METER_RELEASE_DB_PER_SECOND = 24;
3
+ const LEVEL_METER_PEAK_HOLD_MS = 1e3;
4
+ function clampLevelDb(levelDb, { minDb, maxDb }) {
5
+ if (Number.isNaN(levelDb)) return minDb;
6
+ return Math.min(maxDb, Math.max(minDb, levelDb));
7
+ }
8
+ function levelMeterFraction(levelDb, scale) {
9
+ const span = scale.maxDb - scale.minDb;
10
+ if (!(span > 0)) return 0;
11
+ return (clampLevelDb(levelDb, scale) - scale.minDb) / span;
12
+ }
13
+ function advanceLevelMeterReading(reading, targetDb, elapsedMs, nowMs, scale) {
14
+ const target = clampLevelDb(targetDb, scale);
15
+ const fallDb = 24 * Math.max(0, elapsedMs) / 1e3;
16
+ const levelDb = Math.max(target, reading.levelDb - fallDb);
17
+ if (levelDb >= reading.peakDb) return {
18
+ levelDb,
19
+ peakDb: levelDb,
20
+ peakHeldUntilMs: nowMs + LEVEL_METER_PEAK_HOLD_MS
21
+ };
22
+ return {
23
+ levelDb,
24
+ peakDb: nowMs < reading.peakHeldUntilMs ? reading.peakDb : Math.max(levelDb, reading.peakDb - fallDb),
25
+ peakHeldUntilMs: reading.peakHeldUntilMs
26
+ };
27
+ }
28
+ function levelMeterReadingSettled(reading, targetDb, scale) {
29
+ return reading.levelDb === clampLevelDb(targetDb, scale) && reading.peakDb === reading.levelDb;
30
+ }
31
+ //#endregion
32
+ export { LEVEL_METER_PEAK_HOLD_MS, LEVEL_METER_RELEASE_DB_PER_SECOND, advanceLevelMeterReading, clampLevelDb, levelMeterFraction, levelMeterReadingSettled };
@@ -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";
@@ -75,8 +76,9 @@
75
76
  @import "./recipes/inline-attachment.css";
76
77
  @import "./recipes/source-badge.css";
77
78
  @import "./recipes/inline-citation.css";
78
- @import "./recipes/markdown-block.css";
79
79
  @import "./recipes/range.css";
80
+ @import "./recipes/range-level-meter.css";
81
+ @import "./recipes/markdown-block.css";
80
82
  @import "./recipes/morphing-panel.css";
81
83
  @import "./recipes/pagination.css";
82
84
  @import "./recipes/phone-input.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
+ }
@@ -0,0 +1,158 @@
1
+ @layer components {
2
+ :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="root"]) {
3
+ --cui-range-level-warning-background: var(--warning);
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);
7
+ --_range-level-peak-size: 2px;
8
+ --_range-level-zone-angle: 90deg;
9
+ gap: var(--spacing);
10
+ }
11
+
12
+ :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="root"][data-orientation="horizontal"]) {
13
+ grid-template-rows: var(--cui-range-level-size);
14
+ }
15
+
16
+ :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="root"][data-orientation="horizontal"]:dir(rtl)) {
17
+ --_range-level-zone-angle: 270deg;
18
+ }
19
+
20
+ :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="root"][data-orientation="vertical"]) {
21
+ --_range-level-zone-angle: 0deg;
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;
26
+ min-block-size: calc(var(--spacing) * 16);
27
+ }
28
+
29
+ :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="track"]) {
30
+ border-radius: var(--cui-range-track-radius);
31
+ background: var(--cui-range-track-background);
32
+ }
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
+
89
+ :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="bar"]),
90
+ :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="peak"]) {
91
+ background-image: linear-gradient(
92
+ var(--_range-level-zone-angle),
93
+ var(--cui-range-indicator-background) calc(var(--_range-level-warning-start, 1) * 100%),
94
+ var(--cui-range-level-warning-background) 0 calc(var(--_range-level-clip-start, 1) * 100%),
95
+ var(--cui-range-level-clip-background) 0
96
+ );
97
+ }
98
+
99
+ :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="bar"]) {
100
+ clip-path: inset(0 calc((1 - var(--_range-level, 0)) * 100%) 0 0 round var(--cui-range-indicator-radius));
101
+ }
102
+
103
+ :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="peak"]) {
104
+ clip-path: inset(
105
+ 0 calc((1 - var(--_range-level-peak, 0)) * 100%) 0 calc(var(--_range-level-peak, 0) * 100% - var(--_range-level-peak-size))
106
+ );
107
+ }
108
+
109
+ :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="bar"]:dir(rtl)) {
110
+ clip-path: inset(0 0 0 calc((1 - var(--_range-level, 0)) * 100%) round var(--cui-range-indicator-radius));
111
+ }
112
+
113
+ :where([data-control-family="range"][data-range-kind="level-meter"][data-slot="peak"]:dir(rtl)) {
114
+ clip-path: inset(
115
+ 0 calc(var(--_range-level-peak, 0) * 100% - var(--_range-level-peak-size)) 0 calc((1 - var(--_range-level-peak, 0)) * 100%)
116
+ );
117
+ }
118
+
119
+ :where(
120
+ [data-range-kind="level-meter"][data-slot="root"][data-orientation="vertical"]
121
+ [data-control-family="range"][data-range-kind="level-meter"][data-slot="bar"]
122
+ ) {
123
+ clip-path: inset(calc((1 - var(--_range-level, 0)) * 100%) 0 0 0 round var(--cui-range-indicator-radius));
124
+ }
125
+
126
+ :where(
127
+ [data-range-kind="level-meter"][data-slot="root"][data-orientation="vertical"]
128
+ [data-control-family="range"][data-range-kind="level-meter"][data-slot="peak"]
129
+ ) {
130
+ clip-path: inset(
131
+ calc((1 - var(--_range-level-peak, 0)) * 100%) 0 calc(var(--_range-level-peak, 0) * 100% - var(--_range-level-peak-size)) 0
132
+ );
133
+ }
134
+ }
135
+
136
+ @property --cui-range-level-warning-background {
137
+ syntax: "<color>";
138
+ inherits: true;
139
+ initial-value: transparent;
140
+ }
141
+
142
+ @property --cui-range-level-clip-background {
143
+ syntax: "<color>";
144
+ inherits: true;
145
+ initial-value: transparent;
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,89 @@
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
+ "data-control-ui": "channel-strip",
10
+ "data-control-family": "channel-strip",
11
+ "data-slot": "mixer",
12
+ className,
13
+ ...props
14
+ });
15
+ }
16
+ function ChannelStrip({ variant = "default", className, ...props }) {
17
+ const titleId = useId();
18
+ return /* @__PURE__ */ jsx(ChannelStripTitleIdContext, {
19
+ value: titleId,
20
+ children: /* @__PURE__ */ jsx("div", {
21
+ role: "group",
22
+ "aria-labelledby": titleId,
23
+ "data-control-ui": "channel-strip",
24
+ "data-control-family": "channel-strip",
25
+ "data-slot": "root",
26
+ "data-variant": variant,
27
+ className: cn("min-w-0", className),
28
+ ...props
29
+ })
30
+ });
31
+ }
32
+ function ChannelStripHeader({ className, ...props }) {
33
+ return /* @__PURE__ */ jsx("div", {
34
+ "data-control-ui": "channel-strip",
35
+ "data-control-family": "channel-strip",
36
+ "data-slot": "header",
37
+ className: cn("flex min-w-0 items-center", className),
38
+ ...props
39
+ });
40
+ }
41
+ function ChannelStripTitle({ className, ...props }) {
42
+ const titleId = use(ChannelStripTitleIdContext);
43
+ return /* @__PURE__ */ jsx("span", {
44
+ id: titleId ?? void 0,
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
+ });
51
+ }
52
+ function ChannelStripActions({ className, ...props }) {
53
+ return /* @__PURE__ */ jsx("div", {
54
+ "data-control-ui": "channel-strip",
55
+ "data-control-family": "channel-strip",
56
+ "data-slot": "actions",
57
+ className: cn("flex shrink-0 items-center", className),
58
+ ...props
59
+ });
60
+ }
61
+ function ChannelStripBody({ className, ...props }) {
62
+ return /* @__PURE__ */ jsx("div", {
63
+ "data-control-ui": "channel-strip",
64
+ "data-control-family": "channel-strip",
65
+ "data-slot": "body",
66
+ className: cn("flex items-stretch justify-center", className),
67
+ ...props
68
+ });
69
+ }
70
+ function ChannelStripValue({ className, ...props }) {
71
+ return /* @__PURE__ */ jsx("div", {
72
+ "data-control-ui": "channel-strip",
73
+ "data-control-family": "channel-strip",
74
+ "data-slot": "value",
75
+ className: cn("text-caption tabular-nums", className),
76
+ ...props
77
+ });
78
+ }
79
+ function ChannelStripControls({ className, ...props }) {
80
+ return /* @__PURE__ */ jsx("div", {
81
+ "data-control-ui": "channel-strip",
82
+ "data-control-family": "channel-strip",
83
+ "data-slot": "controls",
84
+ className: cn("flex flex-wrap content-start items-center justify-center", className),
85
+ ...props
86
+ });
87
+ }
88
+ //#endregion
89
+ export { ChannelStrip, ChannelStripActions, ChannelStripBody, ChannelStripControls, ChannelStripHeader, ChannelStripTitle, ChannelStripValue, Mixer };
@@ -0,0 +1,36 @@
1
+ import { RangeKnobStyle } from "../knob-contracts/range-knobs.js";
2
+ import { CSSProperties, ComponentProps } from "react";
3
+ //#region src/ui/level-meter.d.ts
4
+ export type LevelMeterSource = {
5
+ subscribe: (listener: (levelDb: number) => void) => () => void;
6
+ };
7
+ export type LevelMeterOrientation = "horizontal" | "vertical";
8
+ export type LevelMeterProps = Omit<ComponentProps<"div">, "style"> & {
9
+ value?: number;
10
+ source?: LevelMeterSource | null;
11
+ minDb?: number;
12
+ maxDb?: number;
13
+ warningDb?: number;
14
+ clipDb?: number;
15
+ orientation?: LevelMeterOrientation;
16
+ style?: CSSProperties & RangeKnobStyle;
17
+ };
18
+ export type LevelMeterTrackProps = Omit<ComponentProps<"div">, "style"> & {
19
+ style?: CSSProperties & RangeKnobStyle;
20
+ };
21
+ export type LevelMeterBarProps = Omit<ComponentProps<"div">, "style" | "children"> & {
22
+ style?: CSSProperties & RangeKnobStyle;
23
+ };
24
+ export type LevelMeterPeakProps = Omit<ComponentProps<"div">, "style" | "children"> & {
25
+ style?: CSSProperties & RangeKnobStyle;
26
+ };
27
+ export type LevelMeterScaleProps = Omit<ComponentProps<"div">, "style" | "children"> & {
28
+ ticks?: readonly number[];
29
+ style?: CSSProperties & RangeKnobStyle;
30
+ };
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>>;
32
+ export declare function LevelMeterTrack({ className, ...props }: LevelMeterTrackProps): import("react").JSX.Element;
33
+ export declare function LevelMeterBar({ className, ...props }: LevelMeterBarProps): import("react").JSX.Element;
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;
36
+ //#endregion
@@ -0,0 +1,201 @@
1
+ "use client";
2
+ import { cn } from "../lib/cn.js";
3
+ import { advanceLevelMeterReading, levelMeterFraction, levelMeterReadingSettled } from "../lib/level-meter-ballistics.js";
4
+ import { createContext, use, useEffect, useRef, useState } from "react";
5
+ import { useRender } from "@base-ui/react/use-render";
6
+ import { jsx } from "react/jsx-runtime";
7
+ //#region src/ui/level-meter.tsx
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
+ }
30
+ function createLevelMeterPainter() {
31
+ let attachment = null;
32
+ let targetDb = Number.NEGATIVE_INFINITY;
33
+ let reading = {
34
+ levelDb: Number.NEGATIVE_INFINITY,
35
+ peakDb: Number.NEGATIVE_INFINITY,
36
+ peakHeldUntilMs: 0
37
+ };
38
+ let frame = 0;
39
+ let lastFrameMs = 0;
40
+ let lastAnnouncedMs = Number.NEGATIVE_INFINITY;
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)}`);
44
+ if (!announcesValue || !settled && nowMs - lastAnnouncedMs < ARIA_UPDATE_INTERVAL_MS) return;
45
+ lastAnnouncedMs = nowMs;
46
+ const roundedDb = Math.round(reading.levelDb);
47
+ root.setAttribute("aria-valuenow", `${roundedDb}`);
48
+ root.setAttribute("aria-valuetext", `${roundedDb} dB`);
49
+ };
50
+ const step = (nowMs) => {
51
+ frame = 0;
52
+ if (!attachment) return;
53
+ reading = advanceLevelMeterReading(reading, targetDb, nowMs - lastFrameMs, nowMs, attachment.scale);
54
+ lastFrameMs = nowMs;
55
+ const settled = levelMeterReadingSettled(reading, targetDb, attachment.scale);
56
+ paint(attachment, nowMs, settled);
57
+ if (!settled) frame = requestAnimationFrame(step);
58
+ };
59
+ const wake = () => {
60
+ if (frame !== 0 || !attachment) return;
61
+ lastFrameMs = performance.now();
62
+ frame = requestAnimationFrame(step);
63
+ };
64
+ return {
65
+ attach(nextAttachment) {
66
+ attachment = nextAttachment;
67
+ wake();
68
+ return () => {
69
+ cancelAnimationFrame(frame);
70
+ frame = 0;
71
+ attachment = null;
72
+ };
73
+ },
74
+ setTarget(levelDb) {
75
+ targetDb = levelDb;
76
+ wake();
77
+ }
78
+ };
79
+ }
80
+ function LevelMeter({ value, source, minDb = -60, maxDb = 0, warningDb = -18, clipDb = -6, orientation = "horizontal", className, style, children, ref, ...props }) {
81
+ const [painter] = useState(createLevelMeterPainter);
82
+ const rootRef = useRef(null);
83
+ const labelled = Boolean(props["aria-label"] || props["aria-labelledby"]);
84
+ const scale = {
85
+ minDb,
86
+ maxDb
87
+ };
88
+ const zoneStyle = {
89
+ "--_range-level-warning-start": `${levelMeterFraction(warningDb, scale)}`,
90
+ "--_range-level-clip-start": `${levelMeterFraction(clipDb, scale)}`,
91
+ ...style
92
+ };
93
+ useEffect(() => {
94
+ const root = rootRef.current;
95
+ if (!root) return;
96
+ return painter.attach({
97
+ root,
98
+ scale: {
99
+ minDb,
100
+ maxDb
101
+ },
102
+ announcesValue: labelled
103
+ });
104
+ }, [
105
+ painter,
106
+ minDb,
107
+ maxDb,
108
+ labelled
109
+ ]);
110
+ useEffect(() => {
111
+ if (source) return source.subscribe(painter.setTarget);
112
+ painter.setTarget(value ?? Number.NEGATIVE_INFINITY);
113
+ }, [
114
+ painter,
115
+ source,
116
+ value
117
+ ]);
118
+ return useRender({
119
+ defaultTagName: "div",
120
+ ref: [ref ?? null, rootRef],
121
+ props: {
122
+ ...props,
123
+ role: labelled ? "meter" : void 0,
124
+ "aria-hidden": labelled ? void 0 : true,
125
+ "aria-valuemin": labelled ? minDb : void 0,
126
+ "aria-valuemax": labelled ? maxDb : void 0,
127
+ "aria-valuenow": labelled ? minDb : void 0,
128
+ "data-control-ui": "level-meter",
129
+ "data-control-family": "range",
130
+ "data-range-kind": "level-meter",
131
+ "data-slot": "root",
132
+ "data-orientation": orientation,
133
+ className: cn("grid", className),
134
+ style: zoneStyle,
135
+ children: /* @__PURE__ */ jsx(LevelMeterScaleContext, {
136
+ value: {
137
+ minDb,
138
+ maxDb
139
+ },
140
+ children: children ?? /* @__PURE__ */ jsx(LevelMeterTrack, { children: /* @__PURE__ */ jsx(LevelMeterBar, {}) })
141
+ })
142
+ }
143
+ });
144
+ }
145
+ function LevelMeterTrack({ className, ...props }) {
146
+ return /* @__PURE__ */ jsx("div", {
147
+ "data-control-ui": "level-meter",
148
+ "data-control-family": "range",
149
+ "data-range-kind": "level-meter",
150
+ "data-slot": "track",
151
+ className: cn("relative overflow-hidden", className),
152
+ ...props
153
+ });
154
+ }
155
+ function LevelMeterBar({ className, ...props }) {
156
+ return /* @__PURE__ */ jsx("div", {
157
+ "data-control-ui": "level-meter",
158
+ "data-control-family": "range",
159
+ "data-range-kind": "level-meter",
160
+ "data-slot": "bar",
161
+ className: cn("absolute inset-0", className),
162
+ ...props
163
+ });
164
+ }
165
+ function LevelMeterPeak({ className, ...props }) {
166
+ return /* @__PURE__ */ jsx("div", {
167
+ "data-control-ui": "level-meter",
168
+ "data-control-family": "range",
169
+ "data-range-kind": "level-meter",
170
+ "data-slot": "peak",
171
+ className: cn("absolute inset-0", className),
172
+ ...props
173
+ });
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
+ }
200
+ //#endregion
201
+ export { LevelMeter, LevelMeterBar, LevelMeterPeak, LevelMeterScale, LevelMeterTrack };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-ui/react",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Control UI components, blocks, and primitives with neutral defaults and consumer-owned themes.",
5
5
  "license": "MIT",
6
6
  "repository": {