@ambientcss/components 1.0.2 → 1.1.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/LICENSE +21 -0
- package/package.json +10 -9
- package/src/components/AmbientButton.tsx +16 -0
- package/src/components/AmbientFader.tsx +127 -0
- package/src/components/AmbientKnob.tsx +145 -0
- package/src/components/AmbientPanel.tsx +8 -0
- package/src/components/AmbientProvider.tsx +57 -0
- package/src/components/AmbientSlider.tsx +127 -0
- package/src/components/AmbientSwitch.tsx +75 -0
- package/src/index.ts +20 -0
- package/src/lib/cn.ts +3 -0
- package/src/styles.css +273 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ramakrishnan Veeraragavan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ambientcss/components",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "React skeuomorphic components powered by Ambient CSS",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,24 +36,25 @@
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"files": [
|
|
39
|
-
"dist"
|
|
39
|
+
"dist",
|
|
40
|
+
"src"
|
|
40
41
|
],
|
|
41
|
-
"scripts": {
|
|
42
|
-
"build": "tsup && cp src/styles.css dist/styles.css",
|
|
43
|
-
"clean": "rm -rf dist",
|
|
44
|
-
"typecheck": "tsc --noEmit"
|
|
45
|
-
},
|
|
46
42
|
"peerDependencies": {
|
|
47
43
|
"react": "^18.3.0 || ^19.0.0",
|
|
48
44
|
"react-dom": "^18.3.0 || ^19.0.0"
|
|
49
45
|
},
|
|
50
46
|
"dependencies": {
|
|
51
|
-
"@ambientcss/css": "
|
|
47
|
+
"@ambientcss/css": "1.1.1"
|
|
52
48
|
},
|
|
53
49
|
"devDependencies": {
|
|
54
50
|
"@types/react": "^19.0.8",
|
|
55
51
|
"@types/react-dom": "^19.0.3",
|
|
56
52
|
"tsup": "^8.3.6",
|
|
57
53
|
"typescript": "^5.8.2"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsup && cp src/styles.css dist/styles.css",
|
|
57
|
+
"clean": "rm -rf dist",
|
|
58
|
+
"typecheck": "tsc --noEmit"
|
|
58
59
|
}
|
|
59
|
-
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
export type AmbientButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
|
|
5
|
+
|
|
6
|
+
export function AmbientButton({ className, children, ...props }: AmbientButtonProps) {
|
|
7
|
+
return (
|
|
8
|
+
<button
|
|
9
|
+
type="button"
|
|
10
|
+
className={cn("ambient amb-button amb-chamfer amb-elevation-1 ambx-button amb-heading-3", className)}
|
|
11
|
+
{...props}
|
|
12
|
+
>
|
|
13
|
+
{children}
|
|
14
|
+
</button>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useId, useRef } from "react";
|
|
2
|
+
import type { HTMLAttributes, KeyboardEvent, PointerEvent } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
|
|
5
|
+
export type AmbientFaderProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange"> & {
|
|
6
|
+
value: number;
|
|
7
|
+
min?: number;
|
|
8
|
+
max?: number;
|
|
9
|
+
step?: number;
|
|
10
|
+
label?: string;
|
|
11
|
+
onChange?: (nextValue: number) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function clamp(value: number, min: number, max: number): number {
|
|
15
|
+
return Math.min(max, Math.max(min, value));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function AmbientFader({
|
|
19
|
+
value,
|
|
20
|
+
min = 0,
|
|
21
|
+
max = 100,
|
|
22
|
+
step = 1,
|
|
23
|
+
label,
|
|
24
|
+
onChange,
|
|
25
|
+
className,
|
|
26
|
+
...props
|
|
27
|
+
}: AmbientFaderProps) {
|
|
28
|
+
const id = useId();
|
|
29
|
+
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
30
|
+
const safeStep = step > 0 ? step : 1;
|
|
31
|
+
|
|
32
|
+
const percent = ((value - min) / (max - min || 1)) * 100;
|
|
33
|
+
|
|
34
|
+
const updateFromClientY = (clientY: number) => {
|
|
35
|
+
const track = trackRef.current;
|
|
36
|
+
if (!track) return;
|
|
37
|
+
|
|
38
|
+
const rect = track.getBoundingClientRect();
|
|
39
|
+
const ratio = 1 - (clientY - rect.top) / rect.height;
|
|
40
|
+
const nextValue = min + clamp(ratio, 0, 1) * (max - min);
|
|
41
|
+
const snapped = Math.round(nextValue / safeStep) * safeStep;
|
|
42
|
+
onChange?.(clamp(snapped, min, max));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const setValue = (nextValue: number) => {
|
|
46
|
+
const snapped = Math.round(nextValue / safeStep) * safeStep;
|
|
47
|
+
onChange?.(clamp(snapped, min, max));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
51
|
+
const pageStep = safeStep * 10;
|
|
52
|
+
switch (event.key) {
|
|
53
|
+
case "ArrowUp":
|
|
54
|
+
case "ArrowRight":
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
setValue(value + safeStep);
|
|
57
|
+
break;
|
|
58
|
+
case "ArrowDown":
|
|
59
|
+
case "ArrowLeft":
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
setValue(value - safeStep);
|
|
62
|
+
break;
|
|
63
|
+
case "PageUp":
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
setValue(value + pageStep);
|
|
66
|
+
break;
|
|
67
|
+
case "PageDown":
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
setValue(value - pageStep);
|
|
70
|
+
break;
|
|
71
|
+
case "Home":
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
setValue(min);
|
|
74
|
+
break;
|
|
75
|
+
case "End":
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
setValue(max);
|
|
78
|
+
break;
|
|
79
|
+
default:
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
|
85
|
+
if (event.buttons !== 1) return;
|
|
86
|
+
updateFromClientY(event.clientY);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className={cn("ambx-stack", className)} {...props}>
|
|
91
|
+
<div
|
|
92
|
+
className="amb-fader ambx-fader"
|
|
93
|
+
ref={trackRef}
|
|
94
|
+
role="slider"
|
|
95
|
+
aria-label={label}
|
|
96
|
+
aria-labelledby={label ? id : undefined}
|
|
97
|
+
aria-valuemin={min}
|
|
98
|
+
aria-valuemax={max}
|
|
99
|
+
aria-valuenow={value}
|
|
100
|
+
aria-orientation="vertical"
|
|
101
|
+
tabIndex={0}
|
|
102
|
+
onPointerDown={(event) => {
|
|
103
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
104
|
+
updateFromClientY(event.clientY);
|
|
105
|
+
}}
|
|
106
|
+
onPointerMove={onPointerMove}
|
|
107
|
+
onKeyDown={onKeyDown}
|
|
108
|
+
>
|
|
109
|
+
<div
|
|
110
|
+
className="amb-fader-thumb ambient amb-fillet amb-elevation-1 amb-surface-concave ambx-fader-thumb"
|
|
111
|
+
style={{ top: `${100 - percent}%` }}
|
|
112
|
+
>
|
|
113
|
+
<div className="amb-fader-grip">
|
|
114
|
+
<span className="amb-fader-dot" />
|
|
115
|
+
<span className="amb-fader-dot" />
|
|
116
|
+
<span className="amb-fader-dot" />
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
{label ? (
|
|
121
|
+
<span id={id} className="ambx-label">
|
|
122
|
+
{label}
|
|
123
|
+
</span>
|
|
124
|
+
) : null}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useId, useRef } from "react";
|
|
2
|
+
import type { HTMLAttributes, KeyboardEvent, PointerEvent } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
|
|
5
|
+
export type AmbientKnobProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange"> & {
|
|
6
|
+
value: number;
|
|
7
|
+
min?: number;
|
|
8
|
+
max?: number;
|
|
9
|
+
step?: number;
|
|
10
|
+
label?: string;
|
|
11
|
+
onChange?: (nextValue: number) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function clamp(value: number, min: number, max: number): number {
|
|
15
|
+
return Math.min(max, Math.max(min, value));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function AmbientKnob({
|
|
19
|
+
value,
|
|
20
|
+
min = 0,
|
|
21
|
+
max = 100,
|
|
22
|
+
step = 1,
|
|
23
|
+
label,
|
|
24
|
+
onChange,
|
|
25
|
+
className,
|
|
26
|
+
...props
|
|
27
|
+
}: AmbientKnobProps) {
|
|
28
|
+
const id = useId();
|
|
29
|
+
const draggingRef = useRef(false);
|
|
30
|
+
const knobRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
const safeStep = step > 0 ? step : 1;
|
|
32
|
+
|
|
33
|
+
const percent = (value - min) / (max - min || 1);
|
|
34
|
+
const rotation = percent * 270 - 135;
|
|
35
|
+
|
|
36
|
+
// Convert pointer position to a knob angle in [-180, 180] where 0 = up,
|
|
37
|
+
// positive = clockwise. The knob's active range is [-135, 135] (270°),
|
|
38
|
+
// with the dead zone at the bottom (~135° to ~225° i.e. past ±135°).
|
|
39
|
+
const pointerToValue = (event: PointerEvent<HTMLDivElement>) => {
|
|
40
|
+
const rect = knobRef.current!.getBoundingClientRect();
|
|
41
|
+
const cx = rect.left + rect.width / 2;
|
|
42
|
+
const cy = rect.top + rect.height / 2;
|
|
43
|
+
// atan2 with screen coords: 0=right, 90=down; add 90 to rotate so 0=up
|
|
44
|
+
const atan2Deg = Math.atan2(event.clientY - cy, event.clientX - cx) * (180 / Math.PI);
|
|
45
|
+
let angle = atan2Deg + 90;
|
|
46
|
+
// Normalize to [-180, 180]
|
|
47
|
+
if (angle > 180) angle -= 360;
|
|
48
|
+
|
|
49
|
+
// Clamp dead zone based on current value position
|
|
50
|
+
if (angle < -135 || angle > 135) {
|
|
51
|
+
angle = percent >= 0.5 ? 135 : -135;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const range = max - min;
|
|
55
|
+
const raw = min + ((angle + 135) / 270) * range;
|
|
56
|
+
const snapped = Math.round(raw / safeStep) * safeStep;
|
|
57
|
+
onChange?.(clamp(snapped, min, max));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const setValue = (nextValue: number) => {
|
|
61
|
+
const snapped = Math.round(nextValue / safeStep) * safeStep;
|
|
62
|
+
onChange?.(clamp(snapped, min, max));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
66
|
+
const pageStep = safeStep * 10;
|
|
67
|
+
switch (event.key) {
|
|
68
|
+
case "ArrowUp":
|
|
69
|
+
case "ArrowRight":
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
setValue(value + safeStep);
|
|
72
|
+
break;
|
|
73
|
+
case "ArrowDown":
|
|
74
|
+
case "ArrowLeft":
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
setValue(value - safeStep);
|
|
77
|
+
break;
|
|
78
|
+
case "PageUp":
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
setValue(value + pageStep);
|
|
81
|
+
break;
|
|
82
|
+
case "PageDown":
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
setValue(value - pageStep);
|
|
85
|
+
break;
|
|
86
|
+
case "Home":
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
setValue(min);
|
|
89
|
+
break;
|
|
90
|
+
case "End":
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
setValue(max);
|
|
93
|
+
break;
|
|
94
|
+
default:
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className={cn("ambx-stack", className)} {...props}>
|
|
101
|
+
<div
|
|
102
|
+
ref={knobRef}
|
|
103
|
+
className="ambient amb-knob amb-chamfer amb-elevation-2 amb-surface ambx-knob"
|
|
104
|
+
role="slider"
|
|
105
|
+
aria-label={label}
|
|
106
|
+
aria-valuemin={min}
|
|
107
|
+
aria-valuemax={max}
|
|
108
|
+
aria-valuenow={value}
|
|
109
|
+
aria-labelledby={label ? id : undefined}
|
|
110
|
+
aria-orientation="vertical"
|
|
111
|
+
tabIndex={0}
|
|
112
|
+
onPointerDown={(event) => {
|
|
113
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
114
|
+
draggingRef.current = true;
|
|
115
|
+
pointerToValue(event);
|
|
116
|
+
}}
|
|
117
|
+
onPointerMove={(event) => {
|
|
118
|
+
if (draggingRef.current) pointerToValue(event);
|
|
119
|
+
}}
|
|
120
|
+
onPointerUp={() => {
|
|
121
|
+
draggingRef.current = false;
|
|
122
|
+
}}
|
|
123
|
+
onPointerCancel={() => {
|
|
124
|
+
draggingRef.current = false;
|
|
125
|
+
}}
|
|
126
|
+
onKeyDown={onKeyDown}
|
|
127
|
+
>
|
|
128
|
+
<div className="ambx-knob-rotation" style={{ transform: `rotate(${rotation}deg)` }}>
|
|
129
|
+
<div className="amb-knob-indicator">
|
|
130
|
+
<div className="amb-knob-grip">
|
|
131
|
+
<span className="amb-fader-dot" />
|
|
132
|
+
<span className="amb-fader-dot" />
|
|
133
|
+
<span className="amb-fader-dot" />
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
{label ? (
|
|
139
|
+
<span id={id} className="ambx-label">
|
|
140
|
+
{label}
|
|
141
|
+
</span>
|
|
142
|
+
) : null}
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
export type AmbientPanelProps = HTMLAttributes<HTMLDivElement>;
|
|
5
|
+
|
|
6
|
+
export function AmbientPanel({ className, ...props }: AmbientPanelProps) {
|
|
7
|
+
return <div className={cn("ambient amb-surface amb-chamfer amb-elevation-2 ambx-panel", className)} {...props} />;
|
|
8
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { CSSProperties, PropsWithChildren } from "react";
|
|
2
|
+
|
|
3
|
+
export type AmbientTheme = {
|
|
4
|
+
lightX?: number;
|
|
5
|
+
lightY?: number;
|
|
6
|
+
keyLight?: number;
|
|
7
|
+
fillLight?: number;
|
|
8
|
+
lightHue?: number;
|
|
9
|
+
lightSaturation?: number;
|
|
10
|
+
highlightColor?: string;
|
|
11
|
+
lumeHue?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type AmbientProviderProps = PropsWithChildren<{
|
|
15
|
+
className?: string;
|
|
16
|
+
style?: CSSProperties;
|
|
17
|
+
theme?: AmbientTheme;
|
|
18
|
+
}>;
|
|
19
|
+
|
|
20
|
+
const VAR_MAP: Record<string, keyof AmbientTheme> = {
|
|
21
|
+
"--amb-light-x": "lightX",
|
|
22
|
+
"--amb-light-y": "lightY",
|
|
23
|
+
"--amb-key-light-intensity": "keyLight",
|
|
24
|
+
"--amb-fill-light-intensity": "fillLight",
|
|
25
|
+
"--amb-light-hue": "lightHue",
|
|
26
|
+
"--amb-light-saturation": "lightSaturation",
|
|
27
|
+
"--amb-highlight-color": "highlightColor",
|
|
28
|
+
"--amb-lume-hue": "lumeHue",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function AmbientProvider({ children, className, style, theme }: AmbientProviderProps) {
|
|
32
|
+
const themeVars: Record<string, string> = {};
|
|
33
|
+
for (const [varName, key] of Object.entries(VAR_MAP)) {
|
|
34
|
+
const value = theme?.[key];
|
|
35
|
+
if (value !== undefined) {
|
|
36
|
+
themeVars[varName] = key === "lightSaturation" ? `${value}%` : String(value);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Derived variables must be re-declared here so they recompute using the
|
|
40
|
+
// overridden input variables on this element rather than inheriting the
|
|
41
|
+
// already-resolved values from :root.
|
|
42
|
+
themeVars["--amb-lume"] = [
|
|
43
|
+
"color-mix(in oklab,",
|
|
44
|
+
"hsl(var(--amb-lume-hue) 100% 74%) calc(clamp(0, (0.5 - var(--amb-key-light-intensity)) / 0.2, 1) * 100%),",
|
|
45
|
+
"hsl(var(--amb-light-hue) var(--amb-light-saturation)",
|
|
46
|
+
"calc((1 - sign(var(--amb-key-light-intensity) - 0.5)) * 50% + 25%)))",
|
|
47
|
+
].join(" ");
|
|
48
|
+
themeVars["--amb-label"] =
|
|
49
|
+
"hsl(var(--amb-light-hue) var(--amb-light-saturation) calc((1 - var(--amb-key-light-intensity)) * 60% + 20%))";
|
|
50
|
+
const mergedStyle = { ...themeVars, ...style } as CSSProperties;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className={className} style={mergedStyle}>
|
|
54
|
+
{children}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useId, useRef } from "react";
|
|
2
|
+
import type { HTMLAttributes, KeyboardEvent, PointerEvent } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
|
|
5
|
+
export type AmbientSliderProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange"> & {
|
|
6
|
+
value: number;
|
|
7
|
+
min?: number;
|
|
8
|
+
max?: number;
|
|
9
|
+
step?: number;
|
|
10
|
+
label?: string;
|
|
11
|
+
onChange?: (nextValue: number) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function clamp(value: number, min: number, max: number): number {
|
|
15
|
+
return Math.min(max, Math.max(min, value));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function AmbientSlider({
|
|
19
|
+
value,
|
|
20
|
+
min = 0,
|
|
21
|
+
max = 100,
|
|
22
|
+
step = 1,
|
|
23
|
+
label,
|
|
24
|
+
onChange,
|
|
25
|
+
className,
|
|
26
|
+
...props
|
|
27
|
+
}: AmbientSliderProps) {
|
|
28
|
+
const id = useId();
|
|
29
|
+
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
30
|
+
const safeStep = step > 0 ? step : 1;
|
|
31
|
+
|
|
32
|
+
const percent = ((value - min) / (max - min || 1)) * 100;
|
|
33
|
+
|
|
34
|
+
const updateFromClientX = (clientX: number) => {
|
|
35
|
+
const track = trackRef.current;
|
|
36
|
+
if (!track) return;
|
|
37
|
+
|
|
38
|
+
const rect = track.getBoundingClientRect();
|
|
39
|
+
const ratio = (clientX - rect.left) / rect.width;
|
|
40
|
+
const nextValue = min + clamp(ratio, 0, 1) * (max - min);
|
|
41
|
+
const snapped = Math.round(nextValue / safeStep) * safeStep;
|
|
42
|
+
onChange?.(clamp(snapped, min, max));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const setValue = (nextValue: number) => {
|
|
46
|
+
const snapped = Math.round(nextValue / safeStep) * safeStep;
|
|
47
|
+
onChange?.(clamp(snapped, min, max));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
51
|
+
const pageStep = safeStep * 10;
|
|
52
|
+
switch (event.key) {
|
|
53
|
+
case "ArrowRight":
|
|
54
|
+
case "ArrowUp":
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
setValue(value + safeStep);
|
|
57
|
+
break;
|
|
58
|
+
case "ArrowLeft":
|
|
59
|
+
case "ArrowDown":
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
setValue(value - safeStep);
|
|
62
|
+
break;
|
|
63
|
+
case "PageUp":
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
setValue(value + pageStep);
|
|
66
|
+
break;
|
|
67
|
+
case "PageDown":
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
setValue(value - pageStep);
|
|
70
|
+
break;
|
|
71
|
+
case "Home":
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
setValue(min);
|
|
74
|
+
break;
|
|
75
|
+
case "End":
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
setValue(max);
|
|
78
|
+
break;
|
|
79
|
+
default:
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
|
85
|
+
if (event.buttons !== 1) return;
|
|
86
|
+
updateFromClientX(event.clientX);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className={cn("ambx-stack", className)} {...props}>
|
|
91
|
+
<div
|
|
92
|
+
className="amb-slider ambx-slider"
|
|
93
|
+
ref={trackRef}
|
|
94
|
+
role="slider"
|
|
95
|
+
aria-label={label}
|
|
96
|
+
aria-labelledby={label ? id : undefined}
|
|
97
|
+
aria-valuemin={min}
|
|
98
|
+
aria-valuemax={max}
|
|
99
|
+
aria-valuenow={value}
|
|
100
|
+
aria-orientation="horizontal"
|
|
101
|
+
tabIndex={0}
|
|
102
|
+
onPointerDown={(event) => {
|
|
103
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
104
|
+
updateFromClientX(event.clientX);
|
|
105
|
+
}}
|
|
106
|
+
onPointerMove={onPointerMove}
|
|
107
|
+
onKeyDown={onKeyDown}
|
|
108
|
+
>
|
|
109
|
+
<div
|
|
110
|
+
className="amb-slider-thumb ambient amb-fillet amb-elevation-1 amb-surface-concave-h ambx-slider-thumb"
|
|
111
|
+
style={{ left: `${percent}%` }}
|
|
112
|
+
>
|
|
113
|
+
<div className="amb-slider-grip">
|
|
114
|
+
<span className="amb-fader-dot" />
|
|
115
|
+
<span className="amb-fader-dot" />
|
|
116
|
+
<span className="amb-fader-dot" />
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
{label ? (
|
|
121
|
+
<span id={id} className="ambx-label">
|
|
122
|
+
{label}
|
|
123
|
+
</span>
|
|
124
|
+
) : null}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useId, useState } from "react";
|
|
2
|
+
import type { ButtonHTMLAttributes } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
|
|
5
|
+
export type AmbientSwitchSize = "sm" | "md" | "lg";
|
|
6
|
+
|
|
7
|
+
export type AmbientSwitchProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> & {
|
|
8
|
+
checked?: boolean;
|
|
9
|
+
defaultChecked?: boolean;
|
|
10
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
11
|
+
size?: AmbientSwitchSize;
|
|
12
|
+
label?: string;
|
|
13
|
+
/** Show a small LED indicator that lights up when the switch is on. Pass `true` for default green or a CSS color string. */
|
|
14
|
+
led?: boolean | string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function AmbientSwitch({
|
|
18
|
+
className,
|
|
19
|
+
checked,
|
|
20
|
+
defaultChecked,
|
|
21
|
+
onCheckedChange,
|
|
22
|
+
onClick,
|
|
23
|
+
size = "md",
|
|
24
|
+
label,
|
|
25
|
+
led,
|
|
26
|
+
children,
|
|
27
|
+
...props
|
|
28
|
+
}: AmbientSwitchProps) {
|
|
29
|
+
const labelId = useId();
|
|
30
|
+
const isControlled = checked !== undefined;
|
|
31
|
+
const [internalChecked, setInternalChecked] = useState(defaultChecked ?? false);
|
|
32
|
+
const active = isControlled ? (checked ?? false) : internalChecked;
|
|
33
|
+
|
|
34
|
+
const button = (
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
role="switch"
|
|
38
|
+
aria-checked={active}
|
|
39
|
+
aria-labelledby={label ? labelId : props["aria-labelledby"]}
|
|
40
|
+
className={cn(
|
|
41
|
+
"ambient amb-switch amb-chamfer amb-elevation-1 amb-heading-3",
|
|
42
|
+
active ? "amb-surface-convex amb-switch-on" : "amb-surface-concave",
|
|
43
|
+
"ambx-switch",
|
|
44
|
+
`ambx-switch-${size}`,
|
|
45
|
+
className
|
|
46
|
+
)}
|
|
47
|
+
onClick={(event) => {
|
|
48
|
+
const next = !active;
|
|
49
|
+
if (!isControlled) setInternalChecked(next);
|
|
50
|
+
onCheckedChange?.(next);
|
|
51
|
+
onClick?.(event);
|
|
52
|
+
}}
|
|
53
|
+
{...props}
|
|
54
|
+
>
|
|
55
|
+
{led ? (
|
|
56
|
+
<span
|
|
57
|
+
className={cn("amb-led", !active && "amb-led-off")}
|
|
58
|
+
style={typeof led === "string" ? { "--amb-led-color": led } as React.CSSProperties : undefined}
|
|
59
|
+
/>
|
|
60
|
+
) : null}
|
|
61
|
+
{children}
|
|
62
|
+
</button>
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (!label) return button;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="ambx-stack">
|
|
69
|
+
{button}
|
|
70
|
+
<span id={labelId} className="ambx-label">
|
|
71
|
+
{label}
|
|
72
|
+
</span>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { AmbientProvider } from "./components/AmbientProvider";
|
|
2
|
+
export type { AmbientProviderProps, AmbientTheme } from "./components/AmbientProvider";
|
|
3
|
+
|
|
4
|
+
export { AmbientPanel } from "./components/AmbientPanel";
|
|
5
|
+
export type { AmbientPanelProps } from "./components/AmbientPanel";
|
|
6
|
+
|
|
7
|
+
export { AmbientButton } from "./components/AmbientButton";
|
|
8
|
+
export type { AmbientButtonProps } from "./components/AmbientButton";
|
|
9
|
+
|
|
10
|
+
export { AmbientSwitch } from "./components/AmbientSwitch";
|
|
11
|
+
export type { AmbientSwitchProps, AmbientSwitchSize } from "./components/AmbientSwitch";
|
|
12
|
+
|
|
13
|
+
export { AmbientKnob } from "./components/AmbientKnob";
|
|
14
|
+
export type { AmbientKnobProps } from "./components/AmbientKnob";
|
|
15
|
+
|
|
16
|
+
export { AmbientFader } from "./components/AmbientFader";
|
|
17
|
+
export type { AmbientFaderProps } from "./components/AmbientFader";
|
|
18
|
+
|
|
19
|
+
export { AmbientSlider } from "./components/AmbientSlider";
|
|
20
|
+
export type { AmbientSliderProps } from "./components/AmbientSlider";
|
package/src/lib/cn.ts
ADDED
package/src/styles.css
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--ambx-grid: 4px;
|
|
3
|
+
--ambx-grid-half: calc(var(--ambx-grid) / 2);
|
|
4
|
+
--ambx-grid-quarter: calc(var(--ambx-grid) / 4);
|
|
5
|
+
--ambx-type-1-size: calc(var(--ambx-grid) * 8);
|
|
6
|
+
--ambx-type-1-line: calc(var(--ambx-grid) * 10);
|
|
7
|
+
--ambx-type-2-size: calc(var(--ambx-grid) * 6);
|
|
8
|
+
--ambx-type-2-line: calc(var(--ambx-grid) * 8);
|
|
9
|
+
--ambx-type-3-size: calc(var(--ambx-grid) * 3);
|
|
10
|
+
--ambx-type-3-line: calc(var(--ambx-grid) * 4);
|
|
11
|
+
--ambx-type-tracking: var(--ambx-grid-quarter);
|
|
12
|
+
--ambx-label-size: calc(var(--ambx-grid) * 2 + var(--ambx-grid-half));
|
|
13
|
+
--ambx-label-line: calc(var(--ambx-grid) * 3);
|
|
14
|
+
--ambx-control-label-gap: calc(var(--ambx-grid) * 2);
|
|
15
|
+
--ambx-knob-label-clearance: calc(var(--ambx-grid) * 2);
|
|
16
|
+
--ambx-slider-label-clearance: calc(var(--ambx-grid) * 2);
|
|
17
|
+
--ambx-fader-label-clearance: calc(var(--ambx-grid) * 4);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.amb-heading-1 {
|
|
21
|
+
font-size: var(--ambx-type-1-size);
|
|
22
|
+
line-height: var(--ambx-type-1-line);
|
|
23
|
+
letter-spacing: var(--ambx-type-tracking);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.amb-heading-2 {
|
|
27
|
+
font-size: var(--ambx-type-2-size);
|
|
28
|
+
line-height: var(--ambx-type-2-line);
|
|
29
|
+
letter-spacing: var(--ambx-type-tracking);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.amb-heading-3 {
|
|
33
|
+
font-size: var(--ambx-type-3-size);
|
|
34
|
+
line-height: var(--ambx-type-3-line);
|
|
35
|
+
letter-spacing: var(--ambx-type-tracking);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.amb-button {
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
border: medium solid var(--amb-lume);
|
|
41
|
+
background-color: inherit;
|
|
42
|
+
padding: calc(var(--ambx-grid) * 2);
|
|
43
|
+
border-radius: calc(var(--ambx-grid) * 3);
|
|
44
|
+
text-transform: uppercase;
|
|
45
|
+
min-width: calc(var(--ambx-grid) * 16);
|
|
46
|
+
font-weight: 600;
|
|
47
|
+
color: var(--amb-lume);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.amb-button:focus {
|
|
51
|
+
outline-offset: var(--ambx-grid);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.amb-button:hover,
|
|
55
|
+
.amb-knob:hover {
|
|
56
|
+
border-color: var(--amb-highlight-color);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.amb-button:active {
|
|
60
|
+
--amb-elevation: 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.amb-knob {
|
|
64
|
+
border-radius: 50%;
|
|
65
|
+
height: calc(var(--ambx-grid) * 16);
|
|
66
|
+
width: calc(var(--ambx-grid) * 16);
|
|
67
|
+
border: medium solid var(--amb-lume);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.amb-knob-indicator {
|
|
71
|
+
position: absolute;
|
|
72
|
+
top: 6px;
|
|
73
|
+
left: 50%;
|
|
74
|
+
transform: translateX(-50%);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.amb-knob-grip {
|
|
78
|
+
display: flex;
|
|
79
|
+
flex-direction: column;
|
|
80
|
+
gap: 2px;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.amb-fader {
|
|
84
|
+
position: relative;
|
|
85
|
+
width: var(--ambx-grid);
|
|
86
|
+
height: calc(var(--ambx-grid) * 30);
|
|
87
|
+
border-radius: var(--ambx-grid);
|
|
88
|
+
background-color: var(--amb-lume);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.amb-fader-thumb {
|
|
92
|
+
position: absolute;
|
|
93
|
+
width: calc(var(--ambx-grid) * 6);
|
|
94
|
+
height: calc(var(--ambx-grid) * 9);
|
|
95
|
+
left: 50%;
|
|
96
|
+
top: 50%;
|
|
97
|
+
transform: translate(-50%, -50%);
|
|
98
|
+
border-radius: calc(var(--ambx-grid) * 3);
|
|
99
|
+
display: grid;
|
|
100
|
+
place-items: center;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.amb-fader-grip {
|
|
104
|
+
display: flex;
|
|
105
|
+
gap: 2px;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.amb-fader-dot {
|
|
109
|
+
width: 3px;
|
|
110
|
+
height: 3px;
|
|
111
|
+
border-radius: 50%;
|
|
112
|
+
background: radial-gradient(
|
|
113
|
+
circle at 30% 30%,
|
|
114
|
+
color-mix(in oklab, var(--amb-lume), white 30%) 0%,
|
|
115
|
+
var(--amb-lume) 55%,
|
|
116
|
+
color-mix(in oklab, var(--amb-lume), black 35%) 100%
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.amb-slider {
|
|
121
|
+
position: relative;
|
|
122
|
+
height: var(--ambx-grid);
|
|
123
|
+
width: calc(var(--ambx-grid) * 30);
|
|
124
|
+
border-radius: var(--ambx-grid);
|
|
125
|
+
background-color: var(--amb-lume);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.amb-slider-thumb {
|
|
129
|
+
position: absolute;
|
|
130
|
+
width: calc(var(--ambx-grid) * 9);
|
|
131
|
+
height: calc(var(--ambx-grid) * 6);
|
|
132
|
+
top: 50%;
|
|
133
|
+
left: 50%;
|
|
134
|
+
transform: translate(-50%, -50%);
|
|
135
|
+
border-radius: calc(var(--ambx-grid) * 3);
|
|
136
|
+
display: grid;
|
|
137
|
+
place-items: center;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.amb-slider-grip {
|
|
141
|
+
display: flex;
|
|
142
|
+
flex-direction: column;
|
|
143
|
+
gap: 2px;
|
|
144
|
+
box-shadow:
|
|
145
|
+
inset 0 var(--ambx-grid-quarter) var(--ambx-grid-quarter) rgb(255 255 255 / 0.28),
|
|
146
|
+
inset 0 calc(var(--ambx-grid-quarter) * -1) var(--ambx-grid-quarter) rgb(0 0 0 / 0.22);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.amb-led {
|
|
150
|
+
width: calc(var(--ambx-grid) * 2);
|
|
151
|
+
height: calc(var(--ambx-grid) * 2);
|
|
152
|
+
border-radius: 50%;
|
|
153
|
+
background-color: var(--amb-led-color, #0f0);
|
|
154
|
+
box-shadow: 0 0 var(--ambx-grid-quarter) var(--amb-led-color, #0f0);
|
|
155
|
+
transition: background-color 150ms ease, box-shadow 150ms ease;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.amb-led-off {
|
|
159
|
+
background-color: hsl(
|
|
160
|
+
var(--amb-light-hue)
|
|
161
|
+
var(--amb-light-saturation)
|
|
162
|
+
calc(var(--amb-key-light-intensity) * 30%)
|
|
163
|
+
);
|
|
164
|
+
box-shadow: none;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.amb-switch {
|
|
168
|
+
position: relative;
|
|
169
|
+
width: calc(var(--ambx-grid) * 6);
|
|
170
|
+
height: calc(var(--ambx-grid) * 6);
|
|
171
|
+
border-radius: 50%;
|
|
172
|
+
cursor: pointer;
|
|
173
|
+
border: medium solid var(--amb-lume);
|
|
174
|
+
margin: 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.ambx-panel {
|
|
178
|
+
border-radius: calc(var(--ambx-grid) * 4);
|
|
179
|
+
padding: calc(var(--ambx-grid) * 4);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.ambx-button {
|
|
183
|
+
min-width: calc(var(--ambx-grid) * 18);
|
|
184
|
+
line-height: var(--ambx-type-3-line);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.ambx-switch {
|
|
188
|
+
--ambx-switch-size: calc(var(--ambx-grid) * 6);
|
|
189
|
+
width: var(--ambx-switch-size);
|
|
190
|
+
height: var(--ambx-switch-size);
|
|
191
|
+
display: inline-grid;
|
|
192
|
+
place-items: center;
|
|
193
|
+
padding: 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.ambx-switch-sm {
|
|
197
|
+
--ambx-switch-size: calc(var(--ambx-grid) * 5);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.ambx-switch-md {
|
|
201
|
+
--ambx-switch-size: calc(var(--ambx-grid) * 6);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.ambx-switch-lg {
|
|
205
|
+
--ambx-switch-size: calc(var(--ambx-grid) * 8);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.amb-switch-on {
|
|
209
|
+
box-shadow:
|
|
210
|
+
inset 0 0 0 var(--ambx-grid-quarter) rgb(255 255 255 / 0.25);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.ambx-stack {
|
|
214
|
+
display: inline-flex;
|
|
215
|
+
flex-direction: column;
|
|
216
|
+
align-items: center;
|
|
217
|
+
gap: var(--ambx-control-label-gap);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.ambx-label {
|
|
221
|
+
font-size: var(--ambx-label-size);
|
|
222
|
+
line-height: var(--ambx-label-line);
|
|
223
|
+
letter-spacing: var(--ambx-grid-quarter);
|
|
224
|
+
text-transform: uppercase;
|
|
225
|
+
color: var(--amb-label);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.ambx-slider + .ambx-label {
|
|
229
|
+
margin-top: var(--ambx-slider-label-clearance);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.ambx-fader + .ambx-label {
|
|
233
|
+
margin-top: var(--ambx-fader-label-clearance);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.ambx-knob + .ambx-label {
|
|
237
|
+
margin-top: var(--ambx-knob-label-clearance);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.ambx-knob {
|
|
241
|
+
position: relative;
|
|
242
|
+
display: grid;
|
|
243
|
+
place-items: center;
|
|
244
|
+
touch-action: none;
|
|
245
|
+
user-select: none;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.ambx-knob-rotation {
|
|
249
|
+
position: absolute;
|
|
250
|
+
inset: 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.ambx-fader {
|
|
254
|
+
width: calc(var(--ambx-grid) * 2);
|
|
255
|
+
border-radius: 999px;
|
|
256
|
+
touch-action: none;
|
|
257
|
+
user-select: none;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.ambx-fader-thumb {
|
|
261
|
+
transform: translate(-50%, -50%);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.ambx-slider {
|
|
265
|
+
height: calc(var(--ambx-grid) * 2);
|
|
266
|
+
border-radius: 999px;
|
|
267
|
+
touch-action: none;
|
|
268
|
+
user-select: none;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.ambx-slider-thumb {
|
|
272
|
+
transform: translate(-50%, -50%);
|
|
273
|
+
}
|