@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
|
+
/* The label above a field and the line under it, shared by every form
|
|
6
|
+
control so they read as one family. */
|
|
7
|
+
|
|
8
|
+
export function FieldLabel({
|
|
9
|
+
id,
|
|
10
|
+
htmlFor,
|
|
11
|
+
children,
|
|
12
|
+
}: {
|
|
13
|
+
id?: string;
|
|
14
|
+
htmlFor?: string;
|
|
15
|
+
children: React.ReactNode;
|
|
16
|
+
}) {
|
|
17
|
+
const className =
|
|
18
|
+
"text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong";
|
|
19
|
+
return htmlFor ? (
|
|
20
|
+
<label id={id} htmlFor={htmlFor} className={className}>
|
|
21
|
+
{children}
|
|
22
|
+
</label>
|
|
23
|
+
) : (
|
|
24
|
+
<span id={id} className={className}>
|
|
25
|
+
{children}
|
|
26
|
+
</span>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* An error wins over a hint; either is announced through `id` */
|
|
31
|
+
export function FieldMessage({ id, error, hint }: { id?: string; error?: string; hint?: string }) {
|
|
32
|
+
if (error) {
|
|
33
|
+
return (
|
|
34
|
+
<p id={id} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">
|
|
35
|
+
<svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
36
|
+
<circle cx="12" cy="12" r="10" />
|
|
37
|
+
<path strokeLinecap="round" d="M12 8v4m0 4h.01" />
|
|
38
|
+
</svg>
|
|
39
|
+
{error}
|
|
40
|
+
</p>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (hint) {
|
|
44
|
+
return (
|
|
45
|
+
<p id={id} className="text-[11.5px] text-ink-muted mt-0.5">
|
|
46
|
+
{hint}
|
|
47
|
+
</p>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* A small raised key that sits inside a field or a panel: steppers,
|
|
54
|
+
month arrows, clear buttons. It sinks flush while held. */
|
|
55
|
+
export function FieldKey({
|
|
56
|
+
label,
|
|
57
|
+
disabled,
|
|
58
|
+
onClick,
|
|
59
|
+
onPress,
|
|
60
|
+
onRelease,
|
|
61
|
+
focusable = true,
|
|
62
|
+
size = 28,
|
|
63
|
+
children,
|
|
64
|
+
}: {
|
|
65
|
+
label: string;
|
|
66
|
+
disabled?: boolean;
|
|
67
|
+
onClick?: () => void;
|
|
68
|
+
/** Pointer went down (for hold-to-repeat) */
|
|
69
|
+
onPress?: () => void;
|
|
70
|
+
onRelease?: () => void;
|
|
71
|
+
focusable?: boolean;
|
|
72
|
+
size?: number;
|
|
73
|
+
children: React.ReactNode;
|
|
74
|
+
}) {
|
|
75
|
+
const [pressed, setPressed] = React.useState(false);
|
|
76
|
+
const release = () => {
|
|
77
|
+
setPressed(false);
|
|
78
|
+
onRelease?.();
|
|
79
|
+
};
|
|
80
|
+
return (
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
aria-label={label}
|
|
84
|
+
tabIndex={focusable ? undefined : -1}
|
|
85
|
+
disabled={disabled}
|
|
86
|
+
onClick={onClick}
|
|
87
|
+
onPointerDown={(e) => {
|
|
88
|
+
if (e.button !== 0) return;
|
|
89
|
+
if (!focusable) e.preventDefault(); // keep focus in the field
|
|
90
|
+
setPressed(true);
|
|
91
|
+
onPress?.();
|
|
92
|
+
}}
|
|
93
|
+
onPointerUp={release}
|
|
94
|
+
onPointerLeave={() => pressed && release()}
|
|
95
|
+
onPointerCancel={release}
|
|
96
|
+
className="grid shrink-0 cursor-pointer place-items-center rounded-[9px] text-ink-muted transition-[box-shadow,color,opacity] duration-150 hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink disabled:opacity-35 disabled:pointer-events-none"
|
|
97
|
+
style={{
|
|
98
|
+
width: size,
|
|
99
|
+
height: size,
|
|
100
|
+
background: "var(--face)",
|
|
101
|
+
boxShadow: pressed ? "var(--face-pressed)" : "var(--key-shadow)",
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
<svg
|
|
105
|
+
aria-hidden
|
|
106
|
+
width="12"
|
|
107
|
+
height="12"
|
|
108
|
+
fill="none"
|
|
109
|
+
stroke="currentColor"
|
|
110
|
+
strokeWidth="2.25"
|
|
111
|
+
strokeLinecap="round"
|
|
112
|
+
strokeLinejoin="round"
|
|
113
|
+
viewBox="0 0 24 24"
|
|
114
|
+
style={{
|
|
115
|
+
transform: pressed ? "translateY(0.5px) scale(0.92)" : "none",
|
|
116
|
+
transition: "transform 150ms var(--ease-spring)",
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
{children}
|
|
120
|
+
</svg>
|
|
121
|
+
</button>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import { Button } from "./Button";
|
|
5
|
+
import { CloseButton } from "./CloseButton";
|
|
6
|
+
import { FieldLabel, FieldMessage } from "./FieldMessage";
|
|
7
|
+
|
|
8
|
+
export interface UploadItem {
|
|
9
|
+
id: string;
|
|
10
|
+
file: File;
|
|
11
|
+
/** 0–100 while uploading */
|
|
12
|
+
progress: number;
|
|
13
|
+
status: "ready" | "uploading" | "done" | "error";
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface FileUploadProps {
|
|
18
|
+
/** Same as the input's accept: "image/*", ".pdf,.docx" */
|
|
19
|
+
accept?: string;
|
|
20
|
+
multiple?: boolean;
|
|
21
|
+
/** In bytes; bigger files land with an error instead of uploading */
|
|
22
|
+
maxSize?: number;
|
|
23
|
+
maxFiles?: number;
|
|
24
|
+
/** Upload each file as it lands. Call onProgress with 0–100; throw to fail it */
|
|
25
|
+
upload?: (file: File, onProgress: (pct: number) => void) => Promise<void>;
|
|
26
|
+
/** Every change to the list: added, progressed, finished, removed */
|
|
27
|
+
onChange?: (items: UploadItem[]) => void;
|
|
28
|
+
label?: string;
|
|
29
|
+
hint?: string;
|
|
30
|
+
/** The words in the empty tray */
|
|
31
|
+
prompt?: string;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatBytes(n: number) {
|
|
37
|
+
if (n < 1024) return `${n} b`;
|
|
38
|
+
const units = ["kb", "mb", "gb"];
|
|
39
|
+
let i = -1;
|
|
40
|
+
do {
|
|
41
|
+
n /= 1024;
|
|
42
|
+
i++;
|
|
43
|
+
} while (n >= 1024 && i < units.length - 1);
|
|
44
|
+
return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Matches a file against an accept string the way the browser's picker does */
|
|
48
|
+
function accepts(file: File, accept?: string) {
|
|
49
|
+
if (!accept) return true;
|
|
50
|
+
return accept.split(",").some((rule) => {
|
|
51
|
+
const r = rule.trim().toLowerCase();
|
|
52
|
+
if (r.startsWith(".")) return file.name.toLowerCase().endsWith(r);
|
|
53
|
+
if (r.endsWith("/*")) return file.type.startsWith(r.slice(0, -1));
|
|
54
|
+
return file.type === r;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let seq = 0;
|
|
59
|
+
|
|
60
|
+
/* Something you fill, so it's built as a field: a wide sunken tray. Drag
|
|
61
|
+
files over it and it lifts flush and draws its border, the way an
|
|
62
|
+
Input does when it takes focus. What you drop lands inside it as
|
|
63
|
+
raised chips; each has a groove along its foot that fills while it
|
|
64
|
+
uploads, then fades once it's done. */
|
|
65
|
+
export function FileUpload({
|
|
66
|
+
accept,
|
|
67
|
+
multiple = true,
|
|
68
|
+
maxSize,
|
|
69
|
+
maxFiles,
|
|
70
|
+
upload,
|
|
71
|
+
onChange,
|
|
72
|
+
label,
|
|
73
|
+
hint,
|
|
74
|
+
prompt,
|
|
75
|
+
disabled = false,
|
|
76
|
+
className = "",
|
|
77
|
+
}: FileUploadProps) {
|
|
78
|
+
const uid = useId();
|
|
79
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
80
|
+
const [items, setItems] = useState<UploadItem[]>([]);
|
|
81
|
+
const [previews, setPreviews] = useState<Record<string, string>>({});
|
|
82
|
+
const [over, setOver] = useState(false);
|
|
83
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
84
|
+
const depth = useRef(0);
|
|
85
|
+
|
|
86
|
+
// Report every change to the list, after it renders
|
|
87
|
+
const report = useRef(onChange);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
report.current = onChange;
|
|
90
|
+
});
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
report.current?.(items);
|
|
93
|
+
}, [items]);
|
|
94
|
+
|
|
95
|
+
// Image previews are object URLs; give them back when they're gone
|
|
96
|
+
const urls = useRef(previews);
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
urls.current = previews;
|
|
99
|
+
});
|
|
100
|
+
useEffect(() => () => Object.values(urls.current).forEach(URL.revokeObjectURL), []);
|
|
101
|
+
|
|
102
|
+
const patch = (id: string, p: Partial<UploadItem>) =>
|
|
103
|
+
setItems((list) => list.map((it) => (it.id === id ? { ...it, ...p } : it)));
|
|
104
|
+
|
|
105
|
+
const start = (item: UploadItem) => {
|
|
106
|
+
if (!upload) return;
|
|
107
|
+
patch(item.id, { status: "uploading", progress: 0 });
|
|
108
|
+
upload(item.file, (pct) => patch(item.id, { progress: Math.max(0, Math.min(100, pct)) }))
|
|
109
|
+
.then(() => patch(item.id, { status: "done", progress: 100 }))
|
|
110
|
+
.catch((err: unknown) =>
|
|
111
|
+
patch(item.id, { status: "error", error: err instanceof Error ? err.message : "upload failed" }),
|
|
112
|
+
);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const add = (files: FileList | File[]) => {
|
|
116
|
+
if (disabled) return;
|
|
117
|
+
let list = Array.from(files);
|
|
118
|
+
if (!multiple) list = list.slice(0, 1);
|
|
119
|
+
const room = maxFiles ? Math.max(0, maxFiles - (multiple ? items.length : 0)) : Infinity;
|
|
120
|
+
setNotice(list.length > room ? `only ${maxFiles} file${maxFiles === 1 ? "" : "s"} can go in` : null);
|
|
121
|
+
const next: UploadItem[] = list.slice(0, room).map((file) => {
|
|
122
|
+
const id = `${uid}-${++seq}`;
|
|
123
|
+
if (!accepts(file, accept)) return { id, file, progress: 0, status: "error", error: "not a type this takes" };
|
|
124
|
+
if (maxSize && file.size > maxSize)
|
|
125
|
+
return { id, file, progress: 0, status: "error", error: `over ${formatBytes(maxSize)}` };
|
|
126
|
+
return { id, file, progress: 0, status: "ready" };
|
|
127
|
+
});
|
|
128
|
+
const shots: Record<string, string> = {};
|
|
129
|
+
next.forEach((it) => {
|
|
130
|
+
if (it.file.type.startsWith("image/") && it.status !== "error") shots[it.id] = URL.createObjectURL(it.file);
|
|
131
|
+
});
|
|
132
|
+
if (!multiple) Object.values(previews).forEach(URL.revokeObjectURL);
|
|
133
|
+
setPreviews((p) => (multiple ? { ...p, ...shots } : shots));
|
|
134
|
+
setItems((cur) => (multiple ? [...cur, ...next] : next));
|
|
135
|
+
next.filter((it) => it.status === "ready").forEach(start);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const remove = (id: string) => {
|
|
139
|
+
if (previews[id]) URL.revokeObjectURL(previews[id]);
|
|
140
|
+
setPreviews((p) => {
|
|
141
|
+
const rest = { ...p };
|
|
142
|
+
delete rest[id];
|
|
143
|
+
return rest;
|
|
144
|
+
});
|
|
145
|
+
setItems((list) => list.filter((it) => it.id !== id));
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const browse = () => !disabled && inputRef.current?.click();
|
|
149
|
+
const full = !!maxFiles && multiple && items.length >= maxFiles;
|
|
150
|
+
const empty = items.length === 0;
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div className={["group/field flex w-full flex-col gap-1.5", className].join(" ")}>
|
|
154
|
+
{label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>}
|
|
155
|
+
|
|
156
|
+
{/* The tray: a field, so data-open gives it Input's focused well */}
|
|
157
|
+
<div
|
|
158
|
+
role="group"
|
|
159
|
+
aria-labelledby={label ? `${uid}-label` : undefined}
|
|
160
|
+
aria-describedby={hint ? `${uid}-hint` : undefined}
|
|
161
|
+
data-open={over || undefined}
|
|
162
|
+
onDragEnter={(e) => {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
if (disabled) return;
|
|
165
|
+
depth.current++;
|
|
166
|
+
setOver(true);
|
|
167
|
+
}}
|
|
168
|
+
onDragOver={(e) => e.preventDefault()}
|
|
169
|
+
onDragLeave={() => {
|
|
170
|
+
// dragleave fires for every child crossed; only the last one counts
|
|
171
|
+
if (--depth.current <= 0) setOver(false);
|
|
172
|
+
}}
|
|
173
|
+
onDrop={(e) => {
|
|
174
|
+
e.preventDefault();
|
|
175
|
+
depth.current = 0;
|
|
176
|
+
setOver(false);
|
|
177
|
+
add(e.dataTransfer.files);
|
|
178
|
+
}}
|
|
179
|
+
className={[
|
|
180
|
+
"field relative flex flex-col rounded-[18px] p-2",
|
|
181
|
+
disabled ? "pointer-events-none opacity-40" : "",
|
|
182
|
+
].join(" ")}
|
|
183
|
+
>
|
|
184
|
+
{empty ? (
|
|
185
|
+
<div
|
|
186
|
+
className="flex cursor-pointer flex-col items-center gap-3.5 px-6 py-9 text-center"
|
|
187
|
+
onClick={(e) => e.target === e.currentTarget && browse()}
|
|
188
|
+
>
|
|
189
|
+
<span className="flex flex-col gap-1">
|
|
190
|
+
<span className="text-[14px] font-medium text-ink tracking-[-0.01em]">
|
|
191
|
+
{over ? "let go to drop them in" : prompt ?? (multiple ? "drop files here" : "drop a file here")}
|
|
192
|
+
</span>
|
|
193
|
+
{hint && (
|
|
194
|
+
<span id={`${uid}-hint`} className="text-[12px] text-ink-muted">
|
|
195
|
+
{hint}
|
|
196
|
+
</span>
|
|
197
|
+
)}
|
|
198
|
+
</span>
|
|
199
|
+
<Button variant="secondary" size="sm" onClick={browse} disabled={disabled}>
|
|
200
|
+
{multiple ? "choose files" : "choose a file"}
|
|
201
|
+
</Button>
|
|
202
|
+
</div>
|
|
203
|
+
) : (
|
|
204
|
+
<>
|
|
205
|
+
<ul aria-label="files" className="flex flex-col gap-1.5">
|
|
206
|
+
{items.map((it) => (
|
|
207
|
+
<FileChip key={it.id} item={it} preview={previews[it.id]} onRemove={() => remove(it.id)} />
|
|
208
|
+
))}
|
|
209
|
+
</ul>
|
|
210
|
+
<div className="flex items-center justify-between gap-3 pl-2.5 pr-0.5 pt-2.5 pb-0.5">
|
|
211
|
+
<span id={`${uid}-hint`} className="truncate text-[12px] text-ink-muted">
|
|
212
|
+
{over ? "let go to add them" : hint ?? `${items.length} file${items.length === 1 ? "" : "s"}`}
|
|
213
|
+
</span>
|
|
214
|
+
{!full && (
|
|
215
|
+
<Button variant="secondary" size="sm" onClick={browse} disabled={disabled}>
|
|
216
|
+
{multiple ? "add more" : "replace"}
|
|
217
|
+
</Button>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
</>
|
|
221
|
+
)}
|
|
222
|
+
|
|
223
|
+
<input
|
|
224
|
+
ref={inputRef}
|
|
225
|
+
type="file"
|
|
226
|
+
accept={accept}
|
|
227
|
+
multiple={multiple}
|
|
228
|
+
disabled={disabled}
|
|
229
|
+
tabIndex={-1}
|
|
230
|
+
aria-hidden
|
|
231
|
+
className="sr-only"
|
|
232
|
+
onChange={(e) => {
|
|
233
|
+
if (e.target.files) add(e.target.files);
|
|
234
|
+
e.target.value = ""; // so picking the same file again still fires
|
|
235
|
+
}}
|
|
236
|
+
/>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
{notice && <FieldMessage error={notice} />}
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* A raised chip sitting in the tray. The groove along its foot is a
|
|
245
|
+
Progress track in miniature; it fills, then fades once the file is up. */
|
|
246
|
+
function FileChip({ item, preview, onRemove }: { item: UploadItem; preview?: string; onRemove: () => void }) {
|
|
247
|
+
const failed = item.status === "error";
|
|
248
|
+
const meta = failed
|
|
249
|
+
? item.error
|
|
250
|
+
: item.status === "uploading"
|
|
251
|
+
? `${formatBytes(item.file.size)} · ${Math.round(item.progress)}%`
|
|
252
|
+
: item.status === "done"
|
|
253
|
+
? `${formatBytes(item.file.size)} · uploaded`
|
|
254
|
+
: formatBytes(item.file.size);
|
|
255
|
+
return (
|
|
256
|
+
<li
|
|
257
|
+
className="relative flex items-center gap-3 overflow-hidden rounded-[12px] pt-2.5 pb-3.5 pl-2.5 pr-2"
|
|
258
|
+
style={{
|
|
259
|
+
background: "var(--face)",
|
|
260
|
+
boxShadow: "var(--thumb-shadow)",
|
|
261
|
+
animation: "select-option-in 320ms var(--ease-out-soft) both",
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
{preview && (
|
|
265
|
+
// eslint-disable-next-line @next/next/no-img-element -- a local object URL, nothing to optimise
|
|
266
|
+
<img
|
|
267
|
+
src={preview}
|
|
268
|
+
alt=""
|
|
269
|
+
className="h-9 w-9 shrink-0 rounded-[8px] object-cover"
|
|
270
|
+
style={{ boxShadow: "inset 0 0 0 1px var(--hairline), var(--key-shadow)" }}
|
|
271
|
+
/>
|
|
272
|
+
)}
|
|
273
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
274
|
+
<span className="truncate text-[13px] font-medium text-ink">{item.file.name}</span>
|
|
275
|
+
<span
|
|
276
|
+
className={[
|
|
277
|
+
"flex items-center gap-1 truncate text-[11.5px] tabular-nums",
|
|
278
|
+
failed ? "text-danger" : "text-ink-muted",
|
|
279
|
+
].join(" ")}
|
|
280
|
+
>
|
|
281
|
+
{item.status === "done" && (
|
|
282
|
+
<svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.75" viewBox="0 0 24 24" className="text-success">
|
|
283
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
284
|
+
</svg>
|
|
285
|
+
)}
|
|
286
|
+
{meta}
|
|
287
|
+
</span>
|
|
288
|
+
</span>
|
|
289
|
+
<CloseButton size="sm" label={`remove ${item.file.name}`} onClick={onRemove} />
|
|
290
|
+
|
|
291
|
+
{/* The groove along the chip's foot */}
|
|
292
|
+
<span
|
|
293
|
+
aria-hidden={item.status !== "uploading"}
|
|
294
|
+
role={item.status === "uploading" ? "progressbar" : undefined}
|
|
295
|
+
aria-label={item.status === "uploading" ? `uploading ${item.file.name}` : undefined}
|
|
296
|
+
aria-valuenow={item.status === "uploading" ? Math.round(item.progress) : undefined}
|
|
297
|
+
aria-valuemin={0}
|
|
298
|
+
aria-valuemax={100}
|
|
299
|
+
className="absolute inset-x-2.5 bottom-[6px] h-[3px] rounded-full transition-opacity duration-500"
|
|
300
|
+
style={{
|
|
301
|
+
background: "var(--track)",
|
|
302
|
+
boxShadow: "var(--track-shadow)",
|
|
303
|
+
opacity: item.status === "uploading" ? 1 : 0,
|
|
304
|
+
transitionDelay: item.status === "done" ? "400ms" : "0ms",
|
|
305
|
+
}}
|
|
306
|
+
>
|
|
307
|
+
<span
|
|
308
|
+
className="block h-full rounded-full"
|
|
309
|
+
style={{
|
|
310
|
+
width: `${item.progress}%`,
|
|
311
|
+
background: "var(--face-inverse)",
|
|
312
|
+
transition: "width 300ms var(--ease-out-soft)",
|
|
313
|
+
}}
|
|
314
|
+
/>
|
|
315
|
+
</span>
|
|
316
|
+
</li>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { RefObject, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
|
|
6
|
+
/* ── Portal ───────────────────────────────────────────────────────── */
|
|
7
|
+
/* Anything that floats over the page is rendered at the end of <body>.
|
|
8
|
+
Left where it was declared, a panel lives inside whatever stacking
|
|
9
|
+
context its parents made (a transform, a filter, an isolate), and a
|
|
10
|
+
later sibling on the page can paint straight over it, whatever its
|
|
11
|
+
z-index says. */
|
|
12
|
+
const noop = () => () => {};
|
|
13
|
+
|
|
14
|
+
export function Portal({ children }: { children: React.ReactNode }) {
|
|
15
|
+
const client = useSyncExternalStore(noop, () => true, () => false);
|
|
16
|
+
return client ? createPortal(children, document.body) : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Layers, lowest first: dialogs, then toasts, then anything anchored to
|
|
20
|
+
a control (so a select inside a modal opens over the modal), then
|
|
21
|
+
tooltips over all of it. */
|
|
22
|
+
export const LAYER = { dialog: 90, toast: 95, floating: 100, tooltip: 110 } as const;
|
|
23
|
+
|
|
24
|
+
/* ── Anchored position ────────────────────────────────────────────── */
|
|
25
|
+
export type Side = "top" | "bottom" | "left" | "right";
|
|
26
|
+
export type Align = "start" | "center" | "end";
|
|
27
|
+
|
|
28
|
+
const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };
|
|
29
|
+
const PAD = 8;
|
|
30
|
+
|
|
31
|
+
interface AnchorOptions {
|
|
32
|
+
open: boolean;
|
|
33
|
+
side?: Side;
|
|
34
|
+
align?: Align;
|
|
35
|
+
/** Space between the anchor and the panel */
|
|
36
|
+
gap?: number;
|
|
37
|
+
/** Make the panel at least as wide as the anchor (selects, comboboxes) */
|
|
38
|
+
matchWidth?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Places a fixed-position panel next to its anchor: on the side asked
|
|
42
|
+
for unless the other side has more room, then nudged back inside the
|
|
43
|
+
viewport. Follows the anchor while the page scrolls or resizes. The
|
|
44
|
+
position is written straight onto the element, so scrolling never
|
|
45
|
+
re-renders the component. */
|
|
46
|
+
export function useAnchoredPosition(
|
|
47
|
+
anchor: RefObject<HTMLElement | null>,
|
|
48
|
+
{ open, side = "bottom", align = "start", gap = 8, matchWidth = false }: AnchorOptions,
|
|
49
|
+
) {
|
|
50
|
+
const [placed, setPlaced] = useState<Side>(side);
|
|
51
|
+
const floating = useRef<HTMLElement | null>(null);
|
|
52
|
+
|
|
53
|
+
const update = useCallback(() => {
|
|
54
|
+
const a = anchor.current?.getBoundingClientRect();
|
|
55
|
+
const el = floating.current;
|
|
56
|
+
if (!a || !el) return;
|
|
57
|
+
if (matchWidth) el.style.minWidth = `${a.width}px`;
|
|
58
|
+
|
|
59
|
+
// offset sizes ignore the enter transform, so these are the settled size
|
|
60
|
+
const w = el.offsetWidth;
|
|
61
|
+
const h = el.offsetHeight;
|
|
62
|
+
const vw = document.documentElement.clientWidth;
|
|
63
|
+
const vh = window.innerHeight;
|
|
64
|
+
const room: Record<Side, number> = { top: a.top, bottom: vh - a.bottom, left: a.left, right: vw - a.right };
|
|
65
|
+
const vertical = side === "top" || side === "bottom";
|
|
66
|
+
const need = (vertical ? h : w) + gap + PAD;
|
|
67
|
+
|
|
68
|
+
let s = side;
|
|
69
|
+
if (room[s] < need && room[OPPOSITE[s]] > room[s]) s = OPPOSITE[s];
|
|
70
|
+
|
|
71
|
+
let top: number;
|
|
72
|
+
let left: number;
|
|
73
|
+
if (s === "top" || s === "bottom") {
|
|
74
|
+
top = s === "bottom" ? a.bottom + gap : a.top - gap - h;
|
|
75
|
+
left = align === "start" ? a.left : align === "end" ? a.right - w : a.left + (a.width - w) / 2;
|
|
76
|
+
} else {
|
|
77
|
+
left = s === "right" ? a.right + gap : a.left - gap - w;
|
|
78
|
+
top = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + (a.height - h) / 2;
|
|
79
|
+
}
|
|
80
|
+
left = Math.max(PAD, Math.min(left, vw - w - PAD));
|
|
81
|
+
top = Math.max(PAD, Math.min(top, vh - h - PAD));
|
|
82
|
+
|
|
83
|
+
el.style.top = `${Math.round(top)}px`;
|
|
84
|
+
el.style.left = `${Math.round(left)}px`;
|
|
85
|
+
el.dataset.side = s;
|
|
86
|
+
setPlaced(s);
|
|
87
|
+
}, [anchor, side, align, gap, matchWidth]);
|
|
88
|
+
|
|
89
|
+
/* Attach this as the panel's ref: it's placed before its first paint */
|
|
90
|
+
const setFloating = useCallback(
|
|
91
|
+
(el: HTMLElement | null) => {
|
|
92
|
+
floating.current = el;
|
|
93
|
+
if (el) update();
|
|
94
|
+
},
|
|
95
|
+
[update],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!open) return;
|
|
100
|
+
let frame = 0;
|
|
101
|
+
const schedule = () => {
|
|
102
|
+
cancelAnimationFrame(frame);
|
|
103
|
+
frame = requestAnimationFrame(update);
|
|
104
|
+
};
|
|
105
|
+
window.addEventListener("scroll", schedule, true);
|
|
106
|
+
window.addEventListener("resize", schedule);
|
|
107
|
+
// The panel can change size while open (a filtered list, a new month)
|
|
108
|
+
const ro = new ResizeObserver(schedule);
|
|
109
|
+
if (floating.current) ro.observe(floating.current);
|
|
110
|
+
if (anchor.current) ro.observe(anchor.current);
|
|
111
|
+
return () => {
|
|
112
|
+
cancelAnimationFrame(frame);
|
|
113
|
+
window.removeEventListener("scroll", schedule, true);
|
|
114
|
+
window.removeEventListener("resize", schedule);
|
|
115
|
+
ro.disconnect();
|
|
116
|
+
};
|
|
117
|
+
}, [open, update, anchor]);
|
|
118
|
+
|
|
119
|
+
return { setFloating, side: placed, floatingRef: floating, update };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* Where the panel grows from, and where it slides in from, for each side */
|
|
123
|
+
export const ORIGIN: Record<Side, string> = {
|
|
124
|
+
top: "bottom",
|
|
125
|
+
bottom: "top",
|
|
126
|
+
left: "right",
|
|
127
|
+
right: "left",
|
|
128
|
+
};
|
|
129
|
+
export const ENTER_FROM: Record<Side, string> = {
|
|
130
|
+
top: "translateY(4px)",
|
|
131
|
+
bottom: "translateY(-4px)",
|
|
132
|
+
left: "translateX(4px)",
|
|
133
|
+
right: "translateX(-4px)",
|
|
134
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import { ENTER_FROM, LAYER, ORIGIN, Portal, Side, useAnchoredPosition } from "./Floating";
|
|
5
|
+
|
|
6
|
+
interface HoverCardProps {
|
|
7
|
+
/** What you hover: a link, a name, an avatar */
|
|
8
|
+
trigger: React.ReactNode;
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
side?: Side;
|
|
11
|
+
align?: "start" | "center" | "end";
|
|
12
|
+
/** Milliseconds to wait before opening, and before closing */
|
|
13
|
+
openDelay?: number;
|
|
14
|
+
closeDelay?: number;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* A preview that floats up when you rest the pointer on something, like
|
|
19
|
+
a profile behind a username. It waits a beat before opening, stays
|
|
20
|
+
open while the pointer crosses into it, and opens on keyboard focus
|
|
21
|
+
too. It's a preview: anything in it should also be reachable by
|
|
22
|
+
following the trigger. */
|
|
23
|
+
export function HoverCard({
|
|
24
|
+
trigger,
|
|
25
|
+
children,
|
|
26
|
+
side = "bottom",
|
|
27
|
+
align = "center",
|
|
28
|
+
openDelay = 450,
|
|
29
|
+
closeDelay = 200,
|
|
30
|
+
className = "",
|
|
31
|
+
}: HoverCardProps) {
|
|
32
|
+
const uid = useId();
|
|
33
|
+
const ref = useRef<HTMLSpanElement>(null);
|
|
34
|
+
const [open, setOpen] = useState(false);
|
|
35
|
+
const [mounted, setMounted] = useState(false);
|
|
36
|
+
const [visible, setVisible] = useState(false);
|
|
37
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
38
|
+
const { setFloating, side: placed } = useAnchoredPosition(ref, { open: mounted, side, align, gap: 10 });
|
|
39
|
+
|
|
40
|
+
if (open && !mounted) setMounted(true);
|
|
41
|
+
if (!open && visible) setVisible(false);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (open) {
|
|
45
|
+
const id = requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
|
|
46
|
+
return () => cancelAnimationFrame(id);
|
|
47
|
+
}
|
|
48
|
+
const t = setTimeout(() => setMounted(false), 180);
|
|
49
|
+
return () => clearTimeout(t);
|
|
50
|
+
}, [open]);
|
|
51
|
+
|
|
52
|
+
useEffect(() => () => {
|
|
53
|
+
if (timer.current) clearTimeout(timer.current);
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!open) return;
|
|
58
|
+
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
|
59
|
+
document.addEventListener("keydown", onKey);
|
|
60
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
61
|
+
}, [open]);
|
|
62
|
+
|
|
63
|
+
const later = (next: boolean, ms: number) => {
|
|
64
|
+
if (timer.current) clearTimeout(timer.current);
|
|
65
|
+
timer.current = setTimeout(() => setOpen(next), ms);
|
|
66
|
+
};
|
|
67
|
+
// Touch has no hover; a tap goes through to the trigger instead
|
|
68
|
+
const enter = (e: React.PointerEvent) => e.pointerType !== "touch" && later(true, openDelay);
|
|
69
|
+
const leave = (e: React.PointerEvent) => e.pointerType !== "touch" && later(false, closeDelay);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<span
|
|
73
|
+
ref={ref}
|
|
74
|
+
className={["inline-flex", className].join(" ")}
|
|
75
|
+
onPointerEnter={enter}
|
|
76
|
+
onPointerLeave={leave}
|
|
77
|
+
onFocus={() => later(true, 0)}
|
|
78
|
+
onBlur={() => later(false, closeDelay)}
|
|
79
|
+
>
|
|
80
|
+
{React.isValidElement(trigger)
|
|
81
|
+
? React.cloneElement(trigger as React.ReactElement<Record<string, unknown>>, {
|
|
82
|
+
"aria-describedby": mounted ? `${uid}-card` : undefined,
|
|
83
|
+
})
|
|
84
|
+
: trigger}
|
|
85
|
+
{mounted && (
|
|
86
|
+
<Portal>
|
|
87
|
+
<div
|
|
88
|
+
ref={setFloating}
|
|
89
|
+
id={`${uid}-card`}
|
|
90
|
+
onPointerEnter={enter}
|
|
91
|
+
onPointerLeave={leave}
|
|
92
|
+
className="w-[300px] max-w-[calc(100vw-16px)] rounded-[18px] 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[placed],
|
|
99
|
+
opacity: visible ? 1 : 0,
|
|
100
|
+
transform: visible ? "none" : `${ENTER_FROM[placed]} scale(0.96)`,
|
|
101
|
+
background: "var(--panel-bezel)",
|
|
102
|
+
boxShadow: "var(--overlay-shadow)",
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<div className="rounded-[13px] bg-panel-face p-4 text-[13px] text-ink-body" style={{ boxShadow: "var(--panel-face-shadow)" }}>
|
|
106
|
+
{children}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</Portal>
|
|
110
|
+
)}
|
|
111
|
+
</span>
|
|
112
|
+
);
|
|
113
|
+
}
|