@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,160 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useRef, useState, useEffect, useLayoutEffect } from "react";
|
|
4
|
+
|
|
5
|
+
interface PaginationProps {
|
|
6
|
+
currentPage: number;
|
|
7
|
+
totalPages: number;
|
|
8
|
+
onPageChange: (page: number) => void;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Pagination({
|
|
13
|
+
currentPage,
|
|
14
|
+
totalPages,
|
|
15
|
+
onPageChange,
|
|
16
|
+
className = "",
|
|
17
|
+
}: PaginationProps) {
|
|
18
|
+
const slots = buildSlots(currentPage, totalPages);
|
|
19
|
+
const [pill, setPill] = useState({ left: 0, width: 0, ready: false });
|
|
20
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
21
|
+
const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
|
22
|
+
|
|
23
|
+
const measurePill = (page: number) => {
|
|
24
|
+
const btn = btnRefs.current[String(page)];
|
|
25
|
+
const container = containerRef.current;
|
|
26
|
+
if (!btn || !container) return;
|
|
27
|
+
const cr = container.getBoundingClientRect();
|
|
28
|
+
const br = btn.getBoundingClientRect();
|
|
29
|
+
setPill({ left: br.left - cr.left, width: br.width, ready: true });
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/* Only the thumb moves: it glides to the new page without overshoot,
|
|
33
|
+
and the numbers themselves stay still. Measured before paint, so it
|
|
34
|
+
is never seen out of place. */
|
|
35
|
+
useLayoutEffect(() => {
|
|
36
|
+
measurePill(currentPage);
|
|
37
|
+
}, [currentPage]);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const ro = new ResizeObserver(() => measurePill(currentPage));
|
|
41
|
+
if (containerRef.current) ro.observe(containerRef.current);
|
|
42
|
+
return () => ro.disconnect();
|
|
43
|
+
}, [currentPage]);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<nav
|
|
47
|
+
aria-label="Pagination"
|
|
48
|
+
className={[
|
|
49
|
+
"inline-flex items-center gap-0.5 rounded-[13px] p-1",
|
|
50
|
+
className,
|
|
51
|
+
].join(" ")}
|
|
52
|
+
style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
|
|
53
|
+
>
|
|
54
|
+
<button
|
|
55
|
+
onClick={() => onPageChange(currentPage - 1)}
|
|
56
|
+
disabled={currentPage <= 1}
|
|
57
|
+
aria-label="Previous page"
|
|
58
|
+
className="group/nav relative overflow-hidden h-8 px-3 rounded-[9px] flex items-center gap-1.5 text-[13px] font-medium text-ink-body hover:text-ink transition-colors cursor-pointer disabled:opacity-30 disabled:pointer-events-none"
|
|
59
|
+
>
|
|
60
|
+
<span className="pointer-events-none absolute inset-0 rounded-[9px] opacity-0 group-hover/nav:opacity-100 transition-opacity duration-200 [background:var(--face)] [box-shadow:var(--thumb-shadow)]" />
|
|
61
|
+
<svg className="relative z-10" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
62
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
63
|
+
</svg>
|
|
64
|
+
<span className="relative z-10 hidden sm:inline">prev</span>
|
|
65
|
+
</button>
|
|
66
|
+
|
|
67
|
+
<div ref={containerRef} className="relative flex items-center">
|
|
68
|
+
{pill.ready && (
|
|
69
|
+
<span
|
|
70
|
+
aria-hidden
|
|
71
|
+
className="absolute top-0 bottom-0 rounded-[9px] pointer-events-none z-0"
|
|
72
|
+
style={{
|
|
73
|
+
left: pill.left,
|
|
74
|
+
width: pill.width,
|
|
75
|
+
background: "var(--face)",
|
|
76
|
+
boxShadow: "var(--thumb-shadow)",
|
|
77
|
+
transition: "left 340ms var(--ease-out-soft), width 340ms var(--ease-out-soft)",
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
{slots.map((slot, i) =>
|
|
83
|
+
slot === "..." ? (
|
|
84
|
+
<span
|
|
85
|
+
key={`dot-${i}`}
|
|
86
|
+
aria-hidden
|
|
87
|
+
className="w-8 h-8 flex items-center justify-center text-[11px] text-ink-faint select-none relative z-10"
|
|
88
|
+
>
|
|
89
|
+
···
|
|
90
|
+
</span>
|
|
91
|
+
) : (
|
|
92
|
+
<button
|
|
93
|
+
key={`slot-${i}`}
|
|
94
|
+
ref={(el) => {
|
|
95
|
+
btnRefs.current[String(slot)] = el;
|
|
96
|
+
}}
|
|
97
|
+
onClick={() => slot !== currentPage && onPageChange(slot as number)}
|
|
98
|
+
aria-label={`Page ${slot}`}
|
|
99
|
+
aria-current={slot === currentPage ? "page" : undefined}
|
|
100
|
+
className={[
|
|
101
|
+
"relative z-10 min-w-8 h-8 px-1 rounded-[9px] flex items-center justify-center text-[13px] font-medium transition-colors duration-200 cursor-pointer tabular-nums",
|
|
102
|
+
slot === currentPage
|
|
103
|
+
? "text-ink"
|
|
104
|
+
: "text-ink-muted hover:text-ink",
|
|
105
|
+
].join(" ")}
|
|
106
|
+
>
|
|
107
|
+
{slot}
|
|
108
|
+
</button>
|
|
109
|
+
),
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<button
|
|
114
|
+
onClick={() => onPageChange(currentPage + 1)}
|
|
115
|
+
disabled={currentPage >= totalPages}
|
|
116
|
+
aria-label="Next page"
|
|
117
|
+
className="group/nav relative overflow-hidden h-8 px-3 rounded-[9px] flex items-center gap-1.5 text-[13px] font-medium text-ink-body hover:text-ink transition-colors cursor-pointer disabled:opacity-30 disabled:pointer-events-none"
|
|
118
|
+
>
|
|
119
|
+
<span className="pointer-events-none absolute inset-0 rounded-[9px] opacity-0 group-hover/nav:opacity-100 transition-opacity duration-200 [background:var(--face)] [box-shadow:var(--thumb-shadow)]" />
|
|
120
|
+
<span className="relative z-10 hidden sm:inline">next</span>
|
|
121
|
+
<svg className="relative z-10" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
122
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
123
|
+
</svg>
|
|
124
|
+
</button>
|
|
125
|
+
</nav>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildSlots(current: number, total: number): (number | "...")[] {
|
|
130
|
+
if (total <= 7) {
|
|
131
|
+
return Array.from({ length: total }, (_, i) => i + 1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const slots: (number | "...")[] = new Array(7);
|
|
135
|
+
|
|
136
|
+
slots[0] = 1;
|
|
137
|
+
slots[6] = total;
|
|
138
|
+
|
|
139
|
+
if (current <= 3) {
|
|
140
|
+
slots[1] = 2;
|
|
141
|
+
slots[2] = 3;
|
|
142
|
+
slots[3] = 4;
|
|
143
|
+
slots[4] = 5;
|
|
144
|
+
slots[5] = "...";
|
|
145
|
+
} else if (current >= total - 2) {
|
|
146
|
+
slots[1] = "...";
|
|
147
|
+
slots[2] = total - 4;
|
|
148
|
+
slots[3] = total - 3;
|
|
149
|
+
slots[4] = total - 2;
|
|
150
|
+
slots[5] = total - 1;
|
|
151
|
+
} else {
|
|
152
|
+
slots[1] = "...";
|
|
153
|
+
slots[2] = current - 1;
|
|
154
|
+
slots[3] = current;
|
|
155
|
+
slots[4] = current + 1;
|
|
156
|
+
slots[5] = "...";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return slots;
|
|
160
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect, useId } from "react";
|
|
4
|
+
import { ENTER_FROM, LAYER, ORIGIN, Portal, useAnchoredPosition } from "./Floating";
|
|
5
|
+
|
|
6
|
+
type PopoverPosition = "top" | "bottom" | "left" | "right";
|
|
7
|
+
|
|
8
|
+
interface PopoverProps {
|
|
9
|
+
trigger: React.ReactNode;
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
position?: PopoverPosition;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Popover({
|
|
16
|
+
trigger,
|
|
17
|
+
children,
|
|
18
|
+
position = "bottom",
|
|
19
|
+
className = "",
|
|
20
|
+
}: PopoverProps) {
|
|
21
|
+
const [open, setOpen] = useState(false);
|
|
22
|
+
const [visible, setVisible] = useState(false);
|
|
23
|
+
const [mounted, setMounted] = useState(false);
|
|
24
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
25
|
+
const uid = useId();
|
|
26
|
+
// Portalled and pinned beside the trigger, flipping if there's no room
|
|
27
|
+
const { setFloating, floatingRef, side } = useAnchoredPosition(ref, { open: mounted, side: position, align: "center" });
|
|
28
|
+
|
|
29
|
+
/* Mounted and faded out during render; the effect only times the rest */
|
|
30
|
+
if (open && !mounted) setMounted(true);
|
|
31
|
+
if (!open && visible) setVisible(false);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (open) {
|
|
35
|
+
requestAnimationFrame(() =>
|
|
36
|
+
requestAnimationFrame(() => {
|
|
37
|
+
setVisible(true);
|
|
38
|
+
// The panel lives at the end of <body>; bring focus to it so Tab reaches its contents
|
|
39
|
+
const panel = floatingRef.current;
|
|
40
|
+
const first = panel?.querySelector<HTMLElement>(
|
|
41
|
+
'button:not([disabled]), [href], input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
42
|
+
);
|
|
43
|
+
(first ?? panel)?.focus({ preventScroll: true });
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
} else {
|
|
47
|
+
const t = setTimeout(() => setMounted(false), 180);
|
|
48
|
+
return () => clearTimeout(t);
|
|
49
|
+
}
|
|
50
|
+
}, [open, floatingRef]);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
function onOutside(e: MouseEvent) {
|
|
54
|
+
const t = e.target as Node;
|
|
55
|
+
if (ref.current?.contains(t) || floatingRef.current?.contains(t)) return;
|
|
56
|
+
setOpen(false);
|
|
57
|
+
}
|
|
58
|
+
document.addEventListener("mousedown", onOutside);
|
|
59
|
+
return () => document.removeEventListener("mousedown", onOutside);
|
|
60
|
+
}, [floatingRef]);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!open) return;
|
|
64
|
+
const onKey = (e: KeyboardEvent) => {
|
|
65
|
+
if (e.key === "Escape") {
|
|
66
|
+
setOpen(false);
|
|
67
|
+
ref.current?.querySelector<HTMLElement>("[data-popover-trigger] button, [data-popover-trigger] a")?.focus();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
document.addEventListener("keydown", onKey);
|
|
71
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
72
|
+
}, [open]);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div ref={ref} className={["relative inline-flex", className].join(" ")}>
|
|
76
|
+
<div data-popover-trigger onClick={() => setOpen((v) => !v)} className="inline-flex cursor-pointer">
|
|
77
|
+
{React.isValidElement(trigger)
|
|
78
|
+
? React.cloneElement(trigger as React.ReactElement<Record<string, unknown>>, {
|
|
79
|
+
"aria-haspopup": "dialog",
|
|
80
|
+
"aria-expanded": open,
|
|
81
|
+
"aria-controls": open ? `${uid}-popover` : undefined,
|
|
82
|
+
})
|
|
83
|
+
: trigger}
|
|
84
|
+
</div>
|
|
85
|
+
{mounted && (
|
|
86
|
+
<Portal>
|
|
87
|
+
<div
|
|
88
|
+
ref={setFloating}
|
|
89
|
+
id={`${uid}-popover`}
|
|
90
|
+
role="dialog"
|
|
91
|
+
tabIndex={-1}
|
|
92
|
+
className="outline-none min-w-[220px] max-w-[calc(100vw-16px)] rounded-[16px] p-1.5 transition-[opacity,transform] duration-200 ease-[var(--ease-spring)]"
|
|
93
|
+
style={{
|
|
94
|
+
position: "fixed",
|
|
95
|
+
top: 0,
|
|
96
|
+
left: 0,
|
|
97
|
+
zIndex: LAYER.floating,
|
|
98
|
+
transformOrigin: ORIGIN[side],
|
|
99
|
+
opacity: visible ? 1 : 0,
|
|
100
|
+
transform: visible ? "none" : `${ENTER_FROM[side]} scale(0.96)`,
|
|
101
|
+
background: "var(--panel-bezel)",
|
|
102
|
+
boxShadow: "var(--overlay-shadow)",
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<div
|
|
106
|
+
className="bg-panel-face rounded-[11px] p-3.5 text-[13px] text-ink-body leading-relaxed"
|
|
107
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
108
|
+
>
|
|
109
|
+
{children}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</Portal>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
interface ProgressProps {
|
|
6
|
+
value: number;
|
|
7
|
+
max?: number;
|
|
8
|
+
label?: string;
|
|
9
|
+
showValue?: boolean;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Progress({
|
|
14
|
+
value,
|
|
15
|
+
max = 100,
|
|
16
|
+
label,
|
|
17
|
+
showValue = false,
|
|
18
|
+
className = "",
|
|
19
|
+
}: ProgressProps) {
|
|
20
|
+
const pct = Math.min(Math.max((value / max) * 100, 0), 100);
|
|
21
|
+
const [displayed, setDisplayed] = useState(0);
|
|
22
|
+
const t = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
t.current = setTimeout(() => setDisplayed(pct), 80);
|
|
26
|
+
return () => { if (t.current) clearTimeout(t.current); };
|
|
27
|
+
}, [pct]);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className={["flex flex-col gap-2", className].join(" ")}>
|
|
31
|
+
{(label || showValue) && (
|
|
32
|
+
<div className="flex items-center justify-between gap-4">
|
|
33
|
+
{label && (
|
|
34
|
+
<span className="text-[13px] font-medium text-ink-label truncate">{label}</span>
|
|
35
|
+
)}
|
|
36
|
+
{showValue && (
|
|
37
|
+
<span aria-hidden className="text-[12px] tabular-nums text-ink-muted shrink-0">
|
|
38
|
+
{Math.round(pct)}%
|
|
39
|
+
</span>
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
<div
|
|
44
|
+
className="w-full h-2 rounded-full p-px overflow-hidden"
|
|
45
|
+
role="progressbar"
|
|
46
|
+
aria-label={label}
|
|
47
|
+
aria-valuenow={value}
|
|
48
|
+
aria-valuemin={0}
|
|
49
|
+
aria-valuemax={max}
|
|
50
|
+
style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
|
|
51
|
+
>
|
|
52
|
+
<div
|
|
53
|
+
className="h-full rounded-full relative overflow-hidden"
|
|
54
|
+
style={{
|
|
55
|
+
width: `${displayed}%`,
|
|
56
|
+
/* Never collapses below a pill, so 1% still reads as started */
|
|
57
|
+
minWidth: displayed > 0 ? 6 : 0,
|
|
58
|
+
background: "var(--face-inverse)",
|
|
59
|
+
boxShadow: "var(--fill-shadow)",
|
|
60
|
+
transition: "width 700ms var(--ease-out-soft)",
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { CardShell } from "./CardShell";
|
|
3
|
+
|
|
4
|
+
interface QuoteCardProps {
|
|
5
|
+
quote: string;
|
|
6
|
+
author: string;
|
|
7
|
+
role?: string;
|
|
8
|
+
avatarSrc?: string;
|
|
9
|
+
theme?: "light" | "dark";
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* Built on CardShell: the quote owns the face; who said it sits on the rail,
|
|
14
|
+
so a stretched card grows the face instead of opening a gap mid-card. */
|
|
15
|
+
export function QuoteCard({
|
|
16
|
+
quote,
|
|
17
|
+
author,
|
|
18
|
+
role,
|
|
19
|
+
avatarSrc,
|
|
20
|
+
theme = "light",
|
|
21
|
+
className = "",
|
|
22
|
+
}: QuoteCardProps) {
|
|
23
|
+
const dark = theme === "dark";
|
|
24
|
+
// Handles like "@name" shouldn't render an "@" monogram
|
|
25
|
+
const initial = (author.match(/[\p{L}\p{N}]/u)?.[0] ?? "?").toLowerCase();
|
|
26
|
+
|
|
27
|
+
const rail = (
|
|
28
|
+
<figcaption className="flex items-center gap-2.5 min-w-0">
|
|
29
|
+
{avatarSrc ? (
|
|
30
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
31
|
+
<img
|
|
32
|
+
src={avatarSrc}
|
|
33
|
+
alt=""
|
|
34
|
+
loading="lazy"
|
|
35
|
+
className="w-7 h-7 rounded-full object-cover shrink-0"
|
|
36
|
+
style={{ boxShadow: dark ? "0 0 0 0.5px var(--photo-edge-dark)" : "0 0 0 0.5px var(--photo-edge)" }}
|
|
37
|
+
/>
|
|
38
|
+
) : (
|
|
39
|
+
<span
|
|
40
|
+
aria-hidden
|
|
41
|
+
className={[
|
|
42
|
+
"w-7 h-7 rounded-full flex items-center justify-center text-[11.5px] font-semibold shrink-0",
|
|
43
|
+
dark ? "text-white" : "text-ink-label",
|
|
44
|
+
].join(" ")}
|
|
45
|
+
style={{
|
|
46
|
+
background: dark ? "var(--tile-dark)" : "var(--tile)",
|
|
47
|
+
boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-shadow)",
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
{initial}
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
<span className="min-w-0 truncate">
|
|
54
|
+
<cite
|
|
55
|
+
className={[
|
|
56
|
+
"not-italic font-semibold tracking-[-0.008em]",
|
|
57
|
+
dark ? "text-white" : "text-ink",
|
|
58
|
+
].join(" ")}
|
|
59
|
+
>
|
|
60
|
+
{author}
|
|
61
|
+
</cite>
|
|
62
|
+
{role && <span> · {role}</span>}
|
|
63
|
+
</span>
|
|
64
|
+
</figcaption>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<CardShell as="figure" theme={theme} className={className} rail={rail} railKind="text">
|
|
69
|
+
<div className="flex-1 flex flex-col p-8">
|
|
70
|
+
<svg
|
|
71
|
+
aria-hidden
|
|
72
|
+
width="22"
|
|
73
|
+
height="17"
|
|
74
|
+
viewBox="0 0 56 44"
|
|
75
|
+
className={["shrink-0 mb-auto", dark ? "text-white/25" : "text-ink-ghost"].join(" ")}
|
|
76
|
+
>
|
|
77
|
+
<path
|
|
78
|
+
fill="currentColor"
|
|
79
|
+
d="M0 44V27.5C0 12.6 7.4 3.4 22.2 0l2.6 6.2C16.9 8.7 13 13.7 12.6 21H24v23H0zm32 0V27.5C32 12.6 39.4 3.4 54.2 0l2.6 6.2C48.9 8.7 45 13.7 44.6 21H56v23H32z"
|
|
80
|
+
/>
|
|
81
|
+
</svg>
|
|
82
|
+
<blockquote
|
|
83
|
+
className={[
|
|
84
|
+
"m-0 pt-8 text-[19px] leading-[1.55] tracking-[-0.015em] text-pretty",
|
|
85
|
+
dark ? "text-neutral-50" : "text-ink",
|
|
86
|
+
].join(" ")}
|
|
87
|
+
>
|
|
88
|
+
{quote}
|
|
89
|
+
</blockquote>
|
|
90
|
+
</div>
|
|
91
|
+
</CardShell>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
interface RadioOption {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface RadioGroupProps {
|
|
13
|
+
options: RadioOption[];
|
|
14
|
+
value?: string;
|
|
15
|
+
defaultValue?: string;
|
|
16
|
+
onChange?: (value: string) => void;
|
|
17
|
+
name?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* Every radio is a sunken field; the selection is one dark face that
|
|
22
|
+
flows between them. Its leading edge leaves first and the trailing edge
|
|
23
|
+
catches up on a spring, so it stretches across the gap like a drop of
|
|
24
|
+
liquid and settles into the new well. One Tab stop for the group,
|
|
25
|
+
arrows move between options (ARIA radio group pattern). */
|
|
26
|
+
interface Blob {
|
|
27
|
+
top: number;
|
|
28
|
+
bottom: number;
|
|
29
|
+
left: number;
|
|
30
|
+
size: number;
|
|
31
|
+
dir: -1 | 0 | 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LEAD = "260ms var(--ease-out-soft)";
|
|
35
|
+
const TRAIL = "480ms cubic-bezier(0.34, 1.25, 0.64, 1) 50ms";
|
|
36
|
+
|
|
37
|
+
/* Where the face sits over a well, in the group's own coordinates. Read
|
|
38
|
+
straight off the DOM, so setting it from an effect is a measurement,
|
|
39
|
+
not derived state */
|
|
40
|
+
function wellBox(group: HTMLElement | null, el: HTMLElement | null, dir: Blob["dir"]): Blob | null {
|
|
41
|
+
if (!group || !el) return null;
|
|
42
|
+
return {
|
|
43
|
+
top: el.offsetTop,
|
|
44
|
+
bottom: group.clientHeight - el.offsetTop - el.offsetHeight,
|
|
45
|
+
left: el.offsetLeft,
|
|
46
|
+
size: el.offsetWidth,
|
|
47
|
+
dir,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function RadioGroup({
|
|
52
|
+
options,
|
|
53
|
+
value,
|
|
54
|
+
defaultValue = "",
|
|
55
|
+
onChange,
|
|
56
|
+
name,
|
|
57
|
+
className = "",
|
|
58
|
+
}: RadioGroupProps) {
|
|
59
|
+
const [internal, setInternal] = useState(defaultValue);
|
|
60
|
+
const selected = value !== undefined ? value : internal;
|
|
61
|
+
const uid = useId();
|
|
62
|
+
const refs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
63
|
+
const groupRef = useRef<HTMLDivElement>(null);
|
|
64
|
+
const prevIndex = useRef(-1);
|
|
65
|
+
const [blob, setBlob] = useState<Blob | null>(null);
|
|
66
|
+
|
|
67
|
+
const select = (val: string, disabled?: boolean) => {
|
|
68
|
+
if (disabled) return;
|
|
69
|
+
setInternal(val);
|
|
70
|
+
onChange?.(val);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const enabled = options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0);
|
|
74
|
+
const selectedIndex = options.findIndex((o) => o.value === selected);
|
|
75
|
+
const tabStop = selectedIndex >= 0 ? selectedIndex : enabled[0];
|
|
76
|
+
|
|
77
|
+
const measure = (dir: Blob["dir"]) =>
|
|
78
|
+
setBlob(wellBox(groupRef.current, refs.current[prevIndex.current], dir));
|
|
79
|
+
|
|
80
|
+
useLayoutEffect(() => {
|
|
81
|
+
const prev = prevIndex.current;
|
|
82
|
+
prevIndex.current = selectedIndex;
|
|
83
|
+
measure(prev < 0 || selectedIndex < 0 ? 0 : selectedIndex > prev ? 1 : selectedIndex < prev ? -1 : 0);
|
|
84
|
+
}, [selectedIndex]);
|
|
85
|
+
|
|
86
|
+
// Rewrapped descriptions move the wells; follow them without animating.
|
|
87
|
+
// An observer reports once on attach — that one is skipped, or it would
|
|
88
|
+
// cut short a move already under way.
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
const group = groupRef.current;
|
|
91
|
+
if (!group) return;
|
|
92
|
+
let attached = false;
|
|
93
|
+
const ro = new ResizeObserver(() => {
|
|
94
|
+
if (attached) measure(0);
|
|
95
|
+
attached = true;
|
|
96
|
+
});
|
|
97
|
+
ro.observe(group);
|
|
98
|
+
return () => ro.disconnect();
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const move = (from: number, step: 1 | -1) => {
|
|
102
|
+
const at = enabled.indexOf(from);
|
|
103
|
+
const next = enabled[(at + step + enabled.length) % enabled.length];
|
|
104
|
+
refs.current[next]?.focus();
|
|
105
|
+
select(options[next].value);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div
|
|
110
|
+
ref={groupRef}
|
|
111
|
+
role="radiogroup"
|
|
112
|
+
aria-label={name}
|
|
113
|
+
className={["relative flex flex-col gap-1", className].join(" ")}
|
|
114
|
+
>
|
|
115
|
+
{options.map((opt, i) => {
|
|
116
|
+
const active = selected === opt.value;
|
|
117
|
+
return (
|
|
118
|
+
<div
|
|
119
|
+
key={opt.value}
|
|
120
|
+
onClick={() => select(opt.value, opt.disabled)}
|
|
121
|
+
className={[
|
|
122
|
+
"flex items-center justify-between gap-4 select-none py-1.5",
|
|
123
|
+
opt.disabled ? "opacity-40 pointer-events-none" : "cursor-pointer group/radio",
|
|
124
|
+
].join(" ")}
|
|
125
|
+
>
|
|
126
|
+
<div className="flex flex-col gap-0.5 min-w-0">
|
|
127
|
+
<span id={`${uid}-${i}-label`} className="text-[13px] font-medium text-ink leading-snug">
|
|
128
|
+
{opt.label}
|
|
129
|
+
</span>
|
|
130
|
+
{opt.description && (
|
|
131
|
+
<span id={`${uid}-${i}-desc`} className="text-[12px] text-ink-muted leading-relaxed">
|
|
132
|
+
{opt.description}
|
|
133
|
+
</span>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{/* Clicks bubble to the row, so the control itself has no onClick */}
|
|
138
|
+
<button
|
|
139
|
+
ref={(el) => {
|
|
140
|
+
refs.current[i] = el;
|
|
141
|
+
}}
|
|
142
|
+
type="button"
|
|
143
|
+
role="radio"
|
|
144
|
+
aria-checked={active}
|
|
145
|
+
aria-labelledby={`${uid}-${i}-label`}
|
|
146
|
+
aria-describedby={opt.description ? `${uid}-${i}-desc` : undefined}
|
|
147
|
+
disabled={opt.disabled}
|
|
148
|
+
tabIndex={i === tabStop ? 0 : -1}
|
|
149
|
+
onKeyDown={(e) => {
|
|
150
|
+
if (e.key === "ArrowDown" || e.key === "ArrowRight") {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
move(i, 1);
|
|
153
|
+
} else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
move(i, -1);
|
|
156
|
+
}
|
|
157
|
+
}}
|
|
158
|
+
className={[
|
|
159
|
+
"shrink-0 w-[22px] h-[22px] rounded-full cursor-pointer",
|
|
160
|
+
"transition-[box-shadow] duration-200 ease-out",
|
|
161
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink",
|
|
162
|
+
active ? "" : "group-hover/radio:[box-shadow:var(--field-shadow-hover)]",
|
|
163
|
+
].join(" ")}
|
|
164
|
+
style={{ background: "var(--field)", boxShadow: "var(--field-shadow)" }}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
})}
|
|
169
|
+
|
|
170
|
+
{blob && (
|
|
171
|
+
<>
|
|
172
|
+
<span
|
|
173
|
+
aria-hidden
|
|
174
|
+
className="pointer-events-none absolute rounded-full"
|
|
175
|
+
style={{
|
|
176
|
+
top: blob.top,
|
|
177
|
+
bottom: blob.bottom,
|
|
178
|
+
left: blob.left,
|
|
179
|
+
width: blob.size,
|
|
180
|
+
background: "var(--face-inverse)",
|
|
181
|
+
boxShadow: "var(--face-inverse-shadow)",
|
|
182
|
+
transition:
|
|
183
|
+
blob.dir === 0
|
|
184
|
+
? "none"
|
|
185
|
+
: blob.dir > 0
|
|
186
|
+
? `top ${TRAIL}, bottom ${LEAD}`
|
|
187
|
+
: `top ${LEAD}, bottom ${TRAIL}`,
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
{/* The dot rides the trailing edge's spring, arriving as it settles */}
|
|
191
|
+
<span
|
|
192
|
+
aria-hidden
|
|
193
|
+
className="pointer-events-none absolute w-2 h-2 rounded-full bg-ink-inverse"
|
|
194
|
+
style={{
|
|
195
|
+
top: blob.top + (blob.size - 8) / 2,
|
|
196
|
+
left: blob.left + (blob.size - 8) / 2,
|
|
197
|
+
boxShadow: "var(--dot-shadow)",
|
|
198
|
+
transition: blob.dir === 0 ? "none" : `top ${TRAIL}`,
|
|
199
|
+
}}
|
|
200
|
+
/>
|
|
201
|
+
</>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|