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.
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { Glyph } from "./Glyph";
3
+ import { useId, type ReactNode } from "react";
4
4
 
5
- import type { ReactNode } from "react";
5
+ import { Glyph } from "./Glyph";
6
6
 
7
7
  /** Accent on the unread mark. The title always says what happened. */
8
8
  export type NotificationTone = "accent" | "success" | "warning" | "danger" | "neutral";
@@ -16,7 +16,13 @@ export interface NotificationProps {
16
16
  time?: ReactNode;
17
17
  /** Leading visual — an avatar or an icon. */
18
18
  start?: ReactNode;
19
- /** A next step, such as a button or a link. */
19
+ /** A small icon pinned to the corner of `start`, tinted by `tone` — what kind of event this is. */
20
+ badge?: ReactNode;
21
+ /** Rich detail under the description: a quoted comment, a file, an amount. Keep it non-interactive. */
22
+ preview?: ReactNode;
23
+ /** Buttons under the copy, such as Accept and Decline. */
24
+ actions?: ReactNode;
25
+ /** A trailing next step, such as a button or a link. */
20
26
  action?: ReactNode;
21
27
  /** Draws the unread mark. Read items stay quiet. */
22
28
  unread?: boolean;
@@ -34,6 +40,19 @@ export interface NotificationListProps {
34
40
  label?: string;
35
41
  /** Title and actions above the list, such as “Mark all read”. */
36
42
  header?: ReactNode;
43
+ /** Shown in place of the rows when there are none. */
44
+ empty?: ReactNode;
45
+ }
46
+
47
+ export interface NotificationGroupProps {
48
+ /** The section title, such as “Today” or “Earlier”. */
49
+ label: ReactNode;
50
+ children: ReactNode;
51
+ }
52
+
53
+ function hasItems(children: ReactNode) {
54
+ const filled = (child: ReactNode) => child != null && child !== false;
55
+ return Array.isArray(children) ? children.some(filled) : filled(children);
37
56
  }
38
57
 
39
58
  /** A feed of things that happened — unread marks, times, and optional actions. */
@@ -41,23 +60,66 @@ export function NotificationList({
41
60
  children,
42
61
  label = "Notifications",
43
62
  header,
63
+ empty,
44
64
  }: NotificationListProps) {
65
+ const showEmpty = empty != null && !hasItems(children);
45
66
  return (
46
67
  <section className="os-notes" aria-label={header ? undefined : label}>
47
68
  {header && <div className="os-notes-header">{header}</div>}
48
- <ul className="os-notes-list" aria-label={header ? label : undefined}>
69
+ {showEmpty ? (
70
+ <div className="os-notes-empty">{empty}</div>
71
+ ) : (
72
+ <ul className="os-notes-list" aria-label={header ? label : undefined}>
73
+ {children}
74
+ </ul>
75
+ )}
76
+ </section>
77
+ );
78
+ }
79
+
80
+ export interface NotificationIconProps {
81
+ /** An Icon or Glyph. */
82
+ icon: ReactNode;
83
+ tone?: NotificationTone;
84
+ }
85
+
86
+ /** A tinted round icon for `start` when the event comes from the system, not a person. */
87
+ export function NotificationIcon({ icon, tone = "accent" }: NotificationIconProps) {
88
+ return (
89
+ <span className={`os-note-icon os-note-icon-${tone}`} aria-hidden>
90
+ {icon}
91
+ </span>
92
+ );
93
+ }
94
+
95
+ /** A labelled run of notifications inside a NotificationList — “Today”, “This week”. */
96
+ export function NotificationGroup({ label, children }: NotificationGroupProps) {
97
+ const id = useId();
98
+ return (
99
+ <li className="os-note-group">
100
+ <div className="os-note-group-label" id={id}>
101
+ {label}
102
+ </div>
103
+ <ul className="os-notes-list" aria-labelledby={id}>
49
104
  {children}
50
105
  </ul>
51
- </section>
106
+ </li>
52
107
  );
53
108
  }
54
109
 
55
- /** One item in a notification feed. Unread items announce themselves; the title carries the meaning. */
110
+ /**
111
+ * One item in a notification feed. Unread items announce themselves; the title carries the
112
+ * meaning. With `href` or `onClick` the whole row is the target, while `actions`, `action`,
113
+ * and the dismiss button stay separately clickable.
114
+ */
56
115
  export function Notification({
57
116
  title,
58
117
  description,
59
118
  time,
60
119
  start,
120
+ badge,
121
+ preview,
122
+ actions,
61
123
  action,
62
124
  unread = false,
63
125
  tone = "accent",
@@ -66,39 +128,58 @@ export function Notification({
66
128
  onDismiss,
67
129
  dismissLabel = "Dismiss",
68
130
  }: NotificationProps) {
69
- const body = (
131
+ const label = (
70
132
  <>
71
- <span
72
- className={["os-note-mark", unread && `os-note-tone-${tone}`].filter(Boolean).join(" ")}
73
- aria-hidden
74
- />
75
- <span className={start ? "os-note-start" : "os-note-start os-note-start-empty"}>{start}</span>
76
- <span className="os-note-body">
77
- <span className="os-note-title">{title}</span>
78
- {description != null && <span className="os-note-description">{description}</span>}
79
- </span>
80
- {time != null && <span className="os-note-time">{time}</span>}
133
+ {unread && <span className="os-sr">Unread. </span>}
134
+ {title}
81
135
  </>
82
136
  );
83
137
 
84
138
  return (
85
- <li className={["os-note", unread && "os-note-unread"].filter(Boolean).join(" ")}>
86
- {href ? (
87
- <a className="os-note-main" href={href}>
88
- {body}
89
- {unread && <span className="os-sr">Unread. </span>}
90
- </a>
91
- ) : onClick ? (
92
- <button type="button" className="os-note-main" onClick={onClick}>
93
- {body}
94
- {unread && <span className="os-sr">Unread. </span>}
95
- </button>
96
- ) : (
97
- <div className="os-note-main">
98
- {body}
99
- {unread && <span className="os-sr">Unread. </span>}
100
- </div>
101
- )}
139
+ <li
140
+ className={[
141
+ "os-note",
142
+ unread && "os-note-unread",
143
+ (href || onClick) && "os-note-interactive",
144
+ ]
145
+ .filter(Boolean)
146
+ .join(" ")}
147
+ >
148
+ <div className="os-note-main">
149
+ <span
150
+ className={["os-note-mark", unread && `os-note-tone-${tone}`].filter(Boolean).join(" ")}
151
+ aria-hidden
152
+ />
153
+ {start != null && (
154
+ <span className="os-note-start">
155
+ {start}
156
+ {badge != null && (
157
+ <span className={`os-note-badge os-note-badge-${tone}`} aria-hidden>
158
+ {badge}
159
+ </span>
160
+ )}
161
+ </span>
162
+ )}
163
+ <span className="os-note-body">
164
+ <span className="os-note-title">
165
+ {href ? (
166
+ <a className="os-note-hit" href={href}>
167
+ {label}
168
+ </a>
169
+ ) : onClick ? (
170
+ <button type="button" className="os-note-hit" onClick={onClick}>
171
+ {label}
172
+ </button>
173
+ ) : (
174
+ label
175
+ )}
176
+ </span>
177
+ {description != null && <span className="os-note-description">{description}</span>}
178
+ {preview != null && <span className="os-note-preview">{preview}</span>}
179
+ {actions != null && <span className="os-note-actions">{actions}</span>}
180
+ </span>
181
+ {time != null && <span className="os-note-time">{time}</span>}
182
+ </div>
102
183
  {action && <span className="os-note-action">{action}</span>}
103
184
  {onDismiss && (
104
185
  <button
@@ -12,26 +12,49 @@ import {
12
12
  } from "react";
13
13
  import { createPortal } from "react-dom";
14
14
 
15
- import { Glyph } from "./Glyph";
15
+ import { Button } from "./Action";
16
+ import { Glyph, type GlyphName } from "./Glyph";
16
17
 
17
18
  import type { NotificationTone } from "./Notification";
18
19
 
19
20
  /** Where a triggered notification sits. Logical start/end mirror under RTL. */
20
21
  export type NotificationPosition = "topStart" | "topEnd" | "bottomStart" | "bottomEnd";
21
22
 
23
+ /** A button on a triggered notification. Clicking it runs `onClick`, then dismisses. */
24
+ export interface NotificationAction {
25
+ label: string;
26
+ onClick: () => void;
27
+ variant?: "primary" | "secondary" | "ghost";
28
+ }
29
+
22
30
  export interface ShowNotificationOptions {
23
31
  title: ReactNode;
24
32
  description?: ReactNode;
25
33
  tone?: NotificationTone;
26
34
  /** Corner of the viewport. */
27
35
  position?: NotificationPosition;
28
- /** How long it stays. 0 keeps it until dismissed. */
36
+ /** How long it stays. 0 keeps it until dismissed. Hovering or focusing the card pauses it. */
29
37
  duration?: number;
38
+ /** Leading visual in place of the tone icon — an avatar or a thumbnail. */
39
+ start?: ReactNode;
40
+ /** Replaces the tone's default icon. */
41
+ icon?: ReactNode;
42
+ /** Up to two buttons under the copy, such as Undo or View. */
43
+ actions?: NotificationAction[];
44
+ /** Draws the time left as a bar along the bottom edge. Defaults to true when it times out. */
45
+ showProgress?: boolean;
30
46
  }
31
47
 
32
48
  export type NotificationDismiss = () => void;
33
49
 
34
50
  const POSITIONS: NotificationPosition[] = ["topStart", "topEnd", "bottomStart", "bottomEnd"];
51
+ const TONE_GLYPH: Record<NotificationTone, GlyphName> = {
52
+ accent: "info",
53
+ success: "check",
54
+ warning: "alert",
55
+ danger: "alertCircle",
56
+ neutral: "bell",
57
+ };
35
58
  const DEFAULT_DURATION_MS = 5000;
36
59
  const DEFAULT_POSITION: NotificationPosition = "topEnd";
37
60
  /** Matches --duration-slow, so the card finishes its exit before it unmounts. */
@@ -44,6 +67,10 @@ interface Notice {
44
67
  tone: NotificationTone;
45
68
  position: NotificationPosition;
46
69
  duration: number;
70
+ start?: ReactNode;
71
+ icon?: ReactNode;
72
+ actions: NotificationAction[];
73
+ showProgress: boolean;
47
74
  leaving: boolean;
48
75
  }
49
76
 
@@ -91,6 +118,10 @@ export function NotificationViewport({ children }: { children: ReactNode }) {
91
118
  tone: options.tone ?? "accent",
92
119
  position: options.position ?? DEFAULT_POSITION,
93
120
  duration: options.duration ?? DEFAULT_DURATION_MS,
121
+ start: options.start,
122
+ icon: options.icon,
123
+ actions: (options.actions ?? []).slice(0, 2),
124
+ showProgress: options.showProgress ?? true,
94
125
  leaving: false,
95
126
  };
96
127
  setItems((all) => [notice, ...all]);
@@ -208,12 +239,7 @@ function NotificationCard({
208
239
  onRemove: () => void;
209
240
  }) {
210
241
  const titleId = useId();
211
-
212
- useEffect(() => {
213
- if (item.duration === 0 || item.leaving) return;
214
- const timer = window.setTimeout(onDismiss, item.duration);
215
- return () => window.clearTimeout(timer);
216
- }, [item.duration, item.leaving, onDismiss]);
242
+ const timed = item.duration > 0;
217
243
 
218
244
  useEffect(() => {
219
245
  if (!item.leaving) return;
@@ -230,7 +256,13 @@ function NotificationCard({
230
256
  role={item.tone === "danger" ? "alert" : "status"}
231
257
  aria-labelledby={titleId}
232
258
  >
233
- <span className="os-note-card-mark" aria-hidden />
259
+ {item.start != null ? (
260
+ <span className="os-note-card-start">{item.start}</span>
261
+ ) : (
262
+ <span className="os-note-card-icon" aria-hidden>
263
+ {item.icon ?? <Glyph name={TONE_GLYPH[item.tone]} />}
264
+ </span>
265
+ )}
234
266
  <span className="os-note-card-copy">
235
267
  <span className="os-note-card-title" id={titleId}>
236
268
  {item.title}
@@ -238,10 +270,40 @@ function NotificationCard({
238
270
  {item.description != null && (
239
271
  <span className="os-note-card-description">{item.description}</span>
240
272
  )}
273
+ {item.actions.length > 0 && (
274
+ <span className="os-note-card-actions">
275
+ {item.actions.map((action, index) => (
276
+ <Button
277
+ key={action.label}
278
+ label={action.label}
279
+ size="sm"
280
+ variant={action.variant ?? (index === 0 ? "secondary" : "ghost")}
281
+ onClick={() => {
282
+ action.onClick();
283
+ onDismiss();
284
+ }}
285
+ />
286
+ ))}
287
+ </span>
288
+ )}
241
289
  </span>
242
290
  <button type="button" className="os-note-dismiss" aria-label="Dismiss" onClick={onDismiss}>
243
291
  <Glyph name="close" />
244
292
  </button>
293
+ {/* The bar is the timer: it pauses with the card on hover or focus, and its end dismisses. */}
294
+ {timed && !item.leaving && (
295
+ <span
296
+ className={[
297
+ "os-note-card-progress",
298
+ !item.showProgress && "os-note-card-progress-hidden",
299
+ ]
300
+ .filter(Boolean)
301
+ .join(" ")}
302
+ style={{ animationDuration: `${item.duration}ms` }}
303
+ onAnimationEnd={onDismiss}
304
+ aria-hidden
305
+ />
306
+ )}
245
307
  </div>
246
308
  </div>
247
309
  );
@@ -0,0 +1,220 @@
1
+ "use client";
2
+
3
+ import { useRef, useState, type KeyboardEvent } from "react";
4
+
5
+ import { niceTicks, polarPoint, polygonPath } from "./chartMath";
6
+ import {
7
+ ChartLegend,
8
+ ChartTable,
9
+ ChartTip,
10
+ defaultFormat,
11
+ toneAt,
12
+ type ChartTone,
13
+ } from "./chartParts";
14
+ import { useElementWidth } from "./Responsive";
15
+
16
+ export type RadarSeries = {
17
+ label: string;
18
+ /** One per axis, in axis order. */
19
+ values: number[];
20
+ tone?: ChartTone;
21
+ };
22
+
23
+ const DEFAULT_SIZE = 420;
24
+ /** Room around the web for axis names. */
25
+ const LABEL_ROOM = 64;
26
+
27
+ /**
28
+ * Several measures of a few things at once — a skill profile, a product comparison. Each axis
29
+ * runs from the center out; each series is a filled shape. Hover or arrow through the axes to
30
+ * read every series on one axis.
31
+ */
32
+ export function RadarChart({
33
+ axes,
34
+ series,
35
+ label,
36
+ max,
37
+ levels = 4,
38
+ size = DEFAULT_SIZE,
39
+ shape = "polygon",
40
+ formatValue = defaultFormat,
41
+ hasLegend,
42
+ }: {
43
+ /** Axis names, clockwise from twelve o'clock. Three or more. */
44
+ axes: string[];
45
+ series: RadarSeries[];
46
+ /** Accessible name for the chart. */
47
+ label: string;
48
+ /** The value at the rim. Defaults to a clean number above the largest value. */
49
+ max?: number;
50
+ /** Rings in the web. */
51
+ levels?: number;
52
+ /** The largest the chart grows to, in px. It shrinks to fit narrow frames. */
53
+ size?: number;
54
+ /** polygon draws a straight-edged web; circle draws round rings. */
55
+ shape?: "polygon" | "circle";
56
+ formatValue?: (value: number) => string;
57
+ /** Defaults to on for two or more series. */
58
+ hasLegend?: boolean;
59
+ }) {
60
+ const frame = useRef<HTMLDivElement>(null);
61
+ const width = useElementWidth(frame);
62
+ const [active, setActive] = useState<number | null>(null);
63
+
64
+ const side = Math.max(0, Math.min(size, width));
65
+ const center = side / 2;
66
+ const radius = Math.max(0, center - LABEL_ROOM);
67
+ const largest = Math.max(0, ...series.flatMap((s) => s.values));
68
+ const ticks = niceTicks(largest, levels);
69
+ const rim = max ?? ticks[ticks.length - 1];
70
+ const rings = max ? Array.from({ length: levels + 1 }, (_, i) => (max / levels) * i) : ticks;
71
+ const n = axes.length;
72
+ const toned = series.map((item, index) => ({ ...item, tone: toneAt(index, item.tone) }));
73
+ const showLegend = hasLegend ?? series.length > 1;
74
+ const at = (axis: number, value: number) =>
75
+ polarPoint(center, center, rim > 0 ? (Math.max(0, value) / rim) * radius : 0, axis / n);
76
+
77
+ const onKey = (event: KeyboardEvent<HTMLDivElement>) => {
78
+ if (n === 0) return;
79
+ if (["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"].includes(event.key)) {
80
+ event.preventDefault();
81
+ const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1;
82
+ setActive((current) => ((current ?? -step) + step + n) % n);
83
+ } else if (event.key === "Escape") {
84
+ setActive(null);
85
+ }
86
+ };
87
+
88
+ const tipPoint = active === null ? null : polarPoint(center, center, radius + 8, active / n);
89
+
90
+ return (
91
+ <div className="os-chart os-radar">
92
+ {showLegend ? (
93
+ <ChartLegend items={toned.map((item) => ({ label: item.label, tone: item.tone }))} />
94
+ ) : null}
95
+ <div
96
+ ref={frame}
97
+ className="os-chart-frame os-radar-frame"
98
+ style={{ height: side || size }}
99
+ tabIndex={0}
100
+ role="group"
101
+ aria-label={`${label}. Use the arrow keys to read each axis.`}
102
+ onKeyDown={onKey}
103
+ onBlur={() => setActive(null)}
104
+ >
105
+ {side > 0 ? (
106
+ <svg
107
+ width={side}
108
+ height={side}
109
+ className="os-chart-svg"
110
+ aria-hidden
111
+ onPointerLeave={() => setActive(null)}
112
+ >
113
+ {rings.slice(1).map((ring) => {
114
+ const r = rim > 0 ? (ring / rim) * radius : 0;
115
+ return shape === "circle" ? (
116
+ <circle key={ring} className="os-radar-ring" cx={center} cy={center} r={r} />
117
+ ) : (
118
+ <path
119
+ key={ring}
120
+ className="os-radar-ring"
121
+ d={polygonPath(axes.map((_, i) => polarPoint(center, center, r, i / n)))}
122
+ />
123
+ );
124
+ })}
125
+ {rings.slice(1).map((ring) => (
126
+ <text
127
+ key={`t-${ring}`}
128
+ className="os-chart-axis os-radar-tick"
129
+ x={center + 4}
130
+ y={center - (rim > 0 ? (ring / rim) * radius : 0)}
131
+ dy="-0.3em"
132
+ >
133
+ {formatValue(ring)}
134
+ </text>
135
+ ))}
136
+ {axes.map((name, i) => {
137
+ const end = polarPoint(center, center, radius, i / n);
138
+ const labelAt = polarPoint(center, center, radius + 14, i / n);
139
+ const anchor =
140
+ Math.abs(labelAt.x - center) < 4 ? "middle" : labelAt.x > center ? "start" : "end";
141
+ return (
142
+ <g key={name} data-active={active === i || undefined}>
143
+ <line
144
+ className="os-radar-spoke"
145
+ x1={center}
146
+ y1={center}
147
+ x2={end.x}
148
+ y2={end.y}
149
+ />
150
+ <text
151
+ className="os-radar-label"
152
+ x={labelAt.x}
153
+ y={labelAt.y}
154
+ textAnchor={anchor}
155
+ dy={labelAt.y < center - radius * 0.7 ? "-0.2em" : labelAt.y > center + radius * 0.7 ? "0.9em" : "0.35em"}
156
+ >
157
+ {name}
158
+ </text>
159
+ </g>
160
+ );
161
+ })}
162
+ {toned.map((item, s) => {
163
+ const points = axes.map((_, i) => at(i, item.values[i] ?? 0));
164
+ return (
165
+ <g
166
+ key={item.label}
167
+ data-tone={item.tone}
168
+ className="os-radar-series"
169
+ style={{ animationDelay: `${s * 120}ms`, transformOrigin: `${center}px ${center}px` }}
170
+ >
171
+ <path className="os-radar-shape" d={polygonPath(points)} />
172
+ {points.map((point, i) => (
173
+ <circle
174
+ key={axes[i]}
175
+ className="os-radar-dot"
176
+ cx={point.x}
177
+ cy={point.y}
178
+ r={active === i ? 5 : 3}
179
+ />
180
+ ))}
181
+ </g>
182
+ );
183
+ })}
184
+ {/* One wedge per axis catches the pointer anywhere in its slice. */}
185
+ {axes.map((name, i) => {
186
+ const a = polarPoint(center, center, radius + LABEL_ROOM, (i - 0.5) / n);
187
+ const b = polarPoint(center, center, radius + LABEL_ROOM, (i + 0.5) / n);
188
+ return (
189
+ <path
190
+ key={`hit-${name}`}
191
+ className="os-chart-hit"
192
+ d={`M${center},${center}L${a.x},${a.y}L${b.x},${b.y}Z`}
193
+ onPointerEnter={() => setActive(i)}
194
+ />
195
+ );
196
+ })}
197
+ </svg>
198
+ ) : null}
199
+ {active !== null && tipPoint ? (
200
+ <ChartTip
201
+ title={axes[active]}
202
+ left={tipPoint.x + (side - width < 0 ? (width - side) / 2 : 0)}
203
+ top={tipPoint.y}
204
+ align={tipPoint.x < center - 4 ? "end" : tipPoint.x > center + 4 ? "start" : "center"}
205
+ rows={toned.map((item) => ({
206
+ label: item.label,
207
+ value: formatValue(item.values[active] ?? 0),
208
+ tone: item.tone,
209
+ }))}
210
+ />
211
+ ) : null}
212
+ </div>
213
+ <ChartTable
214
+ caption={label}
215
+ headers={["", ...series.map((item) => item.label)]}
216
+ rows={axes.map((axis, i) => [axis, ...series.map((item) => formatValue(item.values[i] ?? 0))])}
217
+ />
218
+ </div>
219
+ );
220
+ }