@vvedantb/ui 0.26.0 → 0.27.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.
@@ -0,0 +1,67 @@
1
+ import { AnimatePresence, m } from "motion/react";
2
+ import type { ReactNode } from "react";
3
+ import { tokenTransition } from "../motion/presets";
4
+ import { useCn } from "./ui-config";
5
+
6
+ /**
7
+ * The entrance and exit for a live count badge (unread dots, a drafts pill, a
8
+ * running count). The number usually changes while the user looks elsewhere,
9
+ * so the badge says *that it changed*: it pops from `scale(0.5)` on an
10
+ * emphasized curve, and pops again on every change because `label` is the
11
+ * key (2 → 3 remounts). Exit is a plain shrink on `tokenTransition.fast`.
12
+ *
13
+ * `mode="popLayout"` takes the leaving badge out of flow, so 9 → 10 does not
14
+ * push its neighbours sideways during the crossfade. Only the motion lives
15
+ * here; the caller owns the look through `className`. Uses `m`, so the app
16
+ * must render it inside `LazyMotion`.
17
+ */
18
+ export function CountPop({
19
+ label,
20
+ className,
21
+ children,
22
+ }: {
23
+ /** Rendered content, and the remount key. `null` renders nothing. */
24
+ label: string | null;
25
+ className?: string;
26
+ /** Defaults to `label`; pass children for a badge with more than a number. */
27
+ children?: ReactNode;
28
+ }) {
29
+ return (
30
+ <AnimatePresence mode="popLayout">
31
+ {label === null ? null : (
32
+ <m.span
33
+ key={label}
34
+ initial={{ opacity: 0, scale: 0.5 }}
35
+ animate={{ opacity: 1, scale: 1 }}
36
+ exit={{ opacity: 0, scale: 0.5, transition: tokenTransition.fast }}
37
+ transition={{ ...tokenTransition.base, ease: [0.2, 0.8, 0.2, 1] }}
38
+ className={className}
39
+ >
40
+ {children ?? label}
41
+ </m.span>
42
+ )}
43
+ </AnimatePresence>
44
+ );
45
+ }
46
+
47
+ /** A count badge label: `null` for nothing to show, `"99+"` above 99. */
48
+ export function countLabel(count: number | undefined): string | null {
49
+ if (count === undefined || count <= 0) return null;
50
+ return count > 99 ? "99+" : String(count);
51
+ }
52
+
53
+ /**
54
+ * The unread mark for rows, cards and tabs: a `bg-primary` dot, so it follows
55
+ * the theme accent. `CountPop` gives it the pop-in and the exit.
56
+ */
57
+ export function UnreadDot({ show, className }: { show: boolean; className?: string }) {
58
+ const cn = useCn();
59
+ return (
60
+ <CountPop
61
+ label={show ? "unread" : null}
62
+ className={cn("size-1.5 shrink-0 rounded-full bg-primary", className)}
63
+ >
64
+ <span className="sr-only">Unread</span>
65
+ </CountPop>
66
+ );
67
+ }
@@ -0,0 +1,48 @@
1
+ import { Component, type ErrorInfo, type ReactNode } from "react";
2
+
3
+ export interface ErrorBoundaryProps {
4
+ children: ReactNode;
5
+ /**
6
+ * Rendered when a child throws (a failed query, a render bug). Pass a
7
+ * function to get a `retry` that clears the error and re-renders the
8
+ * children: a boundary with no way out stays broken until its parent
9
+ * remounts, which for a persistent pane means a full page reload.
10
+ */
11
+ fallback?: ReactNode | ((retry: () => void) => ReactNode);
12
+ /** Called with the error. Default: `console.error` with the component stack. */
13
+ onError?: (error: Error, info: ErrorInfo) => void;
14
+ }
15
+
16
+ interface ErrorBoundaryState {
17
+ hasError: boolean;
18
+ }
19
+
20
+ /**
21
+ * Contains a failure to its own subtree, so one broken pane or subscription
22
+ * cannot swap the whole layout for an error page.
23
+ */
24
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
25
+ override state: ErrorBoundaryState = { hasError: false };
26
+
27
+ static getDerivedStateFromError(): ErrorBoundaryState {
28
+ return { hasError: true };
29
+ }
30
+
31
+ override componentDidCatch(error: Error, info: ErrorInfo) {
32
+ if (this.props.onError) this.props.onError(error, info);
33
+ else console.error("ErrorBoundary caught:", error, info.componentStack);
34
+ }
35
+
36
+ private readonly retry = () => {
37
+ this.setState({ hasError: false });
38
+ };
39
+
40
+ override render() {
41
+ if (this.state.hasError) {
42
+ const { fallback } = this.props;
43
+ if (typeof fallback === "function") return fallback(this.retry);
44
+ return fallback ?? null;
45
+ }
46
+ return this.props.children;
47
+ }
48
+ }
@@ -0,0 +1,197 @@
1
+ import { useRef } from "react";
2
+ import {
3
+ IconChevronLeft,
4
+ IconChevronRight,
5
+ IconExternalLink,
6
+ } from "@tabler/icons-react";
7
+ import { animate, m, useMotionValue } from "motion/react";
8
+ import { projectVelocity } from "../utils/gesture";
9
+ import { Dialog, DialogContent, DialogTitle } from "./dialog";
10
+
11
+ export type GalleryImage = { url: string; alt?: string };
12
+
13
+ /**
14
+ * Controls (arrows, "open in new tab") opt out of the swipe surface: taking
15
+ * pointer capture on the container retargets the resulting `click` to the
16
+ * container, so a captured drag would swallow every button press.
17
+ */
18
+ const CONTROL_SELECTOR = "[data-lightbox-control]";
19
+
20
+ /**
21
+ * Fullscreen image viewer: cycles with the arrow keys, the arrow buttons or a
22
+ * 1:1 touch / pointer swipe (with velocity projection), wrapping at both ends.
23
+ * Controlled: `index` is `null` while closed. Uses `m`, so the app must render
24
+ * it inside `LazyMotion`.
25
+ */
26
+ export function ImageLightbox({
27
+ images,
28
+ index,
29
+ onIndexChange,
30
+ onClose,
31
+ }: {
32
+ images: GalleryImage[];
33
+ /** `null` while closed. */
34
+ index: number | null;
35
+ onIndexChange: (next: number) => void;
36
+ onClose: () => void;
37
+ }) {
38
+ const offsetX = useMotionValue(0);
39
+ const draggingRef = useRef(false);
40
+ const startXRef = useRef(0);
41
+ const samplesRef = useRef<Array<{ x: number; t: number }>>([]);
42
+
43
+ const count = images.length;
44
+ const current = index === null ? null : images[index];
45
+
46
+ // Wrap-around so the user can circle through the set in either direction.
47
+ const step = (delta: number) => {
48
+ if (index === null || count === 0) return;
49
+ onIndexChange((index + delta + count) % count);
50
+ offsetX.set(0);
51
+ };
52
+
53
+ const sampleVelocity = (): number => {
54
+ const samples = samplesRef.current;
55
+ if (samples.length < 2) return 0;
56
+ const first = samples[0];
57
+ const last = samples[samples.length - 1];
58
+ if (first === undefined || last === undefined) return 0;
59
+ const dt = last.t - first.t;
60
+ if (dt <= 0) return 0;
61
+ return ((last.x - first.x) / dt) * 1000;
62
+ };
63
+
64
+ const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
65
+ if (count <= 1 || event.button !== 0) return;
66
+ if (
67
+ event.target instanceof Element &&
68
+ event.target.closest(CONTROL_SELECTOR) !== null
69
+ ) {
70
+ return;
71
+ }
72
+ event.currentTarget.setPointerCapture(event.pointerId);
73
+ draggingRef.current = true;
74
+ startXRef.current = event.clientX;
75
+ samplesRef.current = [{ x: event.clientX, t: performance.now() }];
76
+ offsetX.stop();
77
+ };
78
+
79
+ const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
80
+ if (!draggingRef.current) return;
81
+ const dx = event.clientX - startXRef.current;
82
+ offsetX.set(dx);
83
+ samplesRef.current = [
84
+ ...samplesRef.current.slice(-4),
85
+ { x: event.clientX, t: performance.now() },
86
+ ];
87
+ };
88
+
89
+ const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
90
+ if (!draggingRef.current) return;
91
+ draggingRef.current = false;
92
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
93
+ event.currentTarget.releasePointerCapture(event.pointerId);
94
+ }
95
+
96
+ const dx = offsetX.get();
97
+ const velocity = sampleVelocity();
98
+ const projected = dx + projectVelocity(velocity);
99
+ const goPrev = projected > 64 || velocity > 500;
100
+ const goNext = projected < -64 || velocity < -500;
101
+
102
+ if (goPrev || goNext) {
103
+ const target =
104
+ (goNext ? -1 : 1) * Math.max(280, window.innerWidth * 0.45);
105
+ void animate(offsetX, target, {
106
+ type: "spring",
107
+ bounce: 0,
108
+ duration: 0.35,
109
+ velocity,
110
+ }).then(() => step(goNext ? 1 : -1));
111
+ return;
112
+ }
113
+
114
+ void animate(offsetX, 0, {
115
+ type: "spring",
116
+ bounce: 0,
117
+ duration: 0.35,
118
+ velocity,
119
+ });
120
+ };
121
+
122
+ return (
123
+ <Dialog
124
+ open={index !== null}
125
+ onOpenChange={(open) => {
126
+ if (!open) {
127
+ offsetX.set(0);
128
+ onClose();
129
+ }
130
+ }}
131
+ >
132
+ <DialogContent
133
+ className="h-[92dvh] max-w-[96vw] overflow-hidden border-0 bg-black/90 p-0 shadow-none sm:max-w-[96vw]"
134
+ onKeyDown={(event) => {
135
+ if (event.key === "ArrowLeft") step(-1);
136
+ if (event.key === "ArrowRight") step(1);
137
+ }}
138
+ >
139
+ <DialogTitle className="sr-only">
140
+ {current?.alt ?? "Image"}
141
+ </DialogTitle>
142
+ {current ? (
143
+ <div
144
+ className="relative flex h-full w-full touch-pan-y items-center justify-center"
145
+ onPointerDown={onPointerDown}
146
+ onPointerMove={onPointerMove}
147
+ onPointerUp={onPointerUp}
148
+ onPointerCancel={onPointerUp}
149
+ >
150
+ <m.img
151
+ src={current.url}
152
+ alt={current.alt ?? "Image"}
153
+ draggable={false}
154
+ style={{ x: offsetX }}
155
+ className="max-h-full max-w-full select-none object-contain"
156
+ />
157
+ {count > 1 ? (
158
+ <>
159
+ <button
160
+ type="button"
161
+ aria-label="Previous image"
162
+ data-lightbox-control=""
163
+ onClick={() => step(-1)}
164
+ className="max-sm:hit-target absolute left-3 top-1/2 -translate-y-1/2 rounded-full border border-white/20 bg-black/50 p-2 text-white motion-press active:scale-[0.94] hover:bg-black/70"
165
+ >
166
+ <IconChevronLeft size={22} />
167
+ </button>
168
+ <button
169
+ type="button"
170
+ aria-label="Next image"
171
+ data-lightbox-control=""
172
+ onClick={() => step(1)}
173
+ className="max-sm:hit-target absolute right-3 top-1/2 -translate-y-1/2 rounded-full border border-white/20 bg-black/50 p-2 text-white motion-press active:scale-[0.94] hover:bg-black/70"
174
+ >
175
+ <IconChevronRight size={22} />
176
+ </button>
177
+ <span className="absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-3 py-1 text-xs tabular-nums text-white">
178
+ {index === null ? 1 : index + 1} / {count}
179
+ </span>
180
+ </>
181
+ ) : null}
182
+ <a
183
+ href={current.url}
184
+ target="_blank"
185
+ rel="noopener noreferrer"
186
+ data-lightbox-control=""
187
+ className="max-sm:hit-target absolute right-12 top-3 inline-flex items-center gap-1 rounded-md bg-black/60 px-2 py-1 text-xs text-white/80 transition-colors hover:text-white"
188
+ >
189
+ <IconExternalLink size={14} />
190
+ Open in new tab
191
+ </a>
192
+ </div>
193
+ ) : null}
194
+ </DialogContent>
195
+ </Dialog>
196
+ );
197
+ }
@@ -0,0 +1,80 @@
1
+ import { useEffect, useRef, type ReactNode } from "react";
2
+ import { useCn } from "./ui-config";
3
+
4
+ /** Pixels per second the revealed text travels: constant speed, so long labels do not whip by. */
5
+ const MARQUEE_SPEED_PX_PER_S = 60;
6
+ /** Clamp the scroll duration so very short and very long overflows both stay readable. */
7
+ const MIN_DURATION_S = 0.5;
8
+ const MAX_DURATION_S = 5;
9
+
10
+ interface MarqueeOnHoverProps {
11
+ children: ReactNode;
12
+ className?: string;
13
+ }
14
+
15
+ /**
16
+ * Single-line label that scrolls its hidden tail into view on hover ("reveal
17
+ * and hold") and shows a plain ellipsis the rest of the time. It moves only
18
+ * when the text overflows. Needs `@vvedantb/ui/tokens/css/marquee.css`.
19
+ *
20
+ * Two spans: the outer `mq-host` clips and the inner `mq-inner` translates.
21
+ * One element cannot both ellipsis-clip itself and translate its own overflow
22
+ * into view. Hover is pure CSS; this component only measures the overflow on
23
+ * layout changes and writes it to custom properties, so hovering never
24
+ * re-renders. Text styles go on `className` (the host) and are inherited; drop
25
+ * any `truncate` / `line-clamp` class from the caller.
26
+ */
27
+ export function MarqueeOnHover({ children, className }: MarqueeOnHoverProps) {
28
+ const cn = useCn();
29
+ const hostRef = useRef<HTMLSpanElement>(null);
30
+ const innerRef = useRef<HTMLSpanElement>(null);
31
+
32
+ // Re-measure after a content change, a host resize or the web font swap,
33
+ // since each changes how much (if at all) the text overflows.
34
+ useEffect(() => {
35
+ const host = hostRef.current;
36
+ const inner = innerRef.current;
37
+ if (!host || !inner) return;
38
+
39
+ let cancelled = false;
40
+ let raf = 0;
41
+ const recompute = () => {
42
+ if (cancelled) return;
43
+ cancelAnimationFrame(raf);
44
+ raf = requestAnimationFrame(() => {
45
+ const shift = Math.max(0, inner.scrollWidth - host.clientWidth);
46
+ const overflows = shift > 1; // 1px guard against sub-pixel rounding jitter
47
+ host.dataset.mqOverflow = overflows ? "true" : "false";
48
+ // Native tooltip is the touch / long-hover fallback, only when truncated.
49
+ host.title = overflows ? (inner.textContent ?? "") : "";
50
+ if (overflows) {
51
+ const duration = Math.min(
52
+ MAX_DURATION_S,
53
+ Math.max(MIN_DURATION_S, shift / MARQUEE_SPEED_PX_PER_S),
54
+ );
55
+ host.style.setProperty("--mq-shift", `${shift}px`);
56
+ host.style.setProperty("--mq-dur", `${duration}s`);
57
+ }
58
+ });
59
+ };
60
+
61
+ recompute();
62
+ const observer = new ResizeObserver(recompute);
63
+ observer.observe(host);
64
+ void document.fonts.ready.then(() => recompute());
65
+
66
+ return () => {
67
+ cancelled = true;
68
+ cancelAnimationFrame(raf);
69
+ observer.disconnect();
70
+ };
71
+ }, [children]);
72
+
73
+ return (
74
+ <span ref={hostRef} className={cn("mq-host", className)}>
75
+ <span ref={innerRef} className="mq-inner">
76
+ {children}
77
+ </span>
78
+ </span>
79
+ );
80
+ }
@@ -0,0 +1,173 @@
1
+ import { useState } from "react";
2
+ import {
3
+ VideoPlayer,
4
+ VideoPlayerContent,
5
+ VideoPlayerControlBar,
6
+ VideoPlayerFullscreenButton,
7
+ VideoPlayerMuteButton,
8
+ VideoPlayerPlayButton,
9
+ VideoPlayerPlaybackRateButton,
10
+ VideoPlayerSeekBackwardButton,
11
+ VideoPlayerSeekForwardButton,
12
+ VideoPlayerTimeDisplay,
13
+ VideoPlayerTimeRange,
14
+ VideoPlayerVolumeRange,
15
+ } from "../kibo/video-player";
16
+ import { ImageLightbox, type GalleryImage } from "./image-lightbox";
17
+ import { useCn } from "./ui-config";
18
+
19
+ /** Hairline ring that separates media from any surface, in light and dark. */
20
+ const MEDIA_OUTLINE = "ring-1 ring-black/10 dark:ring-white/10";
21
+
22
+ /**
23
+ * Twitter-style image gallery: up to a 2x2 grid of cropped
24
+ * tiles (a "+N" overlay on the fourth when more exist) instead of a long
25
+ * stack, opening `ImageLightbox`. Videos are rendered separately
26
+ * (VideoPreview) — this is images only.
27
+ */
28
+ export function ImageGalleryPreview({ images }: { images: GalleryImage[] }) {
29
+ const cn = useCn();
30
+ const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
31
+
32
+ const count = images.length;
33
+ if (count === 0) return null;
34
+
35
+ const tiles = images.slice(0, 4);
36
+ const overflow = count - tiles.length;
37
+
38
+ return (
39
+ <>
40
+ {count === 1 ? (
41
+ <button
42
+ type="button"
43
+ onClick={() => setLightboxIndex(0)}
44
+ className="block max-w-lg motion-press active:scale-[0.99]"
45
+ >
46
+ <img
47
+ src={images[0]?.url}
48
+ alt={images[0]?.alt ?? "Image"}
49
+ loading="lazy"
50
+ className={`${MEDIA_OUTLINE} rounded-surface max-h-96 max-w-full cursor-pointer transition-opacity hover:opacity-90`}
51
+ />
52
+ </button>
53
+ ) : (
54
+ <div
55
+ className={cn(
56
+ MEDIA_OUTLINE,
57
+ "rounded-surface grid max-w-lg gap-0.5 overflow-hidden",
58
+ "grid-cols-2",
59
+ )}
60
+ >
61
+ {tiles.map((image, index) => (
62
+ <button
63
+ key={index}
64
+ type="button"
65
+ onClick={() => setLightboxIndex(index)}
66
+ className={cn(
67
+ "relative block cursor-pointer motion-press active:scale-[0.99]",
68
+ // Three images: first tile spans the left column, Twitter-style.
69
+ count === 3 && index === 0 ? "row-span-2 h-full" : undefined,
70
+ )}
71
+ >
72
+ <img
73
+ src={image.url}
74
+ alt={image.alt ?? `Image ${index + 1}`}
75
+ loading="lazy"
76
+ className={cn(
77
+ "h-full w-full object-cover transition-opacity hover:opacity-90",
78
+ count === 3 && index === 0 ? "min-h-full" : "aspect-4/3",
79
+ )}
80
+ />
81
+ {overflow > 0 && index === tiles.length - 1 ? (
82
+ <span className="absolute inset-0 flex items-center justify-center bg-black/60 text-xl font-semibold text-white">
83
+ +{overflow}
84
+ </span>
85
+ ) : null}
86
+ </button>
87
+ ))}
88
+ </div>
89
+ )}
90
+
91
+ <ImageLightbox
92
+ images={images}
93
+ index={lightboxIndex}
94
+ onIndexChange={setLightboxIndex}
95
+ onClose={() => setLightboxIndex(null)}
96
+ />
97
+ </>
98
+ );
99
+ }
100
+
101
+ /** Video with the full control bar; rate and volume drop out below `sm`. */
102
+ export function VideoPreview({
103
+ url,
104
+ className,
105
+ }: {
106
+ url: string;
107
+ className?: string;
108
+ }) {
109
+ const cn = useCn();
110
+ return (
111
+ <VideoPlayer className={cn("max-w-full", className)}>
112
+ <VideoPlayerContent
113
+ src={url}
114
+ playsInline
115
+ preload="metadata"
116
+ slot="media"
117
+ />
118
+ {/* Nine controls do not fit a phone-width bar. Playback rate and the
119
+ volume slider drop out below `sm` — a phone has hardware volume keys,
120
+ and everything essential (play, scrub, time, mute, fullscreen) stays. */}
121
+ <VideoPlayerControlBar>
122
+ <VideoPlayerPlayButton />
123
+ <VideoPlayerSeekBackwardButton />
124
+ <VideoPlayerSeekForwardButton />
125
+ <VideoPlayerTimeRange />
126
+ <VideoPlayerTimeDisplay showDuration />
127
+ <VideoPlayerPlaybackRateButton
128
+ rates={[1, 3, 5, 8]}
129
+ className="max-sm:hidden"
130
+ />
131
+ <VideoPlayerMuteButton />
132
+ <VideoPlayerVolumeRange className="max-sm:hidden" />
133
+ <VideoPlayerFullscreenButton />
134
+ </VideoPlayerControlBar>
135
+ </VideoPlayer>
136
+ );
137
+ }
138
+
139
+ /** One image that opens `ImageLightbox` on click. */
140
+ export function ScreenshotPreview({
141
+ url,
142
+ alt = "Screenshot",
143
+ className,
144
+ }: {
145
+ url: string;
146
+ alt?: string;
147
+ className?: string;
148
+ }) {
149
+ const cn = useCn();
150
+ const [open, setOpen] = useState(false);
151
+
152
+ return (
153
+ <>
154
+ <button type="button" onClick={() => setOpen(true)} className="block">
155
+ <img
156
+ src={url}
157
+ alt={alt}
158
+ className={cn(
159
+ MEDIA_OUTLINE,
160
+ "rounded-surface max-w-full cursor-pointer transition-opacity hover:opacity-90",
161
+ className,
162
+ )}
163
+ />
164
+ </button>
165
+ <ImageLightbox
166
+ images={[{ url, alt }]}
167
+ index={open ? 0 : null}
168
+ onIndexChange={() => undefined}
169
+ onClose={() => setOpen(false)}
170
+ />
171
+ </>
172
+ );
173
+ }
@@ -0,0 +1,42 @@
1
+ import { formatCompactRelativeTime } from "../../app/src/time/compactRelativeTime";
2
+ import dayjs from "../utils/dayjs";
3
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
4
+ import { useCn } from "./ui-config";
5
+
6
+ export interface RelativeDateTimeProps {
7
+ /** Unix ms. When omitted, renders `emptyLabel` with no tooltip. */
8
+ at?: number;
9
+ /** Shown when `at` is missing. Default `"—"`. */
10
+ emptyLabel?: string;
11
+ /** dayjs format for the tooltip. Default `"DD/MM/YYYY HH:mm"`. */
12
+ exactFormat?: string;
13
+ className?: string;
14
+ }
15
+
16
+ /**
17
+ * Compact relative time ("15m", "2d", calendar-aware like `dayjs().diff`) with
18
+ * the exact date and time in a hover tooltip. Needs a `TooltipProvider` above.
19
+ * It does not tick: the parent re-renders it (e.g. with a shared clock).
20
+ */
21
+ export function RelativeDateTime({
22
+ at,
23
+ emptyLabel = "—",
24
+ exactFormat = "DD/MM/YYYY HH:mm",
25
+ className,
26
+ }: RelativeDateTimeProps) {
27
+ const cn = useCn();
28
+ if (at === undefined) {
29
+ return <span className={cn("text-muted-foreground", className)}>{emptyLabel}</span>;
30
+ }
31
+
32
+ return (
33
+ <Tooltip>
34
+ <TooltipTrigger asChild>
35
+ <span className={cn("cursor-default text-muted-foreground tabular-nums", className)}>
36
+ {formatCompactRelativeTime(at, { calendar: true })}
37
+ </span>
38
+ </TooltipTrigger>
39
+ <TooltipContent>{dayjs(at).format(exactFormat)}</TooltipContent>
40
+ </Tooltip>
41
+ );
42
+ }