@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.
Files changed (57) hide show
  1. package/package.json +63 -4
  2. package/src/components/avatar.tsx +30 -0
  3. package/src/components/badge.tsx +26 -0
  4. package/src/components/breadcrumb.tsx +37 -0
  5. package/src/components/button.tsx +58 -0
  6. package/src/components/calendar.tsx +244 -0
  7. package/src/components/card.tsx +18 -0
  8. package/src/components/chart.tsx +387 -0
  9. package/src/components/checkbox.tsx +26 -0
  10. package/src/components/code-block.tsx +118 -0
  11. package/src/components/collapsible.tsx +31 -0
  12. package/src/components/command.tsx +111 -0
  13. package/src/components/context-menu.tsx +150 -0
  14. package/src/components/counter.tsx +56 -0
  15. package/src/components/dialog.tsx +36 -0
  16. package/src/components/dropdown-menu.tsx +54 -0
  17. package/src/components/file-upload.tsx +381 -0
  18. package/src/components/glide.tsx +82 -0
  19. package/src/components/icon.tsx +28 -0
  20. package/src/components/image-viewer.tsx +370 -0
  21. package/src/components/input.tsx +187 -0
  22. package/src/components/kbd.tsx +14 -0
  23. package/src/components/logo-mark.ts +8 -0
  24. package/src/components/logo.tsx +36 -0
  25. package/src/components/navbar.tsx +144 -0
  26. package/src/components/otp.tsx +93 -0
  27. package/src/components/page-loader.tsx +37 -0
  28. package/src/components/pophover.tsx +28 -0
  29. package/src/components/popover.tsx +36 -0
  30. package/src/components/popup.tsx +75 -0
  31. package/src/components/progress.tsx +26 -0
  32. package/src/components/result.tsx +22 -0
  33. package/src/components/scroll-area.tsx +38 -0
  34. package/src/components/select.tsx +52 -0
  35. package/src/components/separator.tsx +14 -0
  36. package/src/components/sheet.tsx +96 -0
  37. package/src/components/sidebar.tsx +195 -0
  38. package/src/components/skeleton.tsx +14 -0
  39. package/src/components/slider.tsx +31 -0
  40. package/src/components/smooth-scroll.tsx +174 -0
  41. package/src/components/spinner.tsx +18 -0
  42. package/src/components/switch.tsx +22 -0
  43. package/src/components/table.tsx +52 -0
  44. package/src/components/tabs.tsx +48 -0
  45. package/src/components/theme-toggle.tsx +214 -0
  46. package/src/components/toaster.tsx +38 -0
  47. package/src/components/tooltip.tsx +43 -0
  48. package/src/components/video-player.tsx +559 -0
  49. package/src/components/video-settings.tsx +233 -0
  50. package/src/index.ts +102 -0
  51. package/src/lib/cn.ts +6 -0
  52. package/src/lib/jsx-highlight.ts +128 -0
  53. package/src/styles.css +443 -0
  54. package/src/theme/theme-script.ts +13 -0
  55. package/src/theme/theme.tsx +77 -0
  56. package/styles.css +1 -0
  57. 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
+ }