@ctrl-ui/react 0.9.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +17 -0
- package/dist/lib/level-meter-ballistics.js +32 -0
- package/dist/styles/index.css +2 -1
- package/dist/styles/recipes/range-level-meter.css +87 -0
- package/dist/ui/level-meter.d.ts +31 -0
- package/dist/ui/level-meter.js +152 -0
- package/package.json +1 -1
|
@@ -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-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
|
|
@@ -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 LevelMeterScale = {
|
|
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 }: 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;
|
|
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 };
|
package/dist/styles/index.css
CHANGED
|
@@ -75,8 +75,9 @@
|
|
|
75
75
|
@import "./recipes/inline-attachment.css";
|
|
76
76
|
@import "./recipes/source-badge.css";
|
|
77
77
|
@import "./recipes/inline-citation.css";
|
|
78
|
-
@import "./recipes/markdown-block.css";
|
|
79
78
|
@import "./recipes/range.css";
|
|
79
|
+
@import "./recipes/range-level-meter.css";
|
|
80
|
+
@import "./recipes/markdown-block.css";
|
|
80
81
|
@import "./recipes/morphing-panel.css";
|
|
81
82
|
@import "./recipes/pagination.css";
|
|
82
83
|
@import "./recipes/phone-input.css";
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
--_range-level-peak-size: 2px;
|
|
6
|
+
--_range-level-zone-angle: 90deg;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
:where([data-control-family="range"][data-range-kind="level-meter"][data-slot="root"][data-orientation="horizontal"]) {
|
|
10
|
+
block-size: calc(var(--spacing) * 2);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
:where([data-control-family="range"][data-range-kind="level-meter"][data-slot="root"][data-orientation="horizontal"]:dir(rtl)) {
|
|
14
|
+
--_range-level-zone-angle: 270deg;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
:where([data-control-family="range"][data-range-kind="level-meter"][data-slot="root"][data-orientation="vertical"]) {
|
|
18
|
+
--_range-level-zone-angle: 0deg;
|
|
19
|
+
flex-direction: column;
|
|
20
|
+
inline-size: calc(var(--spacing) * 2);
|
|
21
|
+
min-block-size: calc(var(--spacing) * 16);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
:where([data-control-family="range"][data-range-kind="level-meter"][data-slot="track"]) {
|
|
25
|
+
flex: 1;
|
|
26
|
+
border-radius: var(--cui-range-track-radius);
|
|
27
|
+
background: var(--cui-range-track-background);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
:where([data-control-family="range"][data-range-kind="level-meter"][data-slot="bar"]),
|
|
31
|
+
:where([data-control-family="range"][data-range-kind="level-meter"][data-slot="peak"]) {
|
|
32
|
+
background-image: linear-gradient(
|
|
33
|
+
var(--_range-level-zone-angle),
|
|
34
|
+
var(--cui-range-indicator-background) calc(var(--_range-level-warning-start, 1) * 100%),
|
|
35
|
+
var(--cui-range-level-warning-background) 0 calc(var(--_range-level-clip-start, 1) * 100%),
|
|
36
|
+
var(--cui-range-level-clip-background) 0
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
:where([data-control-family="range"][data-range-kind="level-meter"][data-slot="bar"]) {
|
|
41
|
+
clip-path: inset(0 calc((1 - var(--_range-level, 0)) * 100%) 0 0 round var(--cui-range-indicator-radius));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
:where([data-control-family="range"][data-range-kind="level-meter"][data-slot="peak"]) {
|
|
45
|
+
clip-path: inset(
|
|
46
|
+
0 calc((1 - var(--_range-level-peak, 0)) * 100%) 0 calc(var(--_range-level-peak, 0) * 100% - var(--_range-level-peak-size))
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
:where([data-control-family="range"][data-range-kind="level-meter"][data-slot="bar"]:dir(rtl)) {
|
|
51
|
+
clip-path: inset(0 0 0 calc((1 - var(--_range-level, 0)) * 100%) round var(--cui-range-indicator-radius));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
:where([data-control-family="range"][data-range-kind="level-meter"][data-slot="peak"]:dir(rtl)) {
|
|
55
|
+
clip-path: inset(
|
|
56
|
+
0 calc(var(--_range-level-peak, 0) * 100% - var(--_range-level-peak-size)) 0 calc((1 - var(--_range-level-peak, 0)) * 100%)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
:where(
|
|
61
|
+
[data-range-kind="level-meter"][data-slot="root"][data-orientation="vertical"]
|
|
62
|
+
[data-control-family="range"][data-range-kind="level-meter"][data-slot="bar"]
|
|
63
|
+
) {
|
|
64
|
+
clip-path: inset(calc((1 - var(--_range-level, 0)) * 100%) 0 0 0 round var(--cui-range-indicator-radius));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
:where(
|
|
68
|
+
[data-range-kind="level-meter"][data-slot="root"][data-orientation="vertical"]
|
|
69
|
+
[data-control-family="range"][data-range-kind="level-meter"][data-slot="peak"]
|
|
70
|
+
) {
|
|
71
|
+
clip-path: inset(
|
|
72
|
+
calc((1 - var(--_range-level-peak, 0)) * 100%) 0 calc(var(--_range-level-peak, 0) * 100% - var(--_range-level-peak-size)) 0
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@property --cui-range-level-warning-background {
|
|
78
|
+
syntax: "<color>";
|
|
79
|
+
inherits: true;
|
|
80
|
+
initial-value: transparent;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@property --cui-range-level-clip-background {
|
|
84
|
+
syntax: "<color>";
|
|
85
|
+
inherits: true;
|
|
86
|
+
initial-value: transparent;
|
|
87
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
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 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
|
+
export declare function LevelMeterTrack({ className, ...props }: LevelMeterTrackProps): import("react").JSX.Element;
|
|
29
|
+
export declare function LevelMeterBar({ className, ...props }: LevelMeterBarProps): import("react").JSX.Element;
|
|
30
|
+
export declare function LevelMeterPeak({ className, ...props }: LevelMeterPeakProps): import("react").JSX.Element;
|
|
31
|
+
//#endregion
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { advanceLevelMeterReading, levelMeterFraction, levelMeterReadingSettled } from "../lib/level-meter-ballistics.js";
|
|
4
|
+
import { 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
|
+
function createLevelMeterPainter() {
|
|
10
|
+
let root = null;
|
|
11
|
+
let scale = {
|
|
12
|
+
minDb: -60,
|
|
13
|
+
maxDb: 0
|
|
14
|
+
};
|
|
15
|
+
let announcesValue = false;
|
|
16
|
+
let targetDb = Number.NEGATIVE_INFINITY;
|
|
17
|
+
let reading = {
|
|
18
|
+
levelDb: scale.minDb,
|
|
19
|
+
peakDb: scale.minDb,
|
|
20
|
+
peakHeldUntilMs: 0
|
|
21
|
+
};
|
|
22
|
+
let frame = 0;
|
|
23
|
+
let lastFrameMs = 0;
|
|
24
|
+
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)}`);
|
|
28
|
+
if (!announcesValue || !settled && nowMs - lastAnnouncedMs < ARIA_UPDATE_INTERVAL_MS) return;
|
|
29
|
+
lastAnnouncedMs = nowMs;
|
|
30
|
+
const roundedDb = Math.round(reading.levelDb);
|
|
31
|
+
element.setAttribute("aria-valuenow", `${roundedDb}`);
|
|
32
|
+
element.setAttribute("aria-valuetext", `${roundedDb} dB`);
|
|
33
|
+
};
|
|
34
|
+
const step = (nowMs) => {
|
|
35
|
+
frame = 0;
|
|
36
|
+
if (!root) return;
|
|
37
|
+
reading = advanceLevelMeterReading(reading, targetDb, nowMs - lastFrameMs, nowMs, scale);
|
|
38
|
+
lastFrameMs = nowMs;
|
|
39
|
+
const settled = levelMeterReadingSettled(reading, targetDb, scale);
|
|
40
|
+
paint(root, nowMs, settled);
|
|
41
|
+
if (!settled) frame = requestAnimationFrame(step);
|
|
42
|
+
};
|
|
43
|
+
const wake = () => {
|
|
44
|
+
if (frame !== 0 || !root) return;
|
|
45
|
+
lastFrameMs = performance.now();
|
|
46
|
+
frame = requestAnimationFrame(step);
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
attach(element, nextScale, nextAnnouncesValue) {
|
|
50
|
+
root = element;
|
|
51
|
+
scale = nextScale;
|
|
52
|
+
announcesValue = nextAnnouncesValue;
|
|
53
|
+
wake();
|
|
54
|
+
return () => {
|
|
55
|
+
cancelAnimationFrame(frame);
|
|
56
|
+
frame = 0;
|
|
57
|
+
root = null;
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
setTarget(levelDb) {
|
|
61
|
+
targetDb = levelDb;
|
|
62
|
+
wake();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function LevelMeter({ value, source, minDb = -60, maxDb = 0, warningDb = -18, clipDb = -6, orientation = "horizontal", className, style, children, ref, ...props }) {
|
|
67
|
+
const [painter] = useState(createLevelMeterPainter);
|
|
68
|
+
const rootRef = useRef(null);
|
|
69
|
+
const labelled = Boolean(props["aria-label"] || props["aria-labelledby"]);
|
|
70
|
+
const scale = {
|
|
71
|
+
minDb,
|
|
72
|
+
maxDb
|
|
73
|
+
};
|
|
74
|
+
const zoneStyle = {
|
|
75
|
+
"--_range-level-warning-start": `${levelMeterFraction(warningDb, scale)}`,
|
|
76
|
+
"--_range-level-clip-start": `${levelMeterFraction(clipDb, scale)}`,
|
|
77
|
+
...style
|
|
78
|
+
};
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const root = rootRef.current;
|
|
81
|
+
if (!root) return;
|
|
82
|
+
return painter.attach(root, {
|
|
83
|
+
minDb,
|
|
84
|
+
maxDb
|
|
85
|
+
}, labelled);
|
|
86
|
+
}, [
|
|
87
|
+
painter,
|
|
88
|
+
minDb,
|
|
89
|
+
maxDb,
|
|
90
|
+
labelled
|
|
91
|
+
]);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (source) return source.subscribe(painter.setTarget);
|
|
94
|
+
painter.setTarget(value ?? Number.NEGATIVE_INFINITY);
|
|
95
|
+
}, [
|
|
96
|
+
painter,
|
|
97
|
+
source,
|
|
98
|
+
value
|
|
99
|
+
]);
|
|
100
|
+
return useRender({
|
|
101
|
+
defaultTagName: "div",
|
|
102
|
+
ref: [ref ?? null, rootRef],
|
|
103
|
+
props: {
|
|
104
|
+
...props,
|
|
105
|
+
role: labelled ? "meter" : void 0,
|
|
106
|
+
"aria-hidden": labelled ? void 0 : true,
|
|
107
|
+
"aria-valuemin": labelled ? minDb : void 0,
|
|
108
|
+
"aria-valuemax": labelled ? maxDb : void 0,
|
|
109
|
+
"aria-valuenow": labelled ? minDb : void 0,
|
|
110
|
+
"data-control-ui": "level-meter",
|
|
111
|
+
"data-control-family": "range",
|
|
112
|
+
"data-range-kind": "level-meter",
|
|
113
|
+
"data-slot": "root",
|
|
114
|
+
"data-orientation": orientation,
|
|
115
|
+
className: cn("flex", className),
|
|
116
|
+
style: zoneStyle,
|
|
117
|
+
children: children ?? /* @__PURE__ */ jsx(LevelMeterTrack, { children: /* @__PURE__ */ jsx(LevelMeterBar, {}) })
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function LevelMeterTrack({ className, ...props }) {
|
|
122
|
+
return /* @__PURE__ */ jsx("div", {
|
|
123
|
+
"data-control-ui": "level-meter",
|
|
124
|
+
"data-control-family": "range",
|
|
125
|
+
"data-range-kind": "level-meter",
|
|
126
|
+
"data-slot": "track",
|
|
127
|
+
className: cn("relative overflow-hidden", className),
|
|
128
|
+
...props
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
function LevelMeterBar({ className, ...props }) {
|
|
132
|
+
return /* @__PURE__ */ jsx("div", {
|
|
133
|
+
"data-control-ui": "level-meter",
|
|
134
|
+
"data-control-family": "range",
|
|
135
|
+
"data-range-kind": "level-meter",
|
|
136
|
+
"data-slot": "bar",
|
|
137
|
+
className: cn("absolute inset-0", className),
|
|
138
|
+
...props
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function LevelMeterPeak({ className, ...props }) {
|
|
142
|
+
return /* @__PURE__ */ jsx("div", {
|
|
143
|
+
"data-control-ui": "level-meter",
|
|
144
|
+
"data-control-family": "range",
|
|
145
|
+
"data-range-kind": "level-meter",
|
|
146
|
+
"data-slot": "peak",
|
|
147
|
+
className: cn("absolute inset-0", className),
|
|
148
|
+
...props
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
//#endregion
|
|
152
|
+
export { LevelMeter, LevelMeterBar, LevelMeterPeak, LevelMeterTrack };
|