@blurrow/ui 0.1.0 → 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/README.md +28 -0
- package/dist/cli.js +1477 -0
- package/dist/registry/Accordion.tsx +140 -0
- package/dist/registry/Alert.tsx +92 -0
- package/dist/registry/AuthorCard.tsx +67 -0
- package/dist/registry/Avatar.tsx +116 -0
- package/dist/registry/Badge.tsx +58 -0
- package/dist/registry/Banner.tsx +89 -0
- package/dist/registry/Breadcrumb.tsx +60 -0
- package/dist/registry/Button.tsx +372 -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 +292 -0
- package/dist/registry/Checkbox.tsx +99 -0
- package/dist/registry/CloseButton.tsx +68 -0
- package/dist/registry/CodeBlock.tsx +202 -0
- package/dist/registry/CommandPalette.tsx +349 -0
- package/dist/registry/DataList.tsx +49 -0
- package/dist/registry/Drawer.tsx +164 -0
- package/dist/registry/DropdownMenu.tsx +257 -0
- package/dist/registry/EmptyState.tsx +55 -0
- package/dist/registry/Input.tsx +97 -0
- package/dist/registry/Kbd.tsx +24 -0
- package/dist/registry/Modal.tsx +128 -0
- package/dist/registry/Pagination.tsx +163 -0
- package/dist/registry/Popover.tsx +121 -0
- package/dist/registry/Progress.tsx +66 -0
- package/dist/registry/QuoteCard.tsx +93 -0
- package/dist/registry/Radio.tsx +202 -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 +290 -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/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 +121 -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/Timeline.tsx +102 -0
- package/dist/registry/Toast.tsx +385 -0
- package/dist/registry/Toggle.tsx +99 -0
- package/dist/registry/Tooltip.tsx +212 -0
- package/dist/registry/styles.css +401 -0
- package/dist/registry/useModalBehavior.ts +77 -0
- package/package.json +23 -6
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { CloseButton } from "./CloseButton";
|
|
5
|
+
import { useModalBehavior } from "./useModalBehavior";
|
|
6
|
+
|
|
7
|
+
interface DrawerProps {
|
|
8
|
+
open: boolean;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
title?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
children?: React.ReactNode;
|
|
13
|
+
actions?: React.ReactNode;
|
|
14
|
+
side?: "left" | "right";
|
|
15
|
+
size?: "sm" | "md" | "lg";
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ENTER_MS = 380;
|
|
20
|
+
const EXIT_MS = 240;
|
|
21
|
+
|
|
22
|
+
const sizeMap = {
|
|
23
|
+
sm: "max-w-xs",
|
|
24
|
+
md: "max-w-md",
|
|
25
|
+
lg: "max-w-lg",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
export function Drawer({
|
|
29
|
+
open,
|
|
30
|
+
onClose,
|
|
31
|
+
title,
|
|
32
|
+
description,
|
|
33
|
+
children,
|
|
34
|
+
actions,
|
|
35
|
+
side = "right",
|
|
36
|
+
size = "md",
|
|
37
|
+
className = "",
|
|
38
|
+
}: DrawerProps) {
|
|
39
|
+
const [mounted, setMounted] = useState(false);
|
|
40
|
+
const [visible, setVisible] = useState(false);
|
|
41
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
const uid = useId();
|
|
43
|
+
|
|
44
|
+
/* ── mount / unmount ────────────────────────────────── */
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (open) {
|
|
47
|
+
setMounted(true);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (mounted) {
|
|
51
|
+
setVisible(false);
|
|
52
|
+
const t = setTimeout(() => setMounted(false), EXIT_MS);
|
|
53
|
+
return () => clearTimeout(t);
|
|
54
|
+
}
|
|
55
|
+
}, [open, mounted]);
|
|
56
|
+
|
|
57
|
+
/* ── trigger enter animation after mount ────────────── */
|
|
58
|
+
useLayoutEffect(() => {
|
|
59
|
+
if (!mounted || !open || visible) return;
|
|
60
|
+
void panelRef.current?.getBoundingClientRect();
|
|
61
|
+
const id = requestAnimationFrame(() => setVisible(true));
|
|
62
|
+
return () => cancelAnimationFrame(id);
|
|
63
|
+
}, [mounted, open, visible]);
|
|
64
|
+
|
|
65
|
+
/* ── Escape, focus trap + restore, scroll lock ─────── */
|
|
66
|
+
useModalBehavior({ open, ready: visible, ref: panelRef, onClose });
|
|
67
|
+
|
|
68
|
+
if (!mounted) return null;
|
|
69
|
+
|
|
70
|
+
const isRight = side === "right";
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="fixed inset-0 z-90">
|
|
74
|
+
<div
|
|
75
|
+
aria-hidden
|
|
76
|
+
className="absolute inset-0 backdrop-blur-[6px] transition-opacity ease-out"
|
|
77
|
+
style={{
|
|
78
|
+
background: "var(--scrim)",
|
|
79
|
+
opacity: visible ? 1 : 0,
|
|
80
|
+
transitionDuration: `${visible ? ENTER_MS : EXIT_MS}ms`,
|
|
81
|
+
}}
|
|
82
|
+
onClick={onClose}
|
|
83
|
+
/>
|
|
84
|
+
|
|
85
|
+
{/* Floats a few px off the viewport edge so it reads as a panel, not a wall */}
|
|
86
|
+
<div
|
|
87
|
+
className={[
|
|
88
|
+
"absolute inset-y-0 w-full p-2 flex",
|
|
89
|
+
isRight ? "right-0 justify-end" : "left-0 justify-start",
|
|
90
|
+
].join(" ")}
|
|
91
|
+
style={{ pointerEvents: "none" }}
|
|
92
|
+
>
|
|
93
|
+
<div
|
|
94
|
+
ref={panelRef}
|
|
95
|
+
role="dialog"
|
|
96
|
+
aria-modal="true"
|
|
97
|
+
aria-labelledby={title ? `${uid}-title` : undefined}
|
|
98
|
+
aria-describedby={description ? `${uid}-desc` : undefined}
|
|
99
|
+
className={[
|
|
100
|
+
"relative h-full w-full rounded-[22px] p-1.5 flex flex-col pointer-events-auto",
|
|
101
|
+
sizeMap[size],
|
|
102
|
+
className,
|
|
103
|
+
]
|
|
104
|
+
.filter(Boolean)
|
|
105
|
+
.join(" ")}
|
|
106
|
+
style={{
|
|
107
|
+
background: "var(--panel-bezel)",
|
|
108
|
+
boxShadow: "var(--modal-shadow)",
|
|
109
|
+
transform: visible
|
|
110
|
+
? "translateX(0)"
|
|
111
|
+
: isRight
|
|
112
|
+
? "translateX(calc(100% + 16px))"
|
|
113
|
+
: "translateX(calc(-100% - 16px))",
|
|
114
|
+
transition: `transform ${visible ? ENTER_MS : EXIT_MS}ms ${
|
|
115
|
+
visible ? "var(--ease-out-soft)" : "cubic-bezier(0.4,0,1,1)"
|
|
116
|
+
}`,
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
<div
|
|
120
|
+
className="flex flex-col h-full bg-white rounded-[16px] overflow-hidden"
|
|
121
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
122
|
+
>
|
|
123
|
+
<div className="pl-6 pr-4 pt-5 pb-4 flex items-start gap-4 shrink-0">
|
|
124
|
+
<div className="flex-1 min-w-0 flex flex-col gap-1.5 pt-1">
|
|
125
|
+
{title && (
|
|
126
|
+
<h2
|
|
127
|
+
id={`${uid}-title`}
|
|
128
|
+
className="text-[16px] font-semibold text-neutral-900 tracking-[-0.015em] leading-snug"
|
|
129
|
+
>
|
|
130
|
+
{title}
|
|
131
|
+
</h2>
|
|
132
|
+
)}
|
|
133
|
+
{description && (
|
|
134
|
+
<p id={`${uid}-desc`} className="text-[13px] text-neutral-500 leading-relaxed">
|
|
135
|
+
{description}
|
|
136
|
+
</p>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
<CloseButton label="Close drawer" onClick={onClose} />
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
{children && (
|
|
143
|
+
<div className="flex-1 overflow-y-auto scroll-thin px-6 pb-5 text-[13.5px] text-neutral-600 leading-relaxed">
|
|
144
|
+
{children}
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{actions && (
|
|
149
|
+
<div
|
|
150
|
+
className="mt-auto px-5 py-3.5 flex items-center justify-end gap-2 shrink-0"
|
|
151
|
+
style={{
|
|
152
|
+
background: "linear-gradient(180deg, #fafafa 0%, #f6f6f6 100%)",
|
|
153
|
+
boxShadow: "inset 0 1px 0 rgba(17,17,17,0.06)",
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
{actions}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect, useId, useCallback, ReactNode } from "react";
|
|
4
|
+
import { Kbd } from "./Kbd";
|
|
5
|
+
|
|
6
|
+
type DropdownMenuPosition =
|
|
7
|
+
| "bottom-start"
|
|
8
|
+
| "bottom-end"
|
|
9
|
+
| "top-start"
|
|
10
|
+
| "top-end";
|
|
11
|
+
|
|
12
|
+
export interface DropdownMenuItem {
|
|
13
|
+
key: string;
|
|
14
|
+
label: string;
|
|
15
|
+
icon?: ReactNode;
|
|
16
|
+
shortcut?: string;
|
|
17
|
+
danger?: boolean;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
divider?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface DropdownMenuProps {
|
|
23
|
+
trigger: ReactNode;
|
|
24
|
+
items: DropdownMenuItem[];
|
|
25
|
+
position?: DropdownMenuPosition;
|
|
26
|
+
onSelect?: (key: string) => void;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const positionStyles: Record<DropdownMenuPosition, string> = {
|
|
31
|
+
"bottom-start": "top-full left-0 mt-2 origin-top-left",
|
|
32
|
+
"bottom-end": "top-full right-0 mt-2 origin-top-right",
|
|
33
|
+
"top-start": "bottom-full left-0 mb-2 origin-bottom-left",
|
|
34
|
+
"top-end": "bottom-full right-0 mb-2 origin-bottom-right",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const enterFrom: Record<DropdownMenuPosition, string> = {
|
|
38
|
+
"bottom-start": "-translate-y-1.5",
|
|
39
|
+
"bottom-end": "-translate-y-1.5",
|
|
40
|
+
"top-start": "translate-y-1.5",
|
|
41
|
+
"top-end": "translate-y-1.5",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function DropdownMenu({
|
|
45
|
+
trigger,
|
|
46
|
+
items,
|
|
47
|
+
position = "bottom-end",
|
|
48
|
+
onSelect,
|
|
49
|
+
className = "",
|
|
50
|
+
}: DropdownMenuProps) {
|
|
51
|
+
const [open, setOpen] = useState(false);
|
|
52
|
+
const [visible, setVisible] = useState(false);
|
|
53
|
+
const [mounted, setMounted] = useState(false);
|
|
54
|
+
// Where the menu actually opens: the requested side unless that runs off-screen
|
|
55
|
+
const [placement, setPlacement] = useState(position);
|
|
56
|
+
const [shift, setShift] = useState(0);
|
|
57
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
58
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
59
|
+
const uid = useId();
|
|
60
|
+
|
|
61
|
+
const enabledItems = () =>
|
|
62
|
+
Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])') ?? []);
|
|
63
|
+
|
|
64
|
+
const focusItem = (index: number) => {
|
|
65
|
+
const list = enabledItems();
|
|
66
|
+
if (list.length === 0) return;
|
|
67
|
+
list[(index + list.length) % list.length].focus();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/* Measured as soon as the menu is attached, before it fades in.
|
|
71
|
+
offsetWidth/Height ignore the enter transform, so the numbers are the
|
|
72
|
+
menu's settled size. */
|
|
73
|
+
const attachMenu = useCallback((menu: HTMLDivElement | null) => {
|
|
74
|
+
menuRef.current = menu;
|
|
75
|
+
const anchor = ref.current;
|
|
76
|
+
if (!menu || !anchor) return;
|
|
77
|
+
const gap = 8;
|
|
78
|
+
const a = anchor.getBoundingClientRect();
|
|
79
|
+
const w = menu.offsetWidth;
|
|
80
|
+
const h = menu.offsetHeight;
|
|
81
|
+
const vw = document.documentElement.clientWidth;
|
|
82
|
+
const vh = window.innerHeight;
|
|
83
|
+
|
|
84
|
+
let [v, side] = position.split("-") as ["top" | "bottom", "start" | "end"];
|
|
85
|
+
const fitsStart = a.left + w <= vw - gap;
|
|
86
|
+
const fitsEnd = a.right - w >= gap;
|
|
87
|
+
if (side === "end" && !fitsEnd && fitsStart) side = "start";
|
|
88
|
+
else if (side === "start" && !fitsStart && fitsEnd) side = "end";
|
|
89
|
+
|
|
90
|
+
const fitsBelow = a.bottom + gap + h <= vh - gap;
|
|
91
|
+
const fitsAbove = a.top - gap - h >= gap;
|
|
92
|
+
if (v === "bottom" && !fitsBelow && fitsAbove) v = "top";
|
|
93
|
+
else if (v === "top" && !fitsAbove && fitsBelow) v = "bottom";
|
|
94
|
+
|
|
95
|
+
// Neither side fits (narrow screens): slide it back inside the viewport
|
|
96
|
+
const left = side === "start" ? a.left : a.right - w;
|
|
97
|
+
const nudge = Math.max(gap - left, 0) - Math.max(left + w - (vw - gap), 0);
|
|
98
|
+
|
|
99
|
+
setPlacement(`${v}-${side}` as DropdownMenuPosition);
|
|
100
|
+
setShift(nudge);
|
|
101
|
+
}, [position]);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (open) {
|
|
105
|
+
setMounted(true);
|
|
106
|
+
requestAnimationFrame(() =>
|
|
107
|
+
requestAnimationFrame(() => {
|
|
108
|
+
setVisible(true);
|
|
109
|
+
// Land on the first item so arrow keys work straight away
|
|
110
|
+
focusItem(0);
|
|
111
|
+
})
|
|
112
|
+
);
|
|
113
|
+
} else {
|
|
114
|
+
setVisible(false);
|
|
115
|
+
const t = setTimeout(() => setMounted(false), 180);
|
|
116
|
+
return () => clearTimeout(t);
|
|
117
|
+
}
|
|
118
|
+
}, [open]);
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
function onOutside(e: MouseEvent) {
|
|
122
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
123
|
+
setOpen(false);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
document.addEventListener("mousedown", onOutside);
|
|
127
|
+
return () => document.removeEventListener("mousedown", onOutside);
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
130
|
+
const returnFocus = () => {
|
|
131
|
+
ref.current?.querySelector<HTMLElement>("[data-menu-trigger] button, [data-menu-trigger] a")?.focus();
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const onMenuKeyDown = (e: React.KeyboardEvent) => {
|
|
135
|
+
const list = enabledItems();
|
|
136
|
+
const at = list.indexOf(document.activeElement as HTMLElement);
|
|
137
|
+
switch (e.key) {
|
|
138
|
+
case "ArrowDown":
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
focusItem(at + 1);
|
|
141
|
+
break;
|
|
142
|
+
case "ArrowUp":
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
focusItem(at - 1);
|
|
145
|
+
break;
|
|
146
|
+
case "Home":
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
focusItem(0);
|
|
149
|
+
break;
|
|
150
|
+
case "End":
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
focusItem(list.length - 1);
|
|
153
|
+
break;
|
|
154
|
+
case "Escape":
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
setOpen(false);
|
|
157
|
+
returnFocus();
|
|
158
|
+
break;
|
|
159
|
+
case "Tab":
|
|
160
|
+
setOpen(false);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
function handleSelect(item: DropdownMenuItem) {
|
|
166
|
+
if (item.disabled) return;
|
|
167
|
+
onSelect?.(item.key);
|
|
168
|
+
setOpen(false);
|
|
169
|
+
returnFocus();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div ref={ref} className={["relative inline-flex", className].join(" ")}>
|
|
174
|
+
<div
|
|
175
|
+
data-menu-trigger
|
|
176
|
+
onClick={() => {
|
|
177
|
+
if (!open) {
|
|
178
|
+
// Start each opening from the requested side; attachMenu flips if needed
|
|
179
|
+
setPlacement(position);
|
|
180
|
+
setShift(0);
|
|
181
|
+
}
|
|
182
|
+
setOpen(!open);
|
|
183
|
+
}}
|
|
184
|
+
className="inline-flex cursor-pointer"
|
|
185
|
+
>
|
|
186
|
+
{React.isValidElement(trigger)
|
|
187
|
+
? React.cloneElement(trigger as React.ReactElement<Record<string, unknown>>, {
|
|
188
|
+
"aria-haspopup": "menu",
|
|
189
|
+
"aria-expanded": open,
|
|
190
|
+
"aria-controls": open ? `${uid}-menu` : undefined,
|
|
191
|
+
})
|
|
192
|
+
: trigger}
|
|
193
|
+
</div>
|
|
194
|
+
{mounted && (
|
|
195
|
+
<div
|
|
196
|
+
ref={attachMenu}
|
|
197
|
+
id={`${uid}-menu`}
|
|
198
|
+
role="menu"
|
|
199
|
+
onKeyDown={onMenuKeyDown}
|
|
200
|
+
className={[
|
|
201
|
+
"absolute z-50 min-w-[200px] rounded-[16px] p-1.5",
|
|
202
|
+
"transition-[opacity,transform,scale,translate] duration-200 ease-[var(--ease-spring)]",
|
|
203
|
+
visible
|
|
204
|
+
? "opacity-100 scale-100 translate-y-0"
|
|
205
|
+
: ["opacity-0 scale-[0.96]", enterFrom[placement]].join(" "),
|
|
206
|
+
positionStyles[placement],
|
|
207
|
+
].join(" ")}
|
|
208
|
+
style={{ background: "var(--panel-bezel)", boxShadow: "var(--overlay-shadow)", marginLeft: shift || undefined }}
|
|
209
|
+
>
|
|
210
|
+
<div
|
|
211
|
+
className="bg-white rounded-[11px] p-1 flex flex-col"
|
|
212
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
213
|
+
>
|
|
214
|
+
{items.map((item) => {
|
|
215
|
+
if (item.divider) {
|
|
216
|
+
return <div key={item.key} role="separator" className="h-px bg-neutral-100 my-1 mx-2" />;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<button
|
|
221
|
+
key={item.key}
|
|
222
|
+
type="button"
|
|
223
|
+
role="menuitem"
|
|
224
|
+
tabIndex={-1}
|
|
225
|
+
onClick={() => handleSelect(item)}
|
|
226
|
+
onMouseEnter={(e) => e.currentTarget.focus()}
|
|
227
|
+
disabled={item.disabled}
|
|
228
|
+
className={[
|
|
229
|
+
"group/item w-full flex items-center gap-2.5 px-2.5 py-2 rounded-[8px] text-[13px] font-medium cursor-pointer outline-none",
|
|
230
|
+
"transition-[background,color] duration-100",
|
|
231
|
+
"disabled:opacity-35 disabled:pointer-events-none",
|
|
232
|
+
item.danger
|
|
233
|
+
? "text-red-600 focus:bg-red-50"
|
|
234
|
+
: "text-neutral-700 focus:text-neutral-900 focus:bg-neutral-100/80",
|
|
235
|
+
].join(" ")}
|
|
236
|
+
>
|
|
237
|
+
{item.icon && (
|
|
238
|
+
<span
|
|
239
|
+
className={[
|
|
240
|
+
"shrink-0 w-4 h-4 flex items-center justify-center transition-colors duration-100",
|
|
241
|
+
item.danger ? "text-red-500" : "text-neutral-400 group-focus/item:text-neutral-700",
|
|
242
|
+
].join(" ")}
|
|
243
|
+
>
|
|
244
|
+
{item.icon}
|
|
245
|
+
</span>
|
|
246
|
+
)}
|
|
247
|
+
<span className="flex-1 text-left">{item.label}</span>
|
|
248
|
+
{item.shortcut && <Kbd className="shrink-0">{item.shortcut}</Kbd>}
|
|
249
|
+
</button>
|
|
250
|
+
);
|
|
251
|
+
})}
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
interface EmptyStateProps {
|
|
4
|
+
icon?: React.ReactNode;
|
|
5
|
+
title: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
action?: React.ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function EmptyState({
|
|
12
|
+
icon,
|
|
13
|
+
title,
|
|
14
|
+
description,
|
|
15
|
+
action,
|
|
16
|
+
className = "",
|
|
17
|
+
}: EmptyStateProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
className={["rounded-[20px] p-1.5", className].filter(Boolean).join(" ")}
|
|
21
|
+
style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
|
|
22
|
+
>
|
|
23
|
+
<div
|
|
24
|
+
className="relative bg-white rounded-[15px] overflow-hidden flex flex-col items-center justify-center text-center py-14 px-6"
|
|
25
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
26
|
+
>
|
|
27
|
+
{/* A faint dotted floor: the space is empty, not broken */}
|
|
28
|
+
<div
|
|
29
|
+
aria-hidden
|
|
30
|
+
className="pointer-events-none absolute inset-0"
|
|
31
|
+
style={{
|
|
32
|
+
backgroundImage: "radial-gradient(rgba(17,17,17,0.07) 1px, transparent 1px)",
|
|
33
|
+
backgroundSize: "14px 14px",
|
|
34
|
+
maskImage: "radial-gradient(60% 70% at 50% 40%, #000 0%, transparent 100%)",
|
|
35
|
+
WebkitMaskImage: "radial-gradient(60% 70% at 50% 40%, #000 0%, transparent 100%)",
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
{icon && (
|
|
39
|
+
<div
|
|
40
|
+
aria-hidden
|
|
41
|
+
className="relative w-12 h-12 rounded-[14px] flex items-center justify-center text-neutral-500 mb-5"
|
|
42
|
+
style={{ background: "var(--face-light)", boxShadow: "var(--face-light-shadow), 0 6px 16px -6px rgba(17,17,17,0.15)" }}
|
|
43
|
+
>
|
|
44
|
+
{icon}
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
<p className="relative text-[15px] font-semibold text-neutral-900 tracking-[-0.015em]">{title}</p>
|
|
48
|
+
{description && (
|
|
49
|
+
<p className="relative text-[13px] text-neutral-500 mt-1.5 max-w-xs leading-relaxed">{description}</p>
|
|
50
|
+
)}
|
|
51
|
+
{action && <div className="relative mt-6">{action}</div>}
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
4
|
+
label?: string;
|
|
5
|
+
hint?: string;
|
|
6
|
+
error?: string;
|
|
7
|
+
fullWidth?: boolean;
|
|
8
|
+
iconLeft?: React.ReactNode;
|
|
9
|
+
iconRight?: React.ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/* Fields are sunken wells (see --field tokens in globals.css). Raised
|
|
13
|
+
means "press me", sunken means "fill me" — that is the whole system. */
|
|
14
|
+
|
|
15
|
+
export function Input({
|
|
16
|
+
label,
|
|
17
|
+
hint,
|
|
18
|
+
error,
|
|
19
|
+
fullWidth = false,
|
|
20
|
+
iconLeft,
|
|
21
|
+
iconRight,
|
|
22
|
+
className = "",
|
|
23
|
+
id,
|
|
24
|
+
...rest
|
|
25
|
+
}: InputProps) {
|
|
26
|
+
const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
|
|
27
|
+
const messageId = inputId && (error || hint) ? `${inputId}-message` : undefined;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
|
|
31
|
+
{label && (
|
|
32
|
+
<label
|
|
33
|
+
htmlFor={inputId}
|
|
34
|
+
className="text-[12.5px] font-medium text-neutral-700 tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-neutral-950"
|
|
35
|
+
>
|
|
36
|
+
{label}
|
|
37
|
+
</label>
|
|
38
|
+
)}
|
|
39
|
+
<div className="relative group/input isolate">
|
|
40
|
+
{iconLeft && (
|
|
41
|
+
<span
|
|
42
|
+
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-neutral-400 transition-all duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] group-focus-within/input:text-neutral-800 group-focus-within/input:scale-110 pointer-events-none z-20 flex items-center justify-center"
|
|
43
|
+
>
|
|
44
|
+
{iconLeft}
|
|
45
|
+
</span>
|
|
46
|
+
)}
|
|
47
|
+
<input
|
|
48
|
+
id={inputId}
|
|
49
|
+
className={[
|
|
50
|
+
"field relative z-10 w-full rounded-xl py-3 text-[13.5px] text-neutral-900 caret-neutral-900",
|
|
51
|
+
"placeholder:text-neutral-400 placeholder:transition-colors placeholder:duration-200 focus:placeholder:text-neutral-300",
|
|
52
|
+
"disabled:opacity-40 disabled:cursor-not-allowed",
|
|
53
|
+
iconLeft ? "pl-10" : "pl-4",
|
|
54
|
+
iconRight ? "pr-10" : "pr-4",
|
|
55
|
+
className,
|
|
56
|
+
]
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join(" ")}
|
|
59
|
+
aria-invalid={error ? true : undefined}
|
|
60
|
+
aria-describedby={messageId}
|
|
61
|
+
{...rest}
|
|
62
|
+
/>
|
|
63
|
+
<span
|
|
64
|
+
aria-hidden
|
|
65
|
+
className="pointer-events-none absolute inset-0 z-15 rounded-xl overflow-hidden"
|
|
66
|
+
>
|
|
67
|
+
<span
|
|
68
|
+
className="block absolute inset-y-0 w-[30%] -left-[30%] opacity-0 group-focus-within/input:animate-input-shimmer"
|
|
69
|
+
style={{
|
|
70
|
+
background:
|
|
71
|
+
"linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.55) 50%, transparent 100%)",
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
</span>
|
|
75
|
+
{iconRight && (
|
|
76
|
+
<span
|
|
77
|
+
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-neutral-400 transition-all duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] group-focus-within/input:text-neutral-800 group-focus-within/input:scale-110 pointer-events-none z-20 flex items-center justify-center"
|
|
78
|
+
>
|
|
79
|
+
{iconRight}
|
|
80
|
+
</span>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
{error && (
|
|
84
|
+
<p id={messageId} role="alert" className="text-[11.5px] text-red-600 font-medium flex items-center gap-1 mt-0.5">
|
|
85
|
+
<svg width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
86
|
+
<circle cx="12" cy="12" r="10" />
|
|
87
|
+
<path strokeLinecap="round" d="M12 8v4m0 4h.01" />
|
|
88
|
+
</svg>
|
|
89
|
+
{error}
|
|
90
|
+
</p>
|
|
91
|
+
)}
|
|
92
|
+
{hint && !error && (
|
|
93
|
+
<p id={messageId} className="text-[11.5px] text-neutral-500 mt-0.5">{hint}</p>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export function Kbd({
|
|
4
|
+
children,
|
|
5
|
+
className = "",
|
|
6
|
+
}: {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
}) {
|
|
10
|
+
return (
|
|
11
|
+
<kbd
|
|
12
|
+
className={`inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-md text-[11px] font-semibold text-neutral-500 ${className}`}
|
|
13
|
+
style={{
|
|
14
|
+
/* A keycap: lit top, solid 1.5px skirt below so it reads as pressable */
|
|
15
|
+
background: "var(--face-light)",
|
|
16
|
+
boxShadow:
|
|
17
|
+
"0 0 3px rgba(17,17,17,0.136), 0 1.5px 1px rgba(17,17,17,0.12), 0 2px 3px -1px rgba(17,17,17,0.1), inset 0 2px 3px -2px rgba(255,255,255,1)",
|
|
18
|
+
textShadow: "0 1px 0 rgba(255,255,255,0.9)",
|
|
19
|
+
}}
|
|
20
|
+
>
|
|
21
|
+
{children}
|
|
22
|
+
</kbd>
|
|
23
|
+
);
|
|
24
|
+
}
|