@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.
- package/dist/audio-device-select.d.ts +3 -1
- package/dist/audio-device-select.js +36 -14
- package/dist/knob-contracts/channel-strip-knobs.d.ts +4 -0
- package/dist/knob-contracts/channel-strip-knobs.js +9 -0
- package/dist/knob-contracts/range-knobs.d.ts +1 -1
- package/dist/knob-contracts/range-knobs.js +2 -0
- package/dist/lib/level-meter-ballistics.d.ts +5 -5
- package/dist/styles/index.css +1 -0
- package/dist/styles/recipes/channel-strip.css +75 -0
- package/dist/styles/recipes/range-level-meter.css +75 -4
- package/dist/ui/channel-strip.d.ts +26 -0
- package/dist/ui/channel-strip.js +90 -0
- package/dist/ui/level-meter.d.ts +5 -0
- package/dist/ui/level-meter.js +80 -31
- package/package.json +20 -2
|
@@ -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() ||
|
|
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 =
|
|
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(
|
|
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
|
|
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" ?
|
|
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 =
|
|
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
|
|
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 }:
|
|
14
|
-
export declare function levelMeterFraction(levelDb: number, scale:
|
|
15
|
-
export declare function advanceLevelMeterReading(reading: LevelMeterReading, targetDb: number, elapsedMs: number, nowMs: number, scale:
|
|
16
|
-
export declare function levelMeterReadingSettled(reading: LevelMeterReading, targetDb: number, scale:
|
|
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
|
package/dist/styles/index.css
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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 };
|
package/dist/ui/level-meter.d.ts
CHANGED
|
@@ -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
|
package/dist/ui/level-meter.js
CHANGED
|
@@ -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
|
|
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:
|
|
19
|
-
peakDb:
|
|
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 = (
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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 (!
|
|
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(
|
|
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 || !
|
|
60
|
+
if (frame !== 0 || !attachment) return;
|
|
45
61
|
lastFrameMs = performance.now();
|
|
46
62
|
frame = requestAnimationFrame(step);
|
|
47
63
|
};
|
|
48
64
|
return {
|
|
49
|
-
attach(
|
|
50
|
-
|
|
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
|
-
|
|
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(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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("
|
|
133
|
+
className: cn("grid", className),
|
|
116
134
|
style: zoneStyle,
|
|
117
|
-
children:
|
|
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.
|
|
4
|
-
"description": "
|
|
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",
|