sid-ui 0.1.2 → 0.2.0
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/package.json +1 -1
- package/src/components/BarChart.tsx +3 -3
- package/src/components/CardEffects.tsx +199 -0
- package/src/components/Chip.tsx +250 -0
- package/src/components/CodeInput.tsx +83 -1
- package/src/components/DonutChart.tsx +32 -15
- package/src/components/Glyph.tsx +10 -0
- package/src/components/Notification.tsx +115 -34
- package/src/components/NotificationTrigger.tsx +71 -9
- package/src/components/RadarChart.tsx +220 -0
- package/src/components/RadialBarChart.tsx +147 -0
- package/src/components/Rating.tsx +108 -19
- package/src/components/ScatterChart.tsx +267 -0
- package/src/components/StatusIndicator.tsx +52 -0
- package/src/components/Treemap.tsx +167 -0
- package/src/components/TrendChart.tsx +55 -15
- package/src/components/UptimeBar.tsx +96 -0
- package/src/components/chartMath.ts +151 -0
- package/src/components/index.ts +43 -3
- package/src/styles/components/card-effects.css +304 -0
- package/src/styles/components/charts.css +470 -7
- package/src/styles/components/chip.css +343 -0
- package/src/styles/components/code-input.css +137 -84
- package/src/styles/components/notification.css +236 -41
- package/src/styles/components/status.css +117 -0
- package/src/styles/components.css +99 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useState, type CSSProperties } from "react";
|
|
4
4
|
|
|
5
5
|
import { segmentShares } from "./chartMath";
|
|
6
6
|
import { ChartTable, ChartTip, defaultFormat, type ChartTone } from "./chartParts";
|
|
@@ -84,7 +84,7 @@ export function BarChart({
|
|
|
84
84
|
<span
|
|
85
85
|
className="os-bars-mark"
|
|
86
86
|
data-tone={datum.tone ?? tone}
|
|
87
|
-
style={{ height: size(datum.value) }}
|
|
87
|
+
style={{ height: size(datum.value), "--os-bar-i": index } as CSSProperties}
|
|
88
88
|
/>
|
|
89
89
|
{active === index ? tip(index) : null}
|
|
90
90
|
</div>
|
|
@@ -107,7 +107,7 @@ export function BarChart({
|
|
|
107
107
|
<span
|
|
108
108
|
className="os-bars-mark"
|
|
109
109
|
data-tone={datum.tone ?? tone}
|
|
110
|
-
style={{ width: size(datum.value) }}
|
|
110
|
+
style={{ width: size(datum.value), "--os-bar-i": index } as CSSProperties}
|
|
111
111
|
/>
|
|
112
112
|
{active === index ? tip(index) : null}
|
|
113
113
|
</span>
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useId,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type CSSProperties,
|
|
8
|
+
type PointerEvent,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
|
|
12
|
+
import { Glyph } from "./Glyph";
|
|
13
|
+
|
|
14
|
+
/** lift rises on hover; tilt leans toward the pointer with a glare; spotlight glows under it. */
|
|
15
|
+
export type MotionCardEffect = "lift" | "tilt" | "spotlight";
|
|
16
|
+
|
|
17
|
+
export interface MotionCardProps {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
effect?: MotionCardEffect;
|
|
20
|
+
/** Inner padding as any CSS length. */
|
|
21
|
+
padding?: string;
|
|
22
|
+
/** Width as a number of px or any CSS width. */
|
|
23
|
+
width?: number | string;
|
|
24
|
+
/** Tilt only: the largest lean in degrees. */
|
|
25
|
+
maxTilt?: number;
|
|
26
|
+
/** Spotlight and tilt: the glow color. Defaults to the accent. */
|
|
27
|
+
glowColor?: string;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function size(value: number | string | undefined) {
|
|
32
|
+
return typeof value === "number" ? `${value}px` : value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A card that answers the pointer. The effect is pure CSS driven by custom properties, so moving
|
|
37
|
+
* the pointer never re-renders React. Reduced motion keeps the glow and drops the movement.
|
|
38
|
+
*/
|
|
39
|
+
export function MotionCard({
|
|
40
|
+
children,
|
|
41
|
+
effect = "lift",
|
|
42
|
+
padding = "var(--spacing-5)",
|
|
43
|
+
width,
|
|
44
|
+
maxTilt = 10,
|
|
45
|
+
glowColor,
|
|
46
|
+
className,
|
|
47
|
+
}: MotionCardProps) {
|
|
48
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
49
|
+
|
|
50
|
+
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
|
51
|
+
const node = ref.current;
|
|
52
|
+
if (!node || effect === "lift") return;
|
|
53
|
+
const box = node.getBoundingClientRect();
|
|
54
|
+
const x = (event.clientX - box.left) / box.width;
|
|
55
|
+
const y = (event.clientY - box.top) / box.height;
|
|
56
|
+
node.style.setProperty("--os-mc-x", `${x * 100}%`);
|
|
57
|
+
node.style.setProperty("--os-mc-y", `${y * 100}%`);
|
|
58
|
+
if (effect === "tilt") {
|
|
59
|
+
node.style.setProperty("--os-mc-rx", `${(0.5 - y) * maxTilt * 2}deg`);
|
|
60
|
+
node.style.setProperty("--os-mc-ry", `${(x - 0.5) * maxTilt * 2}deg`);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const onPointerLeave = () => {
|
|
65
|
+
const node = ref.current;
|
|
66
|
+
if (!node) return;
|
|
67
|
+
node.style.setProperty("--os-mc-rx", "0deg");
|
|
68
|
+
node.style.setProperty("--os-mc-ry", "0deg");
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
ref={ref}
|
|
74
|
+
className={["os-mc", `os-mc-${effect}`, className].filter(Boolean).join(" ")}
|
|
75
|
+
style={
|
|
76
|
+
{
|
|
77
|
+
"--os-mc-pad": padding,
|
|
78
|
+
"--os-mc-glow": glowColor,
|
|
79
|
+
width: size(width),
|
|
80
|
+
} as CSSProperties
|
|
81
|
+
}
|
|
82
|
+
onPointerMove={onPointerMove}
|
|
83
|
+
onPointerLeave={onPointerLeave}
|
|
84
|
+
>
|
|
85
|
+
<div className="os-mc-body">{children}</div>
|
|
86
|
+
{effect !== "lift" && <span className="os-mc-light" aria-hidden />}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface FlipCardProps {
|
|
92
|
+
front: ReactNode;
|
|
93
|
+
back: ReactNode;
|
|
94
|
+
/** Controlled side. Omit to let the card flip itself. */
|
|
95
|
+
isFlipped?: boolean;
|
|
96
|
+
onFlipChange?: (isFlipped: boolean) => void;
|
|
97
|
+
/** Both faces share this height. */
|
|
98
|
+
height: number | string;
|
|
99
|
+
width?: number | string;
|
|
100
|
+
/** Names the flip button for assistive tech. */
|
|
101
|
+
label?: string;
|
|
102
|
+
/** vertical flips over the top edge instead of the side. */
|
|
103
|
+
direction?: "horizontal" | "vertical";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Two faces on one card. The round button in the corner turns it over (or flip it from outside
|
|
108
|
+
* with `isFlipped`); the hidden face is inert so focus and screen readers stay on the visible side.
|
|
109
|
+
*/
|
|
110
|
+
export function FlipCard({
|
|
111
|
+
front,
|
|
112
|
+
back,
|
|
113
|
+
isFlipped,
|
|
114
|
+
onFlipChange,
|
|
115
|
+
height,
|
|
116
|
+
width,
|
|
117
|
+
label = "Flip card",
|
|
118
|
+
direction = "horizontal",
|
|
119
|
+
}: FlipCardProps) {
|
|
120
|
+
const [own, setOwn] = useState(false);
|
|
121
|
+
const flipped = isFlipped ?? own;
|
|
122
|
+
const flip = () => {
|
|
123
|
+
if (isFlipped === undefined) setOwn(!flipped);
|
|
124
|
+
onFlipChange?.(!flipped);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div
|
|
129
|
+
className={["os-flip", `os-flip-${direction}`, flipped && "os-flip-on"].filter(Boolean).join(" ")}
|
|
130
|
+
style={{ height: size(height), width: size(width) }}
|
|
131
|
+
>
|
|
132
|
+
<div className="os-flip-inner">
|
|
133
|
+
<div className="os-flip-face os-flip-front" inert={flipped}>
|
|
134
|
+
{front}
|
|
135
|
+
</div>
|
|
136
|
+
<div className="os-flip-face os-flip-back" inert={!flipped}>
|
|
137
|
+
{back}
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
className="os-flip-toggle"
|
|
143
|
+
aria-label={label}
|
|
144
|
+
aria-pressed={flipped}
|
|
145
|
+
onClick={flip}
|
|
146
|
+
>
|
|
147
|
+
<Glyph name="refresh" />
|
|
148
|
+
</button>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface ExpandableCardProps {
|
|
154
|
+
/** Always visible; clicking it toggles the body. */
|
|
155
|
+
header: ReactNode;
|
|
156
|
+
children: ReactNode;
|
|
157
|
+
defaultExpanded?: boolean;
|
|
158
|
+
isExpanded?: boolean;
|
|
159
|
+
onExpandedChange?: (isExpanded: boolean) => void;
|
|
160
|
+
width?: number | string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A card whose body slides open under its header — FAQs, job details, order lines. */
|
|
164
|
+
export function ExpandableCard({
|
|
165
|
+
header,
|
|
166
|
+
children,
|
|
167
|
+
defaultExpanded = false,
|
|
168
|
+
isExpanded,
|
|
169
|
+
onExpandedChange,
|
|
170
|
+
width,
|
|
171
|
+
}: ExpandableCardProps) {
|
|
172
|
+
const id = useId();
|
|
173
|
+
const [own, setOwn] = useState(defaultExpanded);
|
|
174
|
+
const open = isExpanded ?? own;
|
|
175
|
+
const toggle = () => {
|
|
176
|
+
if (isExpanded === undefined) setOwn(!open);
|
|
177
|
+
onExpandedChange?.(!open);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div className={open ? "os-xcard os-xcard-open" : "os-xcard"} style={{ width: size(width) }}>
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
className="os-xcard-head"
|
|
185
|
+
aria-expanded={open}
|
|
186
|
+
aria-controls={id}
|
|
187
|
+
onClick={toggle}
|
|
188
|
+
>
|
|
189
|
+
<span className="os-xcard-title">{header}</span>
|
|
190
|
+
<span className="os-xcard-chevron" aria-hidden>
|
|
191
|
+
<Glyph name="chevronDown" />
|
|
192
|
+
</span>
|
|
193
|
+
</button>
|
|
194
|
+
<div className="os-xcard-panel" id={id} inert={!open}>
|
|
195
|
+
<div className="os-xcard-body">{children}</div>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useState,
|
|
7
|
+
type KeyboardEvent,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
import { Glyph } from "./Glyph";
|
|
12
|
+
|
|
13
|
+
export type ChipColor =
|
|
14
|
+
| "neutral"
|
|
15
|
+
| "blue"
|
|
16
|
+
| "green"
|
|
17
|
+
| "yellow"
|
|
18
|
+
| "red"
|
|
19
|
+
| "purple"
|
|
20
|
+
| "pink"
|
|
21
|
+
| "teal"
|
|
22
|
+
| "orange"
|
|
23
|
+
| "cyan";
|
|
24
|
+
|
|
25
|
+
/** filled is a solid tint, soft a lighter one, outlined a hairline on the surface. */
|
|
26
|
+
export type ChipVariant = "filled" | "soft" | "outlined";
|
|
27
|
+
|
|
28
|
+
export type ChipSize = "sm" | "md" | "lg";
|
|
29
|
+
|
|
30
|
+
export interface ChipProps {
|
|
31
|
+
label: ReactNode;
|
|
32
|
+
/** Identifies the chip inside a ChipGroup. */
|
|
33
|
+
value?: string;
|
|
34
|
+
variant?: ChipVariant;
|
|
35
|
+
color?: ChipColor;
|
|
36
|
+
size?: ChipSize;
|
|
37
|
+
/** Leading visual — an Icon, a small Avatar, or a color swatch. */
|
|
38
|
+
start?: ReactNode;
|
|
39
|
+
/** A trailing count, such as the number of results behind a filter. */
|
|
40
|
+
count?: number;
|
|
41
|
+
/** Selected filter chips take the accent and grow a check mark. */
|
|
42
|
+
isSelected?: boolean;
|
|
43
|
+
isDisabled?: boolean;
|
|
44
|
+
/** Makes the chip a button. Inside a ChipGroup the group handles clicks. */
|
|
45
|
+
onClick?: () => void;
|
|
46
|
+
/** Makes the chip a link. */
|
|
47
|
+
href?: string;
|
|
48
|
+
/** Adds a remove button. The chip collapses away first, then this runs. */
|
|
49
|
+
onRemove?: () => void;
|
|
50
|
+
removeLabel?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type GroupContext = {
|
|
54
|
+
selected: string[];
|
|
55
|
+
toggle: (value: string) => void;
|
|
56
|
+
/** Unset for a plain group that only shares size and variant. */
|
|
57
|
+
mode?: "single" | "multiple";
|
|
58
|
+
size?: ChipSize;
|
|
59
|
+
variant?: ChipVariant;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const ChipGroupContext = createContext<GroupContext | null>(null);
|
|
63
|
+
|
|
64
|
+
/** Matches the collapse in chip.css, so the chip is gone before the parent drops it. */
|
|
65
|
+
const REMOVE_MS = 180;
|
|
66
|
+
|
|
67
|
+
function reducedMotion() {
|
|
68
|
+
return (
|
|
69
|
+
typeof window !== "undefined" &&
|
|
70
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A compact, rounded label: a tag, a filter, a removable input, or a small action. Put several
|
|
76
|
+
* in a ChipGroup to make them a single or multiple choice.
|
|
77
|
+
*/
|
|
78
|
+
export function Chip({
|
|
79
|
+
label,
|
|
80
|
+
value,
|
|
81
|
+
variant,
|
|
82
|
+
color = "neutral",
|
|
83
|
+
size,
|
|
84
|
+
start,
|
|
85
|
+
count,
|
|
86
|
+
isSelected,
|
|
87
|
+
isDisabled = false,
|
|
88
|
+
onClick,
|
|
89
|
+
href,
|
|
90
|
+
onRemove,
|
|
91
|
+
removeLabel = "Remove",
|
|
92
|
+
}: ChipProps) {
|
|
93
|
+
const group = useContext(ChipGroupContext);
|
|
94
|
+
const [leaving, setLeaving] = useState(false);
|
|
95
|
+
const inGroup = group?.mode != null && value != null;
|
|
96
|
+
const selected = inGroup ? (group?.selected.includes(value) ?? false) : Boolean(isSelected);
|
|
97
|
+
const resolvedSize = size ?? group?.size ?? "md";
|
|
98
|
+
const resolvedVariant = variant ?? group?.variant ?? "soft";
|
|
99
|
+
const selectable = inGroup || isSelected !== undefined;
|
|
100
|
+
|
|
101
|
+
const remove = () => {
|
|
102
|
+
if (!onRemove || leaving) return;
|
|
103
|
+
if (reducedMotion()) {
|
|
104
|
+
onRemove();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
setLeaving(true);
|
|
108
|
+
window.setTimeout(onRemove, REMOVE_MS);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const content = (
|
|
112
|
+
<>
|
|
113
|
+
{selectable && (
|
|
114
|
+
<span className="os-chip-check" aria-hidden>
|
|
115
|
+
<span>
|
|
116
|
+
<Glyph name="check" />
|
|
117
|
+
</span>
|
|
118
|
+
</span>
|
|
119
|
+
)}
|
|
120
|
+
{start != null && <span className="os-chip-start">{start}</span>}
|
|
121
|
+
<span className="os-chip-label">{label}</span>
|
|
122
|
+
{count != null && <span className="os-chip-count">{count}</span>}
|
|
123
|
+
</>
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const className = [
|
|
127
|
+
"os-chip",
|
|
128
|
+
`os-chip-${resolvedVariant}`,
|
|
129
|
+
`os-chip-${color}`,
|
|
130
|
+
`os-chip-${resolvedSize}`,
|
|
131
|
+
selected && "os-chip-selected",
|
|
132
|
+
isDisabled && "os-chip-disabled",
|
|
133
|
+
onRemove && "os-chip-removable",
|
|
134
|
+
leaving && "os-chip-leaving",
|
|
135
|
+
]
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.join(" ");
|
|
138
|
+
|
|
139
|
+
const handleClick = () => {
|
|
140
|
+
if (inGroup) group?.toggle(value);
|
|
141
|
+
onClick?.();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
let main: ReactNode;
|
|
145
|
+
if (href && !isDisabled) {
|
|
146
|
+
main = (
|
|
147
|
+
<a className="os-chip-main" href={href}>
|
|
148
|
+
{content}
|
|
149
|
+
</a>
|
|
150
|
+
);
|
|
151
|
+
} else if (inGroup || onClick || selectable) {
|
|
152
|
+
const single = inGroup && group?.mode === "single";
|
|
153
|
+
main = (
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
className="os-chip-main"
|
|
157
|
+
onClick={handleClick}
|
|
158
|
+
disabled={isDisabled}
|
|
159
|
+
role={single ? "radio" : undefined}
|
|
160
|
+
aria-checked={single ? selected : undefined}
|
|
161
|
+
aria-pressed={!single && selectable ? selected : undefined}
|
|
162
|
+
>
|
|
163
|
+
{content}
|
|
164
|
+
</button>
|
|
165
|
+
);
|
|
166
|
+
} else {
|
|
167
|
+
main = <span className="os-chip-main">{content}</span>;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const onKeyDown = (event: KeyboardEvent<HTMLSpanElement>) => {
|
|
171
|
+
if (onRemove && (event.key === "Backspace" || event.key === "Delete")) {
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
remove();
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<span className={className} onKeyDown={onRemove ? onKeyDown : undefined}>
|
|
179
|
+
<span className="os-chip-inner">
|
|
180
|
+
{main}
|
|
181
|
+
{onRemove && (
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
className="os-chip-remove"
|
|
185
|
+
aria-label={typeof label === "string" ? `${removeLabel} ${label}` : removeLabel}
|
|
186
|
+
onClick={remove}
|
|
187
|
+
disabled={isDisabled}
|
|
188
|
+
>
|
|
189
|
+
<Glyph name="close" />
|
|
190
|
+
</button>
|
|
191
|
+
)}
|
|
192
|
+
</span>
|
|
193
|
+
</span>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface ChipGroupProps {
|
|
198
|
+
children: ReactNode;
|
|
199
|
+
/** Accessible name for the set. */
|
|
200
|
+
label: string;
|
|
201
|
+
/** single behaves like radio buttons, multiple like toggles. Omit for a plain set of chips. */
|
|
202
|
+
selectionMode?: "single" | "multiple";
|
|
203
|
+
/** Selected chip values. */
|
|
204
|
+
value?: string[];
|
|
205
|
+
onChange?: (value: string[]) => void;
|
|
206
|
+
/** single mode: allow clicking the selected chip to clear it. */
|
|
207
|
+
isClearable?: boolean;
|
|
208
|
+
size?: ChipSize;
|
|
209
|
+
variant?: ChipVariant;
|
|
210
|
+
/** Lays the chips in one scrolling row instead of wrapping. */
|
|
211
|
+
isScrollable?: boolean;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A row of chips that share a size and, optionally, a selection. */
|
|
215
|
+
export function ChipGroup({
|
|
216
|
+
children,
|
|
217
|
+
label,
|
|
218
|
+
selectionMode,
|
|
219
|
+
value = [],
|
|
220
|
+
onChange,
|
|
221
|
+
isClearable = false,
|
|
222
|
+
size,
|
|
223
|
+
variant,
|
|
224
|
+
isScrollable = false,
|
|
225
|
+
}: ChipGroupProps) {
|
|
226
|
+
const toggle = (chip: string) => {
|
|
227
|
+
if (!onChange) return;
|
|
228
|
+
if (selectionMode === "single") {
|
|
229
|
+
const isOn = value.includes(chip);
|
|
230
|
+
onChange(isOn ? (isClearable ? [] : value) : [chip]);
|
|
231
|
+
} else {
|
|
232
|
+
onChange(value.includes(chip) ? value.filter((v) => v !== chip) : [...value, chip]);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const role = selectionMode === "single" ? "radiogroup" : "group";
|
|
237
|
+
const groupClass = ["os-chip-group", isScrollable && "os-chip-group-scroll"]
|
|
238
|
+
.filter(Boolean)
|
|
239
|
+
.join(" ");
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<ChipGroupContext.Provider
|
|
243
|
+
value={{ selected: value, toggle, mode: selectionMode, size, variant }}
|
|
244
|
+
>
|
|
245
|
+
<div className={groupClass} role={role} aria-label={label}>
|
|
246
|
+
{children}
|
|
247
|
+
</div>
|
|
248
|
+
</ChipGroupContext.Provider>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
useEffect,
|
|
4
5
|
useId,
|
|
5
6
|
useRef,
|
|
7
|
+
useState,
|
|
6
8
|
type ClipboardEvent,
|
|
7
9
|
type ChangeEvent,
|
|
8
10
|
type CSSProperties,
|
|
@@ -41,7 +43,74 @@ export interface CodeInputProps {
|
|
|
41
43
|
size?: ControlSize;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
|
|
47
|
+
|
|
48
|
+
/** Base spin time plus a little per digit travelled, so 0→9 takes longer than 0→1. */
|
|
49
|
+
const ROLL_BASE_MS = 520;
|
|
50
|
+
const ROLL_PER_STEP_MS = 55;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The visible face of a cell. A digit is a drum of 0–9 that spins from where it was (0 for a new
|
|
54
|
+
* digit) to the typed one and settles with a little overshoot; a letter drops in.
|
|
55
|
+
*/
|
|
56
|
+
function CodeFace({ character, delay }: { character: string; delay: number }) {
|
|
57
|
+
const digit = /^[0-9]$/.test(character) ? Number(character) : null;
|
|
58
|
+
const [position, setPosition] = useState(0);
|
|
59
|
+
const [duration, setDuration] = useState(0);
|
|
60
|
+
const from = useRef(0);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (digit === null) return;
|
|
64
|
+
// Two frames: the drum paints at its start before the transition runs.
|
|
65
|
+
let second = 0;
|
|
66
|
+
const first = requestAnimationFrame(() => {
|
|
67
|
+
second = requestAnimationFrame(() => {
|
|
68
|
+
setDuration(ROLL_BASE_MS + Math.abs(digit - from.current) * ROLL_PER_STEP_MS);
|
|
69
|
+
setPosition(digit);
|
|
70
|
+
from.current = digit;
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
return () => {
|
|
74
|
+
cancelAnimationFrame(first);
|
|
75
|
+
cancelAnimationFrame(second);
|
|
76
|
+
};
|
|
77
|
+
}, [digit]);
|
|
78
|
+
|
|
79
|
+
if (!character) return null;
|
|
80
|
+
|
|
81
|
+
if (digit === null) {
|
|
82
|
+
return (
|
|
83
|
+
<span className="os-code-face" aria-hidden>
|
|
84
|
+
<span className="os-code-letter" style={{ animationDelay: `${delay}ms` }}>
|
|
85
|
+
{character}
|
|
86
|
+
</span>
|
|
87
|
+
</span>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<span className="os-code-face os-code-face-drum" aria-hidden>
|
|
93
|
+
<span
|
|
94
|
+
className="os-code-drum"
|
|
95
|
+
style={
|
|
96
|
+
{
|
|
97
|
+
"--os-code-roll": position,
|
|
98
|
+
transitionDuration: `${duration}ms`,
|
|
99
|
+
transitionDelay: `${delay}ms`,
|
|
100
|
+
} as CSSProperties
|
|
101
|
+
}
|
|
102
|
+
>
|
|
103
|
+
{DIGITS.map((d) => (
|
|
104
|
+
<span key={d} className="os-code-digit">
|
|
105
|
+
{d}
|
|
106
|
+
</span>
|
|
107
|
+
))}
|
|
108
|
+
</span>
|
|
109
|
+
</span>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One cell per character. Typing, pasting, backspace, and arrow keys move between cells. */
|
|
45
114
|
export function CodeInput({
|
|
46
115
|
value,
|
|
47
116
|
onChange,
|
|
@@ -61,6 +130,14 @@ export function CodeInput({
|
|
|
61
130
|
// Focus moves before React re-renders, so handlers read the newest value from here.
|
|
62
131
|
const latest = useRef(value);
|
|
63
132
|
latest.current = value;
|
|
133
|
+
// When several cells change at once (a paste, a reset), their drums start one after another.
|
|
134
|
+
const [seen, setSeen] = useState(value);
|
|
135
|
+
const [isBatch, setIsBatch] = useState(false);
|
|
136
|
+
if (seen !== value) {
|
|
137
|
+
const changed = Array.from({ length }, (_, i) => seen[i] !== value[i]).filter(Boolean).length;
|
|
138
|
+
setSeen(value);
|
|
139
|
+
setIsBatch(changed > 1);
|
|
140
|
+
}
|
|
64
141
|
|
|
65
142
|
const focusCell = (index: number) => {
|
|
66
143
|
const cell = cells.current[Math.max(0, Math.min(index, length - 1))];
|
|
@@ -146,6 +223,11 @@ export function CodeInput({
|
|
|
146
223
|
data-filled={character ? "true" : "false"}
|
|
147
224
|
style={{ "--os-code-index": index } as CSSProperties}
|
|
148
225
|
>
|
|
226
|
+
<CodeFace
|
|
227
|
+
key={character ? "filled" : "empty"}
|
|
228
|
+
character={character}
|
|
229
|
+
delay={isBatch ? index * 70 : 0}
|
|
230
|
+
/>
|
|
149
231
|
<input
|
|
150
232
|
ref={(node) => {
|
|
151
233
|
cells.current[index] = node;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useId, useState } from "react";
|
|
4
4
|
|
|
5
5
|
import { arcPath, segmentShares } from "./chartMath";
|
|
6
6
|
import { ChartTable, defaultFormat, toneAt, type ChartTone } from "./chartParts";
|
|
@@ -36,6 +36,7 @@ export function DonutChart({
|
|
|
36
36
|
formatValue?: (value: number) => string;
|
|
37
37
|
}) {
|
|
38
38
|
const [active, setActive] = useState<number | null>(null);
|
|
39
|
+
const maskId = `os-donut-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
39
40
|
const shares = segmentShares(data.map((slice) => slice.value));
|
|
40
41
|
const total = data.reduce((sum, slice) => sum + Math.max(0, slice.value), 0);
|
|
41
42
|
const outer = size / 2;
|
|
@@ -62,7 +63,20 @@ export function DonutChart({
|
|
|
62
63
|
return (
|
|
63
64
|
<div className="os-donut">
|
|
64
65
|
<div className="os-donut-figure" style={{ width: size, height: size }}>
|
|
65
|
-
<svg width={size} height={size} aria-hidden>
|
|
66
|
+
<svg width={size} height={size} aria-hidden overflow="visible">
|
|
67
|
+
<defs>
|
|
68
|
+
<mask id={maskId}>
|
|
69
|
+
<circle
|
|
70
|
+
className="os-donut-sweep"
|
|
71
|
+
cx={outer}
|
|
72
|
+
cy={outer}
|
|
73
|
+
r={outer / 2 + inner / 2}
|
|
74
|
+
strokeWidth={outer - inner + 12}
|
|
75
|
+
pathLength={1}
|
|
76
|
+
transform={`rotate(-90 ${outer} ${outer})`}
|
|
77
|
+
/>
|
|
78
|
+
</mask>
|
|
79
|
+
</defs>
|
|
66
80
|
<circle
|
|
67
81
|
className="os-donut-track"
|
|
68
82
|
cx={outer}
|
|
@@ -70,19 +84,22 @@ export function DonutChart({
|
|
|
70
84
|
r={(outer + inner) / 2}
|
|
71
85
|
strokeWidth={outer - inner}
|
|
72
86
|
/>
|
|
73
|
-
{
|
|
74
|
-
d
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
87
|
+
<g mask={`url(#${maskId})`}>
|
|
88
|
+
{arcs.map(({ slice, index, d }) =>
|
|
89
|
+
d ? (
|
|
90
|
+
<path
|
|
91
|
+
key={slice.label}
|
|
92
|
+
d={d}
|
|
93
|
+
className="os-donut-slice"
|
|
94
|
+
data-tone={slice.tone}
|
|
95
|
+
data-dim={active !== null && active !== index ? true : undefined}
|
|
96
|
+
data-active={active === index || undefined}
|
|
97
|
+
onPointerEnter={() => setActive(index)}
|
|
98
|
+
onPointerLeave={() => setActive(null)}
|
|
99
|
+
/>
|
|
100
|
+
) : null,
|
|
101
|
+
)}
|
|
102
|
+
</g>
|
|
86
103
|
</svg>
|
|
87
104
|
<div className="os-donut-center" aria-live="polite">
|
|
88
105
|
<strong>{formatValue(focus ? focus.value : total)}</strong>
|
package/src/components/Glyph.tsx
CHANGED
|
@@ -224,6 +224,16 @@ const MARKS = {
|
|
|
224
224
|
d: "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z",
|
|
225
225
|
},
|
|
226
226
|
],
|
|
227
|
+
alert: [
|
|
228
|
+
{ d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" },
|
|
229
|
+
{ d: "M12 9v4" },
|
|
230
|
+
{ d: "M12 17h.01" },
|
|
231
|
+
],
|
|
232
|
+
alertCircle: [
|
|
233
|
+
{ cx: "12", cy: "12", r: "10" },
|
|
234
|
+
{ x1: "12", y1: "8", x2: "12", y2: "12" },
|
|
235
|
+
{ x1: "12", y1: "16", x2: "12.01", y2: "16" },
|
|
236
|
+
],
|
|
227
237
|
info: [{ cx: "12", cy: "12", r: "10" }, { d: "M12 16v-4" }, { d: "M12 8h.01" }],
|
|
228
238
|
panelRight: [{ x: "3", y: "3", width: "18", height: "18", rx: "2" }, { d: "M15 3v18" }],
|
|
229
239
|
creditCard: [{ x: "2", y: "5", width: "20", height: "14", rx: "2" }, { d: "M2 10h20" }],
|