@blurrow/ui 0.1.0 → 0.2.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/README.md +60 -20
- package/dist/Accordion.d.ts +17 -0
- package/dist/Accordion.js +138 -0
- package/dist/Accordion.js.map +1 -0
- package/dist/Alert.d.ts +13 -0
- package/dist/Alert.js +69 -0
- package/dist/Alert.js.map +1 -0
- package/dist/AuthorCard.d.ts +12 -0
- package/dist/AuthorCard.js +57 -0
- package/dist/AuthorCard.js.map +1 -0
- package/dist/Avatar.d.ts +21 -0
- package/dist/Avatar.js +96 -0
- package/dist/Avatar.js.map +1 -0
- package/dist/Badge.d.ts +12 -0
- package/dist/Badge.js +46 -0
- package/dist/Badge.js.map +1 -0
- package/dist/Banner.d.ts +15 -0
- package/dist/Banner.js +68 -0
- package/dist/Banner.js.map +1 -0
- package/dist/Breadcrumb.d.ts +13 -0
- package/dist/Breadcrumb.js +45 -0
- package/dist/Breadcrumb.js.map +1 -0
- package/dist/Button.d.ts +19 -0
- package/dist/Button.js +282 -0
- package/dist/Button.js.map +1 -0
- package/dist/ButtonGroup.d.ts +19 -0
- package/dist/ButtonGroup.js +62 -0
- package/dist/ButtonGroup.js.map +1 -0
- package/dist/Card.d.ts +40 -0
- package/dist/Card.js +175 -0
- package/dist/Card.js.map +1 -0
- package/dist/CardShell.d.ts +18 -0
- package/dist/CardShell.js +58 -0
- package/dist/CardShell.js.map +1 -0
- package/dist/Carousel.d.ts +22 -0
- package/dist/Carousel.js +247 -0
- package/dist/Carousel.js.map +1 -0
- package/dist/Checkbox.d.ts +15 -0
- package/dist/Checkbox.js +84 -0
- package/dist/Checkbox.js.map +1 -0
- package/dist/CloseButton.d.ts +12 -0
- package/dist/CloseButton.js +63 -0
- package/dist/CloseButton.js.map +1 -0
- package/dist/CodeBlock.d.ts +12 -0
- package/dist/CodeBlock.js +221 -0
- package/dist/CodeBlock.js.map +1 -0
- package/dist/ColorPicker.d.ts +24 -0
- package/dist/ColorPicker.js +366 -0
- package/dist/ColorPicker.js.map +1 -0
- package/dist/Combobox.d.ts +34 -0
- package/dist/Combobox.js +255 -0
- package/dist/Combobox.js.map +1 -0
- package/dist/CommandPalette.d.ts +25 -0
- package/dist/CommandPalette.js +304 -0
- package/dist/CommandPalette.js.map +1 -0
- package/dist/ContextMenu.d.ts +16 -0
- package/dist/ContextMenu.js +209 -0
- package/dist/ContextMenu.js.map +1 -0
- package/dist/DataList.d.ts +15 -0
- package/dist/DataList.js +42 -0
- package/dist/DataList.js.map +1 -0
- package/dist/DatePicker.d.ts +65 -0
- package/dist/DatePicker.js +391 -0
- package/dist/DatePicker.js.map +1 -0
- package/dist/DateRangePicker.d.ts +34 -0
- package/dist/DateRangePicker.js +180 -0
- package/dist/DateRangePicker.js.map +1 -0
- package/dist/Drawer.d.ts +17 -0
- package/dist/Drawer.js +132 -0
- package/dist/Drawer.js.map +1 -0
- package/dist/DropdownMenu.d.ts +23 -0
- package/dist/DropdownMenu.js +185 -0
- package/dist/DropdownMenu.js.map +1 -0
- package/dist/EmptyState.d.ts +13 -0
- package/dist/EmptyState.js +55 -0
- package/dist/EmptyState.js.map +1 -0
- package/dist/FieldMessage.d.ts +26 -0
- package/dist/FieldMessage.js +95 -0
- package/dist/FieldMessage.js.map +1 -0
- package/dist/FileUpload.d.ts +32 -0
- package/dist/FileUpload.js +253 -0
- package/dist/FileUpload.js.map +1 -0
- package/dist/Floating.d.ts +32 -0
- package/dist/Floating.js +95 -0
- package/dist/Floating.js.map +1 -0
- package/dist/HoverCard.d.ts +18 -0
- package/dist/HoverCard.js +90 -0
- package/dist/HoverCard.js.map +1 -0
- package/dist/Input.d.ts +17 -0
- package/dist/Input.js +96 -0
- package/dist/Input.js.map +1 -0
- package/dist/Kbd.d.ts +9 -0
- package/dist/Kbd.js +24 -0
- package/dist/Kbd.js.map +1 -0
- package/dist/Modal.d.ts +15 -0
- package/dist/Modal.js +109 -0
- package/dist/Modal.js.map +1 -0
- package/dist/NumberInput.d.ts +23 -0
- package/dist/NumberInput.js +162 -0
- package/dist/NumberInput.js.map +1 -0
- package/dist/OtpInput.d.ts +26 -0
- package/dist/OtpInput.js +173 -0
- package/dist/OtpInput.js.map +1 -0
- package/dist/Pagination.d.ts +11 -0
- package/dist/Pagination.js +148 -0
- package/dist/Pagination.js.map +1 -0
- package/dist/Popover.d.ts +13 -0
- package/dist/Popover.js +98 -0
- package/dist/Popover.js.map +1 -0
- package/dist/Progress.d.ts +12 -0
- package/dist/Progress.js +61 -0
- package/dist/Progress.js.map +1 -0
- package/dist/QuoteCard.d.ts +13 -0
- package/dist/QuoteCard.js +92 -0
- package/dist/QuoteCard.js.map +1 -0
- package/dist/Radio.d.ts +19 -0
- package/dist/Radio.js +161 -0
- package/dist/Radio.js.map +1 -0
- package/dist/RadioCards.d.ts +27 -0
- package/dist/RadioCards.js +205 -0
- package/dist/RadioCards.js.map +1 -0
- package/dist/ReactionBar.d.ts +15 -0
- package/dist/ReactionBar.js +92 -0
- package/dist/ReactionBar.js.map +1 -0
- package/dist/ReadingProgress.d.ts +8 -0
- package/dist/ReadingProgress.js +51 -0
- package/dist/ReadingProgress.js.map +1 -0
- package/dist/ReadingTime.d.ts +9 -0
- package/dist/ReadingTime.js +39 -0
- package/dist/ReadingTime.js.map +1 -0
- package/dist/Select.d.ts +20 -0
- package/dist/Select.js +244 -0
- package/dist/Select.js.map +1 -0
- package/dist/Separator.d.ts +14 -0
- package/dist/Separator.js +33 -0
- package/dist/Separator.js.map +1 -0
- package/dist/ShareButtons.d.ts +10 -0
- package/dist/ShareButtons.js +107 -0
- package/dist/ShareButtons.js.map +1 -0
- package/dist/Sidebar.d.ts +30 -0
- package/dist/Sidebar.js +140 -0
- package/dist/Sidebar.js.map +1 -0
- package/dist/Skeleton.d.ts +15 -0
- package/dist/Skeleton.js +67 -0
- package/dist/Skeleton.js.map +1 -0
- package/dist/Slider.d.ts +27 -0
- package/dist/Slider.js +182 -0
- package/dist/Slider.js.map +1 -0
- package/dist/Spinner.d.ts +12 -0
- package/dist/Spinner.js +24 -0
- package/dist/Spinner.js.map +1 -0
- package/dist/SpotlightCard.d.ts +17 -0
- package/dist/SpotlightCard.js +87 -0
- package/dist/SpotlightCard.js.map +1 -0
- package/dist/Stat.d.ts +19 -0
- package/dist/Stat.js +71 -0
- package/dist/Stat.js.map +1 -0
- package/dist/StatCard.d.ts +24 -0
- package/dist/StatCard.js +212 -0
- package/dist/StatCard.js.map +1 -0
- package/dist/Steps.d.ts +14 -0
- package/dist/Steps.js +94 -0
- package/dist/Steps.js.map +1 -0
- package/dist/Table.d.ts +21 -0
- package/dist/Table.js +93 -0
- package/dist/Table.js.map +1 -0
- package/dist/TableOfContents.d.ts +17 -0
- package/dist/TableOfContents.js +92 -0
- package/dist/TableOfContents.js.map +1 -0
- package/dist/Tabs.d.ts +17 -0
- package/dist/Tabs.js +123 -0
- package/dist/Tabs.js.map +1 -0
- package/dist/TagInput.d.ts +16 -0
- package/dist/TagInput.js +169 -0
- package/dist/TagInput.js.map +1 -0
- package/dist/Textarea.d.ts +13 -0
- package/dist/Textarea.js +74 -0
- package/dist/Textarea.js.map +1 -0
- package/dist/TimePicker.d.ts +28 -0
- package/dist/TimePicker.js +264 -0
- package/dist/TimePicker.js.map +1 -0
- package/dist/Timeline.d.ts +19 -0
- package/dist/Timeline.js +76 -0
- package/dist/Timeline.js.map +1 -0
- package/dist/Toast.d.ts +18 -0
- package/dist/Toast.js +318 -0
- package/dist/Toast.js.map +1 -0
- package/dist/Toggle.d.ts +13 -0
- package/dist/Toggle.js +84 -0
- package/dist/Toggle.js.map +1 -0
- package/dist/Tooltip.d.ts +13 -0
- package/dist/Tooltip.js +138 -0
- package/dist/Tooltip.js.map +1 -0
- package/dist/cli.js +1644 -0
- package/dist/index.d.ts +68 -631
- package/dist/index.js +67 -5558
- package/dist/index.js.map +1 -1
- package/dist/registry/Accordion.tsx +140 -0
- package/dist/registry/Alert.tsx +95 -0
- package/dist/registry/AuthorCard.tsx +67 -0
- package/dist/registry/Avatar.tsx +116 -0
- package/dist/registry/Badge.tsx +55 -0
- package/dist/registry/Banner.tsx +86 -0
- package/dist/registry/Breadcrumb.tsx +60 -0
- package/dist/registry/Button.tsx +361 -0
- package/dist/registry/ButtonGroup.tsx +82 -0
- package/dist/registry/Card.tsx +241 -0
- package/dist/registry/CardShell.tsx +78 -0
- package/dist/registry/Carousel.tsx +296 -0
- package/dist/registry/Checkbox.tsx +104 -0
- package/dist/registry/CloseButton.tsx +68 -0
- package/dist/registry/CodeBlock.tsx +202 -0
- package/dist/registry/ColorPicker.tsx +404 -0
- package/dist/registry/Combobox.tsx +318 -0
- package/dist/registry/CommandPalette.tsx +356 -0
- package/dist/registry/ContextMenu.tsx +241 -0
- package/dist/registry/DataList.tsx +49 -0
- package/dist/registry/DatePicker.tsx +494 -0
- package/dist/registry/DateRangePicker.tsx +232 -0
- package/dist/registry/Drawer.tsx +166 -0
- package/dist/registry/DropdownMenu.tsx +219 -0
- package/dist/registry/EmptyState.tsx +55 -0
- package/dist/registry/FieldMessage.tsx +123 -0
- package/dist/registry/FileUpload.tsx +318 -0
- package/dist/registry/Floating.tsx +134 -0
- package/dist/registry/HoverCard.tsx +113 -0
- package/dist/registry/Input.tsx +108 -0
- package/dist/registry/Kbd.tsx +23 -0
- package/dist/registry/Modal.tsx +130 -0
- package/dist/registry/NumberInput.tsx +183 -0
- package/dist/registry/OtpInput.tsx +219 -0
- package/dist/registry/Pagination.tsx +160 -0
- package/dist/registry/Popover.tsx +116 -0
- package/dist/registry/Progress.tsx +66 -0
- package/dist/registry/QuoteCard.tsx +93 -0
- package/dist/registry/Radio.tsx +205 -0
- package/dist/registry/RadioCards.tsx +247 -0
- package/dist/registry/ReactionBar.tsx +101 -0
- package/dist/registry/ReadingProgress.tsx +51 -0
- package/dist/registry/ReadingTime.tsx +35 -0
- package/dist/registry/Select.tsx +265 -0
- package/dist/registry/Separator.tsx +52 -0
- package/dist/registry/ShareButtons.tsx +134 -0
- package/dist/registry/Sidebar.tsx +182 -0
- package/dist/registry/Skeleton.tsx +83 -0
- package/dist/registry/Slider.tsx +229 -0
- package/dist/registry/Spinner.tsx +32 -0
- package/dist/registry/SpotlightCard.tsx +106 -0
- package/dist/registry/Stat.tsx +82 -0
- package/dist/registry/StatCard.tsx +256 -0
- package/dist/registry/Steps.tsx +109 -0
- package/dist/registry/Table.tsx +123 -0
- package/dist/registry/TableOfContents.tsx +116 -0
- package/dist/registry/Tabs.tsx +136 -0
- package/dist/registry/TagInput.tsx +191 -0
- package/dist/registry/Textarea.tsx +84 -0
- package/dist/registry/TimePicker.tsx +315 -0
- package/dist/registry/Timeline.tsx +102 -0
- package/dist/registry/Toast.tsx +388 -0
- package/dist/registry/Toggle.tsx +96 -0
- package/dist/registry/Tooltip.tsx +160 -0
- package/dist/registry/styles.css +1207 -0
- package/dist/registry/useFloatingPanel.ts +112 -0
- package/dist/registry/useModalBehavior.ts +77 -0
- package/dist/styles.css +868 -62
- package/dist/useFloatingPanel.d.ts +24 -0
- package/dist/useFloatingPanel.js +89 -0
- package/dist/useFloatingPanel.js.map +1 -0
- package/dist/useModalBehavior.d.ts +15 -0
- package/dist/useModalBehavior.js +57 -0
- package/dist/useModalBehavior.js.map +1 -0
- package/package.json +23 -6
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
|
|
5
|
+
interface Column<T> {
|
|
6
|
+
key: keyof T | string;
|
|
7
|
+
label: string;
|
|
8
|
+
align?: "left" | "right" | "center";
|
|
9
|
+
render?: (value: unknown, row: T) => React.ReactNode;
|
|
10
|
+
width?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface TableProps<T extends Record<string, unknown>> {
|
|
14
|
+
columns: Column<T>[];
|
|
15
|
+
rows: T[];
|
|
16
|
+
title?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
onRowClick?: (row: T) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const alignClass = (align?: "left" | "right" | "center") =>
|
|
23
|
+
align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left";
|
|
24
|
+
|
|
25
|
+
/* Title and column headers live in the bezel; rows sit on the raised face. */
|
|
26
|
+
export function Table<T extends Record<string, unknown>>({
|
|
27
|
+
columns,
|
|
28
|
+
rows,
|
|
29
|
+
title,
|
|
30
|
+
description,
|
|
31
|
+
className = "",
|
|
32
|
+
onRowClick,
|
|
33
|
+
}: TableProps<T>) {
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
className={["w-full rounded-[18px] p-1.5", title || description ? "pt-0" : "", className].join(" ")}
|
|
37
|
+
style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
|
|
38
|
+
>
|
|
39
|
+
{(title || description) && (
|
|
40
|
+
<div className="px-3.5 pt-3.5 pb-3">
|
|
41
|
+
{title && <p className="text-[13px] font-semibold text-ink tracking-[-0.01em]">{title}</p>}
|
|
42
|
+
{description && <p className="text-[12px] text-ink-muted mt-0.5">{description}</p>}
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
|
|
46
|
+
<div
|
|
47
|
+
className="bg-panel-face rounded-[13px] overflow-x-auto scroll-thin"
|
|
48
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
49
|
+
>
|
|
50
|
+
<table className="w-full text-left border-collapse">
|
|
51
|
+
{title && <caption className="sr-only">{title}</caption>}
|
|
52
|
+
<thead>
|
|
53
|
+
<tr className="border-b border-line">
|
|
54
|
+
{columns.map((col) => (
|
|
55
|
+
<th
|
|
56
|
+
key={String(col.key)}
|
|
57
|
+
scope="col"
|
|
58
|
+
style={col.width ? { width: col.width } : undefined}
|
|
59
|
+
className={[
|
|
60
|
+
"px-4 pt-3 pb-2.5 text-[12px] font-medium text-ink-muted lowercase whitespace-nowrap",
|
|
61
|
+
alignClass(col.align),
|
|
62
|
+
].join(" ")}
|
|
63
|
+
>
|
|
64
|
+
{col.label}
|
|
65
|
+
</th>
|
|
66
|
+
))}
|
|
67
|
+
</tr>
|
|
68
|
+
</thead>
|
|
69
|
+
<tbody>
|
|
70
|
+
{rows.map((row, ri) => (
|
|
71
|
+
<tr
|
|
72
|
+
key={ri}
|
|
73
|
+
onClick={() => onRowClick?.(row)}
|
|
74
|
+
// Clickable rows are reachable and operable from the keyboard too
|
|
75
|
+
tabIndex={onRowClick ? 0 : undefined}
|
|
76
|
+
onKeyDown={
|
|
77
|
+
onRowClick
|
|
78
|
+
? (e) => {
|
|
79
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
onRowClick(row);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
: undefined
|
|
85
|
+
}
|
|
86
|
+
className={[
|
|
87
|
+
"border-b border-line last:border-0 transition-colors duration-100",
|
|
88
|
+
onRowClick
|
|
89
|
+
? "cursor-pointer hover:bg-surface-hover focus-visible:bg-surface-hover outline-none focus-visible:shadow-[inset_2px_0_0_var(--ink)]"
|
|
90
|
+
: "",
|
|
91
|
+
].join(" ")}
|
|
92
|
+
>
|
|
93
|
+
{columns.map((col, ci) => {
|
|
94
|
+
const val = row[col.key as keyof T] as unknown;
|
|
95
|
+
return (
|
|
96
|
+
<td
|
|
97
|
+
key={String(col.key)}
|
|
98
|
+
className={[
|
|
99
|
+
"px-4 py-3 text-[13px]",
|
|
100
|
+
ci === 0 ? "font-medium text-ink" : "text-ink-body tabular-nums",
|
|
101
|
+
alignClass(col.align),
|
|
102
|
+
].join(" ")}
|
|
103
|
+
>
|
|
104
|
+
{col.render ? col.render(val, row) : String(val ?? "")}
|
|
105
|
+
</td>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
</tr>
|
|
109
|
+
))}
|
|
110
|
+
{rows.length === 0 && (
|
|
111
|
+
<tr>
|
|
112
|
+
<td colSpan={columns.length} className="px-5 py-12 text-center">
|
|
113
|
+
<p className="text-[13px] font-medium text-ink-label">nothing here yet</p>
|
|
114
|
+
<p className="text-[12px] text-ink-muted mt-0.5">rows will show up here once there are some.</p>
|
|
115
|
+
</td>
|
|
116
|
+
</tr>
|
|
117
|
+
)}
|
|
118
|
+
</tbody>
|
|
119
|
+
</table>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
export interface TocItem {
|
|
6
|
+
key: string;
|
|
7
|
+
label: string;
|
|
8
|
+
level: 1 | 2 | 3;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface TableOfContentsProps {
|
|
12
|
+
items: TocItem[];
|
|
13
|
+
activeKey?: string;
|
|
14
|
+
defaultActiveKey?: string;
|
|
15
|
+
onSelect?: (key: string) => void;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const indent: Record<1 | 2 | 3, number> = { 1: 16, 2: 28, 3: 40 };
|
|
20
|
+
|
|
21
|
+
/* A hairline track runs down the left edge; a slim dark marker glides
|
|
22
|
+
along it to the active heading. Nothing else moves — the active label
|
|
23
|
+
just darkens. */
|
|
24
|
+
export function TableOfContents({
|
|
25
|
+
items,
|
|
26
|
+
activeKey,
|
|
27
|
+
defaultActiveKey,
|
|
28
|
+
onSelect,
|
|
29
|
+
className = "",
|
|
30
|
+
}: TableOfContentsProps) {
|
|
31
|
+
const [internal, setInternal] = useState(defaultActiveKey ?? items[0]?.key ?? "");
|
|
32
|
+
const current = activeKey !== undefined ? activeKey : internal;
|
|
33
|
+
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
|
|
34
|
+
const [marker, setMarker] = useState<{ top: number; height: number } | null>(null);
|
|
35
|
+
const [ready, setReady] = useState(false);
|
|
36
|
+
|
|
37
|
+
const measure = () => {
|
|
38
|
+
const el = refs.current[current];
|
|
39
|
+
setMarker(el ? { top: el.offsetTop, height: el.offsetHeight } : null);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
useLayoutEffect(() => {
|
|
43
|
+
measure();
|
|
44
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
45
|
+
}, [current]);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
// Only animate moves after the first placement
|
|
49
|
+
const id = requestAnimationFrame(() => setReady(true));
|
|
50
|
+
return () => cancelAnimationFrame(id);
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const handleSelect = (key: string) => {
|
|
54
|
+
setInternal(key);
|
|
55
|
+
onSelect?.(key);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const slide = ready ? "top 360ms var(--ease-out-soft), height 360ms var(--ease-out-soft)" : "none";
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<nav
|
|
62
|
+
aria-label="On this page"
|
|
63
|
+
className={["rounded-[18px] p-1.5", className].filter(Boolean).join(" ")}
|
|
64
|
+
style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
|
|
65
|
+
>
|
|
66
|
+
<div
|
|
67
|
+
className="bg-panel-face rounded-[13px] px-5 pt-4 pb-4"
|
|
68
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
69
|
+
>
|
|
70
|
+
<p aria-hidden className="text-[12px] font-medium text-ink-faint mb-3">
|
|
71
|
+
on this page
|
|
72
|
+
</p>
|
|
73
|
+
|
|
74
|
+
<div className="relative">
|
|
75
|
+
<span aria-hidden className="absolute left-0 top-0 bottom-0 w-px bg-ink/[0.08]" />
|
|
76
|
+
{marker && (
|
|
77
|
+
<span
|
|
78
|
+
aria-hidden
|
|
79
|
+
className="absolute -left-[0.5px] w-[2px] rounded-full bg-ink"
|
|
80
|
+
style={{ top: marker.top + 6, height: marker.height - 12, transition: slide }}
|
|
81
|
+
/>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
{items.map((item) => {
|
|
85
|
+
const isActive = current === item.key;
|
|
86
|
+
return (
|
|
87
|
+
<button
|
|
88
|
+
key={item.key}
|
|
89
|
+
ref={(el) => {
|
|
90
|
+
refs.current[item.key] = el;
|
|
91
|
+
}}
|
|
92
|
+
type="button"
|
|
93
|
+
onClick={() => handleSelect(item.key)}
|
|
94
|
+
aria-current={isActive ? "location" : undefined}
|
|
95
|
+
className={[
|
|
96
|
+
// Weight stays fixed so the active item doesn't reflow its line
|
|
97
|
+
"relative block w-full text-left py-1.5 text-[13px] leading-5 rounded-[6px] cursor-pointer",
|
|
98
|
+
"transition-colors duration-200 outline-none",
|
|
99
|
+
"focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ink",
|
|
100
|
+
isActive
|
|
101
|
+
? "text-ink font-medium"
|
|
102
|
+
: item.level === 1
|
|
103
|
+
? "text-ink-body hover:text-ink"
|
|
104
|
+
: "text-ink-faint hover:text-ink-label",
|
|
105
|
+
].join(" ")}
|
|
106
|
+
style={{ paddingLeft: indent[item.level] }}
|
|
107
|
+
>
|
|
108
|
+
{item.label}
|
|
109
|
+
</button>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</nav>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect, useLayoutEffect, useId } from "react";
|
|
4
|
+
|
|
5
|
+
interface Tab {
|
|
6
|
+
key: string;
|
|
7
|
+
label: string;
|
|
8
|
+
content?: React.ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface TabsProps {
|
|
12
|
+
tabs: Tab[];
|
|
13
|
+
defaultTab?: string;
|
|
14
|
+
onChange?: (key: string) => void;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* A sunken well holding a raised thumb — the same segmented control as
|
|
19
|
+
Pagination. One Tab stop; arrows move between tabs and select them. */
|
|
20
|
+
export function Tabs({ tabs, defaultTab, onChange, className = "" }: TabsProps) {
|
|
21
|
+
const [active, setActive] = useState(defaultTab ?? tabs[0]?.key ?? "");
|
|
22
|
+
const [direction, setDirection] = useState<"left" | "right">("right");
|
|
23
|
+
const [pill, setPill] = useState({ left: 0, width: 0, ready: false });
|
|
24
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
|
26
|
+
const uid = useId();
|
|
27
|
+
|
|
28
|
+
const measurePill = (key: string) => {
|
|
29
|
+
const btn = btnRefs.current[key];
|
|
30
|
+
const list = listRef.current;
|
|
31
|
+
if (!btn || !list) return;
|
|
32
|
+
const lr = list.getBoundingClientRect();
|
|
33
|
+
const br = btn.getBoundingClientRect();
|
|
34
|
+
setPill({ left: br.left - lr.left, width: br.width, ready: true });
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Before paint, so the thumb is never seen out of place
|
|
38
|
+
useLayoutEffect(() => { measurePill(active); }, [active]);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const ro = new ResizeObserver(() => measurePill(active));
|
|
42
|
+
if (listRef.current) ro.observe(listRef.current);
|
|
43
|
+
return () => ro.disconnect();
|
|
44
|
+
}, [active]);
|
|
45
|
+
|
|
46
|
+
const handleSelect = (key: string) => {
|
|
47
|
+
const prevIndex = tabs.findIndex((t) => t.key === active);
|
|
48
|
+
const nextIndex = tabs.findIndex((t) => t.key === key);
|
|
49
|
+
setDirection(nextIndex > prevIndex ? "right" : "left");
|
|
50
|
+
setActive(key);
|
|
51
|
+
onChange?.(key);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
55
|
+
const i = tabs.findIndex((t) => t.key === active);
|
|
56
|
+
let next = -1;
|
|
57
|
+
if (e.key === "ArrowRight") next = (i + 1) % tabs.length;
|
|
58
|
+
if (e.key === "ArrowLeft") next = (i - 1 + tabs.length) % tabs.length;
|
|
59
|
+
if (e.key === "Home") next = 0;
|
|
60
|
+
if (e.key === "End") next = tabs.length - 1;
|
|
61
|
+
if (next < 0) return;
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
const key = tabs[next].key;
|
|
64
|
+
handleSelect(key);
|
|
65
|
+
btnRefs.current[key]?.focus();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const activeTab = tabs.find((t) => t.key === active);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className={["flex flex-col gap-4", className].join(" ")}>
|
|
72
|
+
<div
|
|
73
|
+
ref={listRef}
|
|
74
|
+
role="tablist"
|
|
75
|
+
onKeyDown={onKeyDown}
|
|
76
|
+
className="relative flex w-full sm:inline-flex sm:w-auto sm:self-start items-center rounded-[13px] p-1"
|
|
77
|
+
style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
|
|
78
|
+
>
|
|
79
|
+
{pill.ready && (
|
|
80
|
+
<span
|
|
81
|
+
aria-hidden
|
|
82
|
+
className="absolute top-1 bottom-1 rounded-[9px] pointer-events-none"
|
|
83
|
+
style={{
|
|
84
|
+
left: pill.left,
|
|
85
|
+
width: pill.width,
|
|
86
|
+
background: "var(--face)",
|
|
87
|
+
boxShadow: "var(--thumb-shadow)",
|
|
88
|
+
transition: "left 300ms var(--ease-spring), width 300ms var(--ease-spring)",
|
|
89
|
+
}}
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{tabs.map((tab) => {
|
|
94
|
+
const isActive = active === tab.key;
|
|
95
|
+
return (
|
|
96
|
+
<button
|
|
97
|
+
key={tab.key}
|
|
98
|
+
ref={(el) => { btnRefs.current[tab.key] = el; }}
|
|
99
|
+
id={`${uid}-tab-${tab.key}`}
|
|
100
|
+
role="tab"
|
|
101
|
+
type="button"
|
|
102
|
+
aria-selected={isActive}
|
|
103
|
+
aria-controls={tab.content ? `${uid}-panel-${tab.key}` : undefined}
|
|
104
|
+
tabIndex={isActive ? 0 : -1}
|
|
105
|
+
onClick={() => handleSelect(tab.key)}
|
|
106
|
+
className={[
|
|
107
|
+
"relative z-10 flex-1 sm:flex-none px-3.5 py-2 rounded-[9px] text-[13px] font-medium text-center whitespace-nowrap cursor-pointer",
|
|
108
|
+
"transition-colors duration-150",
|
|
109
|
+
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink",
|
|
110
|
+
isActive ? "text-ink" : "text-ink-muted hover:text-ink-soft",
|
|
111
|
+
].join(" ")}
|
|
112
|
+
>
|
|
113
|
+
{tab.label}
|
|
114
|
+
</button>
|
|
115
|
+
);
|
|
116
|
+
})}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{activeTab?.content && (
|
|
120
|
+
<div
|
|
121
|
+
key={active}
|
|
122
|
+
id={`${uid}-panel-${active}`}
|
|
123
|
+
role="tabpanel"
|
|
124
|
+
aria-labelledby={`${uid}-tab-${active}`}
|
|
125
|
+
tabIndex={0}
|
|
126
|
+
className={[
|
|
127
|
+
"text-[13px] text-ink-body leading-relaxed rounded-lg focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ink",
|
|
128
|
+
direction === "right" ? "animate-tab-slide-right" : "animate-tab-slide-left",
|
|
129
|
+
].join(" ")}
|
|
130
|
+
>
|
|
131
|
+
{activeTab.content}
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect, useId } from "react";
|
|
4
|
+
|
|
5
|
+
interface TagInputProps {
|
|
6
|
+
value?: string[];
|
|
7
|
+
defaultValue?: string[];
|
|
8
|
+
onChange?: (tags: string[]) => void;
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
label?: string;
|
|
11
|
+
hint?: string;
|
|
12
|
+
maxTags?: number;
|
|
13
|
+
fullWidth?: boolean;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface AnimatedTag {
|
|
18
|
+
text: string;
|
|
19
|
+
state: "entering" | "visible" | "exiting";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function TagInput({
|
|
23
|
+
value,
|
|
24
|
+
defaultValue = [],
|
|
25
|
+
onChange,
|
|
26
|
+
placeholder = "add a tag…",
|
|
27
|
+
label,
|
|
28
|
+
hint,
|
|
29
|
+
maxTags,
|
|
30
|
+
fullWidth = false,
|
|
31
|
+
className = "",
|
|
32
|
+
}: TagInputProps) {
|
|
33
|
+
const [internal, setInternal] = useState<string[]>(defaultValue);
|
|
34
|
+
const [input, setInput] = useState("");
|
|
35
|
+
const [animTags, setAnimTags] = useState<AnimatedTag[]>(() =>
|
|
36
|
+
(value ?? defaultValue).map((t) => ({ text: t, state: "visible" as const }))
|
|
37
|
+
);
|
|
38
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
39
|
+
const uid = useId();
|
|
40
|
+
const prevValueRef = useRef<string[]>(value ?? defaultValue);
|
|
41
|
+
|
|
42
|
+
const tags = value !== undefined ? value : internal;
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const prev = prevValueRef.current;
|
|
46
|
+
const next = tags;
|
|
47
|
+
|
|
48
|
+
const added = next.filter((t) => !prev.includes(t));
|
|
49
|
+
const removed = prev.filter((t) => !next.includes(t));
|
|
50
|
+
|
|
51
|
+
if (added.length === 0 && removed.length === 0) return;
|
|
52
|
+
|
|
53
|
+
setAnimTags((current) => {
|
|
54
|
+
const updated = current.map((at) =>
|
|
55
|
+
removed.includes(at.text) ? { ...at, state: "exiting" as const } : at
|
|
56
|
+
);
|
|
57
|
+
added.forEach((t) => {
|
|
58
|
+
if (!updated.find((at) => at.text === t)) {
|
|
59
|
+
updated.push({ text: t, state: "entering" });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return updated;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (added.length > 0) {
|
|
66
|
+
requestAnimationFrame(() => {
|
|
67
|
+
setAnimTags((current) =>
|
|
68
|
+
current.map((at) =>
|
|
69
|
+
at.state === "entering" ? { ...at, state: "visible" } : at
|
|
70
|
+
)
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (removed.length > 0) {
|
|
76
|
+
setTimeout(() => {
|
|
77
|
+
setAnimTags((current) => current.filter((at) => at.state !== "exiting"));
|
|
78
|
+
}, 180);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
prevValueRef.current = next;
|
|
82
|
+
}, [tags]);
|
|
83
|
+
|
|
84
|
+
const update = (next: string[]) => {
|
|
85
|
+
setInternal(next);
|
|
86
|
+
onChange?.(next);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const addTag = (raw: string) => {
|
|
90
|
+
const tag = raw.trim();
|
|
91
|
+
if (!tag || tags.includes(tag)) return;
|
|
92
|
+
if (maxTags && tags.length >= maxTags) return;
|
|
93
|
+
update([...tags, tag]);
|
|
94
|
+
setInput("");
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const removeTag = (index: number) => {
|
|
98
|
+
const tagText = tags[index];
|
|
99
|
+
if (!tagText) return;
|
|
100
|
+
setAnimTags((current) =>
|
|
101
|
+
current.map((at) =>
|
|
102
|
+
at.text === tagText ? { ...at, state: "exiting" as const } : at
|
|
103
|
+
)
|
|
104
|
+
);
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
update(tags.filter((_, i) => i !== index));
|
|
107
|
+
}, 180);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
111
|
+
if (e.key === "Enter" || e.key === ",") {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
addTag(input);
|
|
114
|
+
}
|
|
115
|
+
if (e.key === "Backspace" && !input && tags.length > 0) {
|
|
116
|
+
removeTag(tags.length - 1);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className={["flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
|
|
122
|
+
{label && (
|
|
123
|
+
<label htmlFor={`${uid}-input`} className="text-[12.5px] font-medium text-ink-label tracking-[-0.005em]">
|
|
124
|
+
{label}
|
|
125
|
+
</label>
|
|
126
|
+
)}
|
|
127
|
+
<div
|
|
128
|
+
onClick={() => inputRef.current?.focus()}
|
|
129
|
+
className={[
|
|
130
|
+
"flex flex-wrap items-center gap-1.5 rounded-xl px-3 py-2.5",
|
|
131
|
+
"transition-[box-shadow,background] duration-200 ease-out cursor-text",
|
|
132
|
+
"[background:var(--field)] [box-shadow:var(--field-shadow)]",
|
|
133
|
+
"hover:[background:var(--field-hover)] hover:[box-shadow:var(--field-shadow-hover)]",
|
|
134
|
+
"focus-within:[background:var(--field-focus)] focus-within:[box-shadow:var(--field-shadow-focus)]",
|
|
135
|
+
fullWidth ? "w-full" : "",
|
|
136
|
+
className,
|
|
137
|
+
]
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.join(" ")}
|
|
140
|
+
>
|
|
141
|
+
{animTags.map((at) => (
|
|
142
|
+
<span
|
|
143
|
+
key={at.text}
|
|
144
|
+
className={[
|
|
145
|
+
"inline-flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-[7px] text-[11.5px] font-medium text-ink-label select-none",
|
|
146
|
+
"transition-all duration-180 ease-[cubic-bezier(0.34,1.4,0.64,1)]",
|
|
147
|
+
at.state === "entering" ? "opacity-0 scale-75" : "",
|
|
148
|
+
at.state === "visible" ? "opacity-100 scale-100" : "",
|
|
149
|
+
at.state === "exiting" ? "opacity-0 scale-75" : "",
|
|
150
|
+
].join(" ")}
|
|
151
|
+
style={{
|
|
152
|
+
/* Tags are small raised faces sitting in the sunken field */
|
|
153
|
+
background: "var(--face)",
|
|
154
|
+
boxShadow: "var(--face-shadow)",
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
{at.text}
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
onClick={(e) => {
|
|
161
|
+
e.stopPropagation();
|
|
162
|
+
const idx = tags.indexOf(at.text);
|
|
163
|
+
if (idx !== -1) removeTag(idx);
|
|
164
|
+
}}
|
|
165
|
+
aria-label={`Remove ${at.text}`}
|
|
166
|
+
className="w-4 h-4 inline-flex items-center justify-center rounded-[5px] text-ink-faint hover:text-ink hover:bg-ink/[0.06] cursor-pointer transition-colors"
|
|
167
|
+
>
|
|
168
|
+
<svg width="8" height="8" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
|
169
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
170
|
+
</svg>
|
|
171
|
+
</button>
|
|
172
|
+
</span>
|
|
173
|
+
))}
|
|
174
|
+
<input
|
|
175
|
+
ref={inputRef}
|
|
176
|
+
id={`${uid}-input`}
|
|
177
|
+
type="text"
|
|
178
|
+
value={input}
|
|
179
|
+
onChange={(e) => setInput(e.target.value)}
|
|
180
|
+
onKeyDown={handleKeyDown}
|
|
181
|
+
onBlur={() => { if (input) addTag(input); }}
|
|
182
|
+
placeholder={tags.length === 0 ? placeholder : ""}
|
|
183
|
+
aria-label={label ? undefined : placeholder}
|
|
184
|
+
aria-describedby={hint ? `${uid}-hint` : undefined}
|
|
185
|
+
className="flex-1 min-w-[80px] bg-transparent text-[13.5px] text-ink placeholder:text-ink-faint outline-none py-0.5"
|
|
186
|
+
/>
|
|
187
|
+
</div>
|
|
188
|
+
{hint && <p id={`${uid}-hint`} className="text-[11.5px] text-ink-muted mt-0.5">{hint}</p>}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useRef, useCallback, useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
6
|
+
label?: string;
|
|
7
|
+
hint?: string;
|
|
8
|
+
error?: string;
|
|
9
|
+
fullWidth?: boolean;
|
|
10
|
+
autoResize?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Textarea({
|
|
14
|
+
label,
|
|
15
|
+
hint,
|
|
16
|
+
error,
|
|
17
|
+
fullWidth = false,
|
|
18
|
+
autoResize = false,
|
|
19
|
+
className = "",
|
|
20
|
+
id,
|
|
21
|
+
rows = 3,
|
|
22
|
+
onChange,
|
|
23
|
+
...rest
|
|
24
|
+
}: TextareaProps) {
|
|
25
|
+
const textareaId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
|
|
26
|
+
const messageId = textareaId && (error || hint) ? `${textareaId}-message` : undefined;
|
|
27
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
28
|
+
|
|
29
|
+
const resize = useCallback(() => {
|
|
30
|
+
if (!autoResize || !ref.current) return;
|
|
31
|
+
ref.current.style.height = "auto";
|
|
32
|
+
ref.current.style.height = ref.current.scrollHeight + "px";
|
|
33
|
+
}, [autoResize]);
|
|
34
|
+
|
|
35
|
+
useEffect(() => { resize(); }, [resize]);
|
|
36
|
+
|
|
37
|
+
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
38
|
+
resize();
|
|
39
|
+
onChange?.(e);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
|
|
44
|
+
{label && (
|
|
45
|
+
<label
|
|
46
|
+
htmlFor={textareaId}
|
|
47
|
+
className="text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong"
|
|
48
|
+
>
|
|
49
|
+
{label}
|
|
50
|
+
</label>
|
|
51
|
+
)}
|
|
52
|
+
<textarea
|
|
53
|
+
ref={ref}
|
|
54
|
+
id={textareaId}
|
|
55
|
+
rows={rows}
|
|
56
|
+
onChange={handleChange}
|
|
57
|
+
className={[
|
|
58
|
+
"field rounded-xl px-4 py-3 text-[13.5px] text-ink resize-none scroll-thin",
|
|
59
|
+
"placeholder:text-ink-faint placeholder:transition-colors placeholder:duration-200 focus:placeholder:text-ink-ghost",
|
|
60
|
+
"disabled:opacity-40 disabled:cursor-not-allowed",
|
|
61
|
+
fullWidth ? "w-full" : "",
|
|
62
|
+
className,
|
|
63
|
+
]
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.join(" ")}
|
|
66
|
+
aria-invalid={error ? true : undefined}
|
|
67
|
+
aria-describedby={messageId}
|
|
68
|
+
{...rest}
|
|
69
|
+
/>
|
|
70
|
+
{error && (
|
|
71
|
+
<p id={messageId} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">
|
|
72
|
+
<svg width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
73
|
+
<circle cx="12" cy="12" r="10" />
|
|
74
|
+
<path strokeLinecap="round" d="M12 8v4m0 4h.01" />
|
|
75
|
+
</svg>
|
|
76
|
+
{error}
|
|
77
|
+
</p>
|
|
78
|
+
)}
|
|
79
|
+
{hint && !error && (
|
|
80
|
+
<p id={messageId} className="text-[11.5px] text-ink-muted mt-0.5">{hint}</p>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|