@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.
@@ -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
@@ -1,5 +1,7 @@
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",
3
5
  "--cui-range-indeterminate-size",
4
6
  "--cui-range-track-radius",
5
7
  "--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 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 };
@@ -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 };
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.1",
4
4
  "description": "Control UI components, blocks, and primitives with neutral defaults and consumer-owned themes.",
5
5
  "license": "MIT",
6
6
  "repository": {