@hogoshi/ui 0.0.0-stage → 0.1.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/package.json +63 -4
- package/src/components/avatar.tsx +30 -0
- package/src/components/badge.tsx +26 -0
- package/src/components/breadcrumb.tsx +37 -0
- package/src/components/button.tsx +58 -0
- package/src/components/calendar.tsx +244 -0
- package/src/components/card.tsx +18 -0
- package/src/components/chart.tsx +387 -0
- package/src/components/checkbox.tsx +26 -0
- package/src/components/code-block.tsx +118 -0
- package/src/components/collapsible.tsx +31 -0
- package/src/components/command.tsx +111 -0
- package/src/components/context-menu.tsx +150 -0
- package/src/components/counter.tsx +56 -0
- package/src/components/dialog.tsx +36 -0
- package/src/components/dropdown-menu.tsx +54 -0
- package/src/components/file-upload.tsx +381 -0
- package/src/components/glide.tsx +82 -0
- package/src/components/icon.tsx +28 -0
- package/src/components/image-viewer.tsx +370 -0
- package/src/components/input.tsx +187 -0
- package/src/components/kbd.tsx +14 -0
- package/src/components/logo-mark.ts +8 -0
- package/src/components/logo.tsx +36 -0
- package/src/components/navbar.tsx +144 -0
- package/src/components/otp.tsx +93 -0
- package/src/components/page-loader.tsx +37 -0
- package/src/components/pophover.tsx +28 -0
- package/src/components/popover.tsx +36 -0
- package/src/components/popup.tsx +75 -0
- package/src/components/progress.tsx +26 -0
- package/src/components/result.tsx +22 -0
- package/src/components/scroll-area.tsx +38 -0
- package/src/components/select.tsx +52 -0
- package/src/components/separator.tsx +14 -0
- package/src/components/sheet.tsx +96 -0
- package/src/components/sidebar.tsx +195 -0
- package/src/components/skeleton.tsx +14 -0
- package/src/components/slider.tsx +31 -0
- package/src/components/smooth-scroll.tsx +174 -0
- package/src/components/spinner.tsx +18 -0
- package/src/components/switch.tsx +22 -0
- package/src/components/table.tsx +52 -0
- package/src/components/tabs.tsx +48 -0
- package/src/components/theme-toggle.tsx +214 -0
- package/src/components/toaster.tsx +38 -0
- package/src/components/tooltip.tsx +43 -0
- package/src/components/video-player.tsx +559 -0
- package/src/components/video-settings.tsx +233 -0
- package/src/index.ts +102 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/jsx-highlight.ts +128 -0
- package/src/styles.css +443 -0
- package/src/theme/theme-script.ts +13 -0
- package/src/theme/theme.tsx +77 -0
- package/styles.css +1 -0
- package/README.md +0 -3
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, useRef } from "react";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
|
|
6
|
+
export const glideActive = "[[data-glide-ready]_&]:bg-transparent";
|
|
7
|
+
|
|
8
|
+
export interface GlideProps {
|
|
9
|
+
selector: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function attributesOf(selector: string) {
|
|
14
|
+
return [...new Set([...selector.matchAll(/\[([\w-]+)/g)].map((match) => match[1]))];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function offsetWithin(target: HTMLElement, frame: Element | null) {
|
|
18
|
+
let x = 0;
|
|
19
|
+
let y = 0;
|
|
20
|
+
let node: HTMLElement | null = target;
|
|
21
|
+
while (node && node !== frame) {
|
|
22
|
+
x += node.offsetLeft;
|
|
23
|
+
y += node.offsetTop;
|
|
24
|
+
node = node.offsetParent as HTMLElement | null;
|
|
25
|
+
}
|
|
26
|
+
return node === frame ? { x, y } : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Glide({ selector, className }: GlideProps) {
|
|
30
|
+
const ref = useRef<HTMLSpanElement>(null);
|
|
31
|
+
|
|
32
|
+
useLayoutEffect(() => {
|
|
33
|
+
const glide = ref.current;
|
|
34
|
+
const scope = glide?.parentElement;
|
|
35
|
+
if (!glide || !scope) return;
|
|
36
|
+
let shown = false;
|
|
37
|
+
|
|
38
|
+
const place = () => {
|
|
39
|
+
const target = scope.querySelector<HTMLElement>(selector);
|
|
40
|
+
const point = target ? offsetWithin(target, glide.offsetParent) : null;
|
|
41
|
+
if (!target || !point) {
|
|
42
|
+
glide.style.opacity = "0";
|
|
43
|
+
delete scope.dataset.glideReady;
|
|
44
|
+
shown = false;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
scope.dataset.glideReady = "";
|
|
48
|
+
if (!shown) glide.style.transition = "none";
|
|
49
|
+
glide.style.transform = `translate(${point.x}px, ${point.y}px)`;
|
|
50
|
+
glide.style.width = `${target.offsetWidth}px`;
|
|
51
|
+
glide.style.height = `${target.offsetHeight}px`;
|
|
52
|
+
if (!shown) {
|
|
53
|
+
void glide.offsetWidth;
|
|
54
|
+
glide.style.transition = "";
|
|
55
|
+
shown = true;
|
|
56
|
+
}
|
|
57
|
+
glide.style.opacity = "1";
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
place();
|
|
61
|
+
const mutations = new MutationObserver(place);
|
|
62
|
+
mutations.observe(scope, { subtree: true, childList: true, attributes: true, attributeFilter: attributesOf(selector) });
|
|
63
|
+
const resize = new ResizeObserver(place);
|
|
64
|
+
resize.observe(scope);
|
|
65
|
+
return () => {
|
|
66
|
+
mutations.disconnect();
|
|
67
|
+
resize.disconnect();
|
|
68
|
+
delete scope.dataset.glideReady;
|
|
69
|
+
};
|
|
70
|
+
}, [selector]);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<span
|
|
74
|
+
ref={ref}
|
|
75
|
+
aria-hidden
|
|
76
|
+
className={cn(
|
|
77
|
+
"bg-bg-subtle/55 pointer-events-none absolute top-0 left-0 rounded-[var(--radius-sm)] opacity-0 transition-[transform,width,height,opacity] duration-[calc(var(--motion-duration)*1.6)] ease-[var(--motion-ease)] motion-reduce:transition-none",
|
|
78
|
+
className,
|
|
79
|
+
)}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
|
|
2
|
+
|
|
3
|
+
export type AppIcon = IconSvgElement;
|
|
4
|
+
|
|
5
|
+
export type IconVariant = "fill" | "stroke";
|
|
6
|
+
|
|
7
|
+
export interface IconProps {
|
|
8
|
+
icon: AppIcon;
|
|
9
|
+
altIcon?: AppIcon;
|
|
10
|
+
showAlt?: boolean;
|
|
11
|
+
variant?: IconVariant;
|
|
12
|
+
size?: number;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function Icon({ icon, altIcon, showAlt, variant = "fill", size = 16, className }: IconProps) {
|
|
17
|
+
return (
|
|
18
|
+
<HugeiconsIcon
|
|
19
|
+
icon={icon}
|
|
20
|
+
altIcon={altIcon}
|
|
21
|
+
showAlt={showAlt}
|
|
22
|
+
size={size}
|
|
23
|
+
color="currentColor"
|
|
24
|
+
strokeWidth={variant === "stroke" ? 1.75 : undefined}
|
|
25
|
+
className={className}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
|
|
4
|
+
import {
|
|
5
|
+
ArrowLeft01Icon,
|
|
6
|
+
ArrowRight01Icon,
|
|
7
|
+
Cancel01Icon,
|
|
8
|
+
Copy01Icon,
|
|
9
|
+
Download04Icon,
|
|
10
|
+
LinkSquare02Icon,
|
|
11
|
+
MoreHorizontalIcon,
|
|
12
|
+
SearchAddIcon,
|
|
13
|
+
SearchMinusIcon,
|
|
14
|
+
} from "@hugeicons-pro/core-stroke-rounded";
|
|
15
|
+
import {
|
|
16
|
+
useEffect,
|
|
17
|
+
useRef,
|
|
18
|
+
useState,
|
|
19
|
+
type ComponentProps,
|
|
20
|
+
type MouseEvent,
|
|
21
|
+
type PointerEvent as ReactPointerEvent,
|
|
22
|
+
} from "react";
|
|
23
|
+
import { cn } from "../lib/cn";
|
|
24
|
+
import { ContextMenuContent, ContextMenuItem, ContextMenuRoot, ContextMenuTrigger } from "./context-menu";
|
|
25
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./dropdown-menu";
|
|
26
|
+
import { Icon } from "./icon";
|
|
27
|
+
import { backdropMotion, popupMotion } from "./popup";
|
|
28
|
+
|
|
29
|
+
export interface ViewerImage {
|
|
30
|
+
src: string;
|
|
31
|
+
alt?: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MIN = 1;
|
|
36
|
+
const MAX = 6;
|
|
37
|
+
|
|
38
|
+
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
|
39
|
+
|
|
40
|
+
function fileName(image: ViewerImage) {
|
|
41
|
+
return image.name ?? image.src.split("/").pop()?.split("?")[0] ?? "image";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function downloadImage(image: ViewerImage) {
|
|
45
|
+
try {
|
|
46
|
+
const blob = await (await fetch(image.src)).blob();
|
|
47
|
+
const url = URL.createObjectURL(blob);
|
|
48
|
+
const link = document.createElement("a");
|
|
49
|
+
link.href = url;
|
|
50
|
+
link.download = fileName(image);
|
|
51
|
+
link.click();
|
|
52
|
+
URL.revokeObjectURL(url);
|
|
53
|
+
} catch {
|
|
54
|
+
window.open(image.src, "_blank", "noopener");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const toolClass =
|
|
59
|
+
"text-fg hover:bg-bg-subtle inline-flex size-9 cursor-pointer items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40";
|
|
60
|
+
|
|
61
|
+
interface View {
|
|
62
|
+
scale: number;
|
|
63
|
+
x: number;
|
|
64
|
+
y: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const resting: View = { scale: 1, x: 0, y: 0 };
|
|
68
|
+
|
|
69
|
+
function useStageGestures(onSwipe: (direction: 1 | -1) => void) {
|
|
70
|
+
const stage = useRef<HTMLDivElement>(null);
|
|
71
|
+
const [view, setView] = useState<View>(resting);
|
|
72
|
+
const [moving, setMoving] = useState(false);
|
|
73
|
+
const pointers = useRef(new Map<number, { x: number; y: number }>());
|
|
74
|
+
const gesture = useRef<{ view: View; x: number; y: number; distance: number } | null>(null);
|
|
75
|
+
|
|
76
|
+
const zoomAt = (next: number, clientX: number, clientY: number) => {
|
|
77
|
+
const rect = stage.current?.getBoundingClientRect();
|
|
78
|
+
if (!rect) return;
|
|
79
|
+
setView((current) => {
|
|
80
|
+
const scale = clamp(next, MIN, MAX);
|
|
81
|
+
if (scale === MIN) return resting;
|
|
82
|
+
const px = clientX - rect.left - rect.width / 2;
|
|
83
|
+
const py = clientY - rect.top - rect.height / 2;
|
|
84
|
+
const ratio = scale / current.scale;
|
|
85
|
+
return { scale, x: px - (px - current.x) * ratio, y: py - (py - current.y) * ratio };
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
const node = stage.current;
|
|
91
|
+
if (!node) return;
|
|
92
|
+
const onWheel = (event: WheelEvent) => {
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
setView((current) => {
|
|
95
|
+
const rect = node.getBoundingClientRect();
|
|
96
|
+
const scale = clamp(current.scale * Math.exp(-event.deltaY * 0.0025), MIN, MAX);
|
|
97
|
+
if (scale === MIN) return resting;
|
|
98
|
+
const px = event.clientX - rect.left - rect.width / 2;
|
|
99
|
+
const py = event.clientY - rect.top - rect.height / 2;
|
|
100
|
+
const ratio = scale / current.scale;
|
|
101
|
+
return { scale, x: px - (px - current.x) * ratio, y: py - (py - current.y) * ratio };
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
node.addEventListener("wheel", onWheel, { passive: false });
|
|
105
|
+
return () => node.removeEventListener("wheel", onWheel);
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
const spread = () => {
|
|
109
|
+
const [a, b] = [...pointers.current.values()];
|
|
110
|
+
return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const handlers = {
|
|
114
|
+
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
115
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
116
|
+
pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
117
|
+
gesture.current = { view, x: event.clientX, y: event.clientY, distance: spread() };
|
|
118
|
+
setMoving(true);
|
|
119
|
+
},
|
|
120
|
+
onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
121
|
+
if (!pointers.current.has(event.pointerId) || !gesture.current) return;
|
|
122
|
+
pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
123
|
+
const start = gesture.current;
|
|
124
|
+
if (pointers.current.size > 1 && start.distance) {
|
|
125
|
+
setView({ ...start.view, scale: clamp(start.view.scale * (spread() / start.distance), MIN, MAX) });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (start.view.scale > 1) {
|
|
129
|
+
setView({ ...start.view, x: start.view.x + event.clientX - start.x, y: start.view.y + event.clientY - start.y });
|
|
130
|
+
} else {
|
|
131
|
+
setView({ scale: 1, x: (event.clientX - start.x) * 0.6, y: 0 });
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
135
|
+
const start = gesture.current;
|
|
136
|
+
pointers.current.delete(event.pointerId);
|
|
137
|
+
if (pointers.current.size) return;
|
|
138
|
+
setMoving(false);
|
|
139
|
+
gesture.current = null;
|
|
140
|
+
if (start && start.view.scale === 1) {
|
|
141
|
+
const dx = event.clientX - start.x;
|
|
142
|
+
if (Math.abs(dx) > 60) onSwipe(dx < 0 ? 1 : -1);
|
|
143
|
+
setView((current) => (current.scale === 1 ? resting : current));
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
onDoubleClick: (event: MouseEvent<HTMLDivElement>) => zoomAt(view.scale > 1 ? MIN : 2.5, event.clientX, event.clientY),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return { stage, view, setView, moving, zoomAt, handlers };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function ImageViewer({
|
|
153
|
+
images,
|
|
154
|
+
index,
|
|
155
|
+
open,
|
|
156
|
+
onOpenChange,
|
|
157
|
+
onIndexChange,
|
|
158
|
+
}: {
|
|
159
|
+
images: ViewerImage[];
|
|
160
|
+
index: number;
|
|
161
|
+
open: boolean;
|
|
162
|
+
onOpenChange: (open: boolean) => void;
|
|
163
|
+
onIndexChange: (index: number) => void;
|
|
164
|
+
}) {
|
|
165
|
+
const [direction, setDirection] = useState<1 | -1>(1);
|
|
166
|
+
const count = images.length;
|
|
167
|
+
const image = images[clamp(index, 0, Math.max(0, count - 1))];
|
|
168
|
+
|
|
169
|
+
const go = (step: 1 | -1) => {
|
|
170
|
+
if (count < 2) return;
|
|
171
|
+
setDirection(step);
|
|
172
|
+
onIndexChange((index + step + count) % count);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const { stage, view, setView, moving, zoomAt, handlers } = useStageGestures(go);
|
|
176
|
+
|
|
177
|
+
useEffect(() => setView(resting), [index, open, setView]);
|
|
178
|
+
|
|
179
|
+
if (!image) return null;
|
|
180
|
+
|
|
181
|
+
const zoomCenter = (factor: number) => {
|
|
182
|
+
const rect = stage.current?.getBoundingClientRect();
|
|
183
|
+
if (rect) zoomAt(view.scale * factor, rect.left + rect.width / 2, rect.top + rect.height / 2);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const actions = [
|
|
187
|
+
{ id: "download", label: "Download", icon: Download04Icon, onSelect: () => void downloadImage(image) },
|
|
188
|
+
{ id: "open", label: "Open original", icon: LinkSquare02Icon, onSelect: () => window.open(image.src, "_blank", "noopener") },
|
|
189
|
+
{ id: "copy", label: "Copy link", icon: Copy01Icon, onSelect: () => void navigator.clipboard.writeText(image.src) },
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<BaseDialog.Root open={open} onOpenChange={onOpenChange}>
|
|
194
|
+
<BaseDialog.Portal>
|
|
195
|
+
<BaseDialog.Backdrop className={cn("bg-bg/95 fixed inset-0 z-[var(--z-overlay)] backdrop-blur-md", backdropMotion)} />
|
|
196
|
+
<BaseDialog.Popup
|
|
197
|
+
className={cn("fixed inset-0 z-[var(--z-modal)] flex flex-col outline-none", popupMotion)}
|
|
198
|
+
onKeyDown={(event) => {
|
|
199
|
+
if (event.key === "ArrowRight") go(1);
|
|
200
|
+
else if (event.key === "ArrowLeft") go(-1);
|
|
201
|
+
else if (event.key === "+" || event.key === "=") zoomCenter(1.5);
|
|
202
|
+
else if (event.key === "-") zoomCenter(1 / 1.5);
|
|
203
|
+
else if (event.key === "0") setView(resting);
|
|
204
|
+
}}
|
|
205
|
+
>
|
|
206
|
+
<BaseDialog.Title className="sr-only">{image.alt ?? fileName(image)}</BaseDialog.Title>
|
|
207
|
+
<div className="flex shrink-0 items-center gap-2 px-3 pt-[calc(0.75rem+var(--safe-top))] pr-[calc(0.75rem+var(--safe-right))] pb-3 pl-[calc(0.75rem+var(--safe-left))]">
|
|
208
|
+
<p className="text-fg-muted min-w-0 flex-1 truncate text-sm">
|
|
209
|
+
{count > 1 ? <span className="text-fg mr-2 tabular-nums">{index + 1} / {count}</span> : null}
|
|
210
|
+
{image.alt ?? fileName(image)}
|
|
211
|
+
</p>
|
|
212
|
+
<button type="button" aria-label="Zoom out" className={toolClass} disabled={view.scale <= MIN} onClick={() => zoomCenter(1 / 1.5)}>
|
|
213
|
+
<Icon icon={SearchMinusIcon} variant="stroke" size={18} />
|
|
214
|
+
</button>
|
|
215
|
+
<button type="button" aria-label="Zoom in" className={toolClass} disabled={view.scale >= MAX} onClick={() => zoomCenter(1.5)}>
|
|
216
|
+
<Icon icon={SearchAddIcon} variant="stroke" size={18} />
|
|
217
|
+
</button>
|
|
218
|
+
<DropdownMenu>
|
|
219
|
+
<DropdownMenuTrigger aria-label="More" className={toolClass}>
|
|
220
|
+
<Icon icon={MoreHorizontalIcon} variant="stroke" size={18} />
|
|
221
|
+
</DropdownMenuTrigger>
|
|
222
|
+
<DropdownMenuContent>
|
|
223
|
+
{actions.map((action) => (
|
|
224
|
+
<DropdownMenuItem key={action.id} onClick={action.onSelect}>
|
|
225
|
+
<Icon icon={action.icon} variant="stroke" size={16} />
|
|
226
|
+
{action.label}
|
|
227
|
+
</DropdownMenuItem>
|
|
228
|
+
))}
|
|
229
|
+
</DropdownMenuContent>
|
|
230
|
+
</DropdownMenu>
|
|
231
|
+
<BaseDialog.Close aria-label="Close" className={toolClass}>
|
|
232
|
+
<Icon icon={Cancel01Icon} variant="stroke" size={18} />
|
|
233
|
+
</BaseDialog.Close>
|
|
234
|
+
</div>
|
|
235
|
+
<ContextMenuRoot>
|
|
236
|
+
<ContextMenuTrigger
|
|
237
|
+
ref={stage}
|
|
238
|
+
className={cn(
|
|
239
|
+
"relative flex min-h-0 flex-1 touch-none items-center justify-center overflow-hidden select-none",
|
|
240
|
+
view.scale > 1 ? "cursor-grab active:cursor-grabbing" : "cursor-zoom-in",
|
|
241
|
+
)}
|
|
242
|
+
{...handlers}
|
|
243
|
+
>
|
|
244
|
+
<img
|
|
245
|
+
key={image.src}
|
|
246
|
+
src={image.src}
|
|
247
|
+
alt={image.alt ?? ""}
|
|
248
|
+
draggable={false}
|
|
249
|
+
className={cn(
|
|
250
|
+
"max-h-full max-w-full object-contain px-4 will-change-transform",
|
|
251
|
+
direction === 1
|
|
252
|
+
? "animate-[slide-from-right_var(--motion-duration)_var(--motion-ease)]"
|
|
253
|
+
: "animate-[slide-from-left_var(--motion-duration)_var(--motion-ease)]",
|
|
254
|
+
!moving && "transition-transform duration-[var(--motion-duration)] ease-[var(--motion-ease)]",
|
|
255
|
+
)}
|
|
256
|
+
style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }}
|
|
257
|
+
/>
|
|
258
|
+
</ContextMenuTrigger>
|
|
259
|
+
<ContextMenuContent>
|
|
260
|
+
{actions.map((action) => (
|
|
261
|
+
<ContextMenuItem key={action.id} onClick={action.onSelect}>
|
|
262
|
+
<Icon icon={action.icon} variant="stroke" size={16} />
|
|
263
|
+
{action.label}
|
|
264
|
+
</ContextMenuItem>
|
|
265
|
+
))}
|
|
266
|
+
</ContextMenuContent>
|
|
267
|
+
</ContextMenuRoot>
|
|
268
|
+
{count > 1 ? (
|
|
269
|
+
<>
|
|
270
|
+
<button
|
|
271
|
+
type="button"
|
|
272
|
+
aria-label="Previous image"
|
|
273
|
+
className={cn(toolClass, "bg-bg-elevated/80 border-border absolute top-1/2 left-[calc(0.75rem+var(--safe-left))] size-11 -translate-y-1/2 border backdrop-blur-md")}
|
|
274
|
+
onClick={() => go(-1)}
|
|
275
|
+
>
|
|
276
|
+
<Icon icon={ArrowLeft01Icon} variant="stroke" size={20} />
|
|
277
|
+
</button>
|
|
278
|
+
<button
|
|
279
|
+
type="button"
|
|
280
|
+
aria-label="Next image"
|
|
281
|
+
className={cn(toolClass, "bg-bg-elevated/80 border-border absolute top-1/2 right-[calc(0.75rem+var(--safe-right))] size-11 -translate-y-1/2 border backdrop-blur-md")}
|
|
282
|
+
onClick={() => go(1)}
|
|
283
|
+
>
|
|
284
|
+
<Icon icon={ArrowRight01Icon} variant="stroke" size={20} />
|
|
285
|
+
</button>
|
|
286
|
+
<div className="flex shrink-0 justify-center gap-1.5 overflow-x-auto px-3 pt-3 pb-[calc(0.75rem+var(--safe-bottom))]">
|
|
287
|
+
{images.map((item, at) => (
|
|
288
|
+
<button
|
|
289
|
+
key={`${item.src}-${at}`}
|
|
290
|
+
type="button"
|
|
291
|
+
aria-label={`Show image ${at + 1}`}
|
|
292
|
+
aria-current={at === index || undefined}
|
|
293
|
+
className="border-border aria-[current]:border-accent size-12 shrink-0 cursor-pointer overflow-hidden rounded-[var(--radius-sm)] border-2 opacity-60 transition-opacity duration-[var(--motion-duration)] hover:opacity-100 aria-[current]:opacity-100"
|
|
294
|
+
onClick={() => {
|
|
295
|
+
setDirection(at > index ? 1 : -1);
|
|
296
|
+
onIndexChange(at);
|
|
297
|
+
}}
|
|
298
|
+
>
|
|
299
|
+
<img src={item.src} alt="" className="size-full object-cover" draggable={false} />
|
|
300
|
+
</button>
|
|
301
|
+
))}
|
|
302
|
+
</div>
|
|
303
|
+
</>
|
|
304
|
+
) : null}
|
|
305
|
+
</BaseDialog.Popup>
|
|
306
|
+
</BaseDialog.Portal>
|
|
307
|
+
</BaseDialog.Root>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function ImageGallery({
|
|
312
|
+
images,
|
|
313
|
+
className,
|
|
314
|
+
imageClassName,
|
|
315
|
+
}: {
|
|
316
|
+
images: ViewerImage[];
|
|
317
|
+
className?: string;
|
|
318
|
+
imageClassName?: string;
|
|
319
|
+
}) {
|
|
320
|
+
const [open, setOpen] = useState(false);
|
|
321
|
+
const [index, setIndex] = useState(0);
|
|
322
|
+
return (
|
|
323
|
+
<>
|
|
324
|
+
<div className={cn("grid grid-cols-[repeat(auto-fill,minmax(8rem,1fr))] gap-2", className)}>
|
|
325
|
+
{images.map((image, at) => (
|
|
326
|
+
<button
|
|
327
|
+
key={`${image.src}-${at}`}
|
|
328
|
+
type="button"
|
|
329
|
+
aria-label={`Open ${image.alt ?? fileName(image)}`}
|
|
330
|
+
className={cn(
|
|
331
|
+
"border-border bg-bg-subtle group aspect-square cursor-zoom-in overflow-hidden rounded-[var(--radius-md)] border outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
332
|
+
imageClassName,
|
|
333
|
+
)}
|
|
334
|
+
onClick={() => {
|
|
335
|
+
setIndex(at);
|
|
336
|
+
setOpen(true);
|
|
337
|
+
}}
|
|
338
|
+
>
|
|
339
|
+
<img
|
|
340
|
+
src={image.src}
|
|
341
|
+
alt={image.alt ?? ""}
|
|
342
|
+
loading="lazy"
|
|
343
|
+
draggable={false}
|
|
344
|
+
className="size-full object-cover transition-transform duration-[var(--motion-duration)] ease-[var(--motion-ease)] group-hover:scale-[1.03]"
|
|
345
|
+
/>
|
|
346
|
+
</button>
|
|
347
|
+
))}
|
|
348
|
+
</div>
|
|
349
|
+
<ImageViewer images={images} index={index} open={open} onOpenChange={setOpen} onIndexChange={setIndex} />
|
|
350
|
+
</>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function Image({ src, alt, name, className, ...props }: ViewerImage & Omit<ComponentProps<"img">, "src" | "alt">) {
|
|
355
|
+
const [open, setOpen] = useState(false);
|
|
356
|
+
const images = [{ src, alt, name }];
|
|
357
|
+
return (
|
|
358
|
+
<>
|
|
359
|
+
<button
|
|
360
|
+
type="button"
|
|
361
|
+
aria-label={`Open ${alt ?? "image"}`}
|
|
362
|
+
className="cursor-zoom-in overflow-hidden rounded-[var(--radius-md)] outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
363
|
+
onClick={() => setOpen(true)}
|
|
364
|
+
>
|
|
365
|
+
<img src={src} alt={alt ?? ""} loading="lazy" draggable={false} className={cn("block", className)} {...props} />
|
|
366
|
+
</button>
|
|
367
|
+
<ImageViewer images={images} index={0} open={open} onOpenChange={setOpen} onIndexChange={() => undefined} />
|
|
368
|
+
</>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Input as BaseInput } from "@base-ui/react/input";
|
|
4
|
+
import { Add01Icon, Cancel01Icon, MinusSignIcon, Search01Icon, ViewIcon, ViewOffSlashIcon } from "@hugeicons-pro/core-stroke-rounded";
|
|
5
|
+
import { useRef, useState, type ComponentProps, type ReactNode } from "react";
|
|
6
|
+
import { cn } from "../lib/cn";
|
|
7
|
+
import { Icon } from "./icon";
|
|
8
|
+
|
|
9
|
+
const fieldClass =
|
|
10
|
+
"bg-bg-elevated text-fg placeholder:text-fg-subtle border-border w-full rounded-[var(--radius-md)] border px-3 text-sm transition-colors duration-[var(--motion-duration)] hover:border-border-strong focus-visible:border-accent focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50";
|
|
11
|
+
|
|
12
|
+
export const searchControlClass = cn(
|
|
13
|
+
fieldClass,
|
|
14
|
+
"[height:var(--row-h)] pr-10 pl-9 [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden",
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export function SearchChrome({
|
|
18
|
+
value,
|
|
19
|
+
disabled,
|
|
20
|
+
onClear,
|
|
21
|
+
children,
|
|
22
|
+
}: {
|
|
23
|
+
value: string;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
onClear: () => void;
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}) {
|
|
28
|
+
return (
|
|
29
|
+
<div className="relative">
|
|
30
|
+
<Icon
|
|
31
|
+
icon={Search01Icon}
|
|
32
|
+
variant="stroke"
|
|
33
|
+
className="text-fg-muted pointer-events-none absolute top-1/2 left-3 -translate-y-1/2"
|
|
34
|
+
/>
|
|
35
|
+
{children}
|
|
36
|
+
{value ? (
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
aria-label="Clear"
|
|
40
|
+
disabled={disabled}
|
|
41
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
42
|
+
onClick={onClear}
|
|
43
|
+
className="text-fg-muted hover:text-fg absolute top-1/2 right-2 inline-flex size-7 -translate-y-1/2 items-center justify-center disabled:pointer-events-none disabled:opacity-50"
|
|
44
|
+
>
|
|
45
|
+
<Icon icon={Cancel01Icon} variant="stroke" />
|
|
46
|
+
</button>
|
|
47
|
+
) : null}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type InputProps = ComponentProps<typeof BaseInput>;
|
|
53
|
+
|
|
54
|
+
function bound(value: InputProps["min"]) {
|
|
55
|
+
if (value === undefined) return undefined;
|
|
56
|
+
const parsed = Number(value);
|
|
57
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function NumberInput({ className, min, max, step, disabled, value, defaultValue, onChange, ...props }: InputProps) {
|
|
61
|
+
const [current, setCurrent] = useState(String(defaultValue ?? value ?? ""));
|
|
62
|
+
const minBound = bound(min);
|
|
63
|
+
const maxBound = bound(max);
|
|
64
|
+
const stepValue = bound(step) ?? 1;
|
|
65
|
+
const shown = value !== undefined ? String(value) : current;
|
|
66
|
+
const numeric = shown === "" ? 0 : Number(shown);
|
|
67
|
+
|
|
68
|
+
const commit = (next: number) => {
|
|
69
|
+
const text = String(next);
|
|
70
|
+
setCurrent(text);
|
|
71
|
+
onChange?.({ target: { value: text } } as never);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const nudge = (direction: 1 | -1) => {
|
|
75
|
+
if (disabled || Number.isNaN(numeric)) return;
|
|
76
|
+
let next = (shown === "" ? 0 : numeric) + direction * stepValue;
|
|
77
|
+
if (minBound !== undefined) next = Math.max(minBound, next);
|
|
78
|
+
if (maxBound !== undefined) next = Math.min(maxBound, next);
|
|
79
|
+
commit(next);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className={cn("flex items-center gap-1", className)}>
|
|
84
|
+
<button type="button" aria-label="Decrease" disabled={disabled} onClick={() => nudge(-1)} className="border-border bg-bg-elevated text-fg hover:bg-bg-subtle inline-flex size-9 items-center justify-center rounded-[var(--radius-md)] border">
|
|
85
|
+
<Icon icon={MinusSignIcon} variant="stroke" />
|
|
86
|
+
</button>
|
|
87
|
+
<BaseInput
|
|
88
|
+
type="text"
|
|
89
|
+
inputMode="decimal"
|
|
90
|
+
autoComplete="off"
|
|
91
|
+
spellCheck={false}
|
|
92
|
+
disabled={disabled}
|
|
93
|
+
value={shown}
|
|
94
|
+
onChange={(event) => {
|
|
95
|
+
setCurrent(event.target.value);
|
|
96
|
+
onChange?.(event);
|
|
97
|
+
}}
|
|
98
|
+
className={cn(fieldClass, "[height:var(--row-h)] text-center")}
|
|
99
|
+
{...props}
|
|
100
|
+
/>
|
|
101
|
+
<button type="button" aria-label="Increase" disabled={disabled} onClick={() => nudge(1)} className="border-border bg-bg-elevated text-fg hover:bg-bg-subtle inline-flex size-9 items-center justify-center rounded-[var(--radius-md)] border">
|
|
102
|
+
<Icon icon={Add01Icon} variant="stroke" />
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function PasswordInput({ className, disabled, ...props }: InputProps) {
|
|
109
|
+
const [visible, setVisible] = useState(false);
|
|
110
|
+
return (
|
|
111
|
+
<div className="relative">
|
|
112
|
+
<BaseInput
|
|
113
|
+
autoComplete="off"
|
|
114
|
+
spellCheck={false}
|
|
115
|
+
disabled={disabled}
|
|
116
|
+
type={visible ? "text" : "password"}
|
|
117
|
+
className={cn(fieldClass, "[height:var(--row-h)] pr-10 [&::-ms-reveal]:hidden", className)}
|
|
118
|
+
{...props}
|
|
119
|
+
/>
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
aria-label={visible ? "Hide password" : "Show password"}
|
|
123
|
+
disabled={disabled}
|
|
124
|
+
onClick={() => setVisible((current) => !current)}
|
|
125
|
+
className="text-fg-muted hover:text-fg absolute top-1/2 right-2 inline-flex size-7 -translate-y-1/2 items-center justify-center disabled:pointer-events-none disabled:opacity-50"
|
|
126
|
+
>
|
|
127
|
+
<Icon icon={visible ? ViewOffSlashIcon : ViewIcon} variant="stroke" />
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function SearchInput({ className, disabled, value, defaultValue, onChange, onValueChange, ...props }: InputProps) {
|
|
134
|
+
const input = useRef<HTMLInputElement>(null);
|
|
135
|
+
const [inner, setInner] = useState(String(defaultValue ?? ""));
|
|
136
|
+
const shown = value !== undefined ? String(value) : inner;
|
|
137
|
+
|
|
138
|
+
const apply = (next: string) => {
|
|
139
|
+
setInner(next);
|
|
140
|
+
onChange?.({ target: { value: next } } as never);
|
|
141
|
+
onValueChange?.(next, { reason: "none" } as never);
|
|
142
|
+
input.current?.focus();
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<SearchChrome value={shown} disabled={disabled} onClear={() => apply("")}>
|
|
147
|
+
<BaseInput
|
|
148
|
+
ref={input}
|
|
149
|
+
type="search"
|
|
150
|
+
autoComplete="off"
|
|
151
|
+
spellCheck={false}
|
|
152
|
+
disabled={disabled}
|
|
153
|
+
{...props}
|
|
154
|
+
value={shown}
|
|
155
|
+
onChange={(event) => {
|
|
156
|
+
setInner(event.target.value);
|
|
157
|
+
onChange?.(event);
|
|
158
|
+
}}
|
|
159
|
+
onValueChange={onValueChange}
|
|
160
|
+
className={cn(searchControlClass, className)}
|
|
161
|
+
/>
|
|
162
|
+
</SearchChrome>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function Input({ className, type, autoComplete = "off", spellCheck = false, ...props }: InputProps) {
|
|
167
|
+
if (type === "password") return <PasswordInput className={className} {...props} />;
|
|
168
|
+
if (type === "number") return <NumberInput className={className} {...props} />;
|
|
169
|
+
if (type === "search") return <SearchInput className={className} {...props} />;
|
|
170
|
+
return (
|
|
171
|
+
<BaseInput
|
|
172
|
+
type={type}
|
|
173
|
+
autoComplete={autoComplete}
|
|
174
|
+
spellCheck={spellCheck}
|
|
175
|
+
className={cn(fieldClass, "[height:var(--row-h)]", className)}
|
|
176
|
+
{...props}
|
|
177
|
+
/>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function Textarea({ className, spellCheck = false, ...props }: ComponentProps<"textarea">) {
|
|
182
|
+
return <textarea spellCheck={spellCheck} className={cn(fieldClass, "hogoshi-textarea min-h-24 resize-y py-2", className)} {...props} />;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function Label({ className, ...props }: ComponentProps<"label">) {
|
|
186
|
+
return <label className={cn("text-fg text-sm font-medium", className)} {...props} />;
|
|
187
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
export function Kbd({ className, ...props }: ComponentProps<"kbd">) {
|
|
5
|
+
return (
|
|
6
|
+
<kbd
|
|
7
|
+
className={cn(
|
|
8
|
+
"bg-bg-subtle text-fg-muted border-border inline-flex h-5 min-w-5 items-center justify-center rounded-[var(--radius-sm)] border px-1 font-mono text-[10px] font-medium",
|
|
9
|
+
className,
|
|
10
|
+
)}
|
|
11
|
+
{...props}
|
|
12
|
+
/>
|
|
13
|
+
);
|
|
14
|
+
}
|