@boyernick/standard-ui-react 0.1.1-canary.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/src/accordion.tsx +5 -2
- package/src/alert-dialog.tsx +1 -1
- package/src/attachment.tsx +5 -2
- package/src/autocomplete.tsx +4 -3
- package/src/block-editor.tsx +1747 -0
- package/src/brand.tsx +2 -2
- package/src/breadcrumb.tsx +14 -7
- package/src/button.tsx +22 -11
- package/src/calendar.tsx +6 -3
- package/src/carousel.tsx +220 -53
- package/src/checkbox.tsx +3 -2
- package/src/code-block.tsx +66 -5
- package/src/collapsible.tsx +4 -2
- package/src/combobox.tsx +4 -3
- package/src/command.tsx +27 -12
- package/src/dialog.tsx +33 -10
- package/src/empty.tsx +4 -4
- package/src/field.tsx +4 -3
- package/src/filter-group.tsx +143 -0
- package/src/icons.tsx +28 -1
- package/src/illustrations.tsx +219 -141
- package/src/index.ts +238 -18
- package/src/input.tsx +8 -5
- package/src/kbd.tsx +10 -4
- package/src/lib/focus.ts +41 -0
- package/src/lib/popup.ts +1 -1
- package/src/lifeline/company-icon.tsx +71 -0
- package/src/lifeline/index.ts +42 -0
- package/src/lifeline/lifeline-data.ts +97 -0
- package/src/lifeline/lifeline-desktop.tsx +204 -0
- package/src/lifeline/lifeline-event.tsx +108 -0
- package/src/lifeline/lifeline-fireworks.tsx +302 -0
- package/src/lifeline/lifeline-hover-image.tsx +269 -0
- package/src/lifeline/lifeline-icons.tsx +39 -0
- package/src/lifeline/lifeline-intro-timing.ts +105 -0
- package/src/lifeline/lifeline-labels.tsx +24 -0
- package/src/lifeline/lifeline-layout.ts +1 -0
- package/src/lifeline/lifeline-legend.tsx +31 -0
- package/src/lifeline/lifeline-lightbox.tsx +309 -0
- package/src/lifeline/lifeline-marker.tsx +183 -0
- package/src/lifeline/lifeline-people.tsx +99 -0
- package/src/lifeline/lifeline-photos.tsx +366 -0
- package/src/lifeline/lifeline-shell.tsx +135 -0
- package/src/lifeline/lifeline-utils.ts +83 -0
- package/src/lifeline/lifeline-vertical.tsx +482 -0
- package/src/lifeline/lifeline.tsx +69 -0
- package/src/lifeline/types.ts +112 -0
- package/src/lifeline/use-lifeline-intro.ts +130 -0
- package/src/lifeline/use-lifeline-scroll.ts +1011 -0
- package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
- package/src/menubar.tsx +9 -2
- package/src/minimap.tsx +296 -0
- package/src/modal.tsx +608 -0
- package/src/navigation-menu.tsx +338 -67
- package/src/number-field.tsx +336 -61
- package/src/otp-field.tsx +4 -3
- package/src/pagination.tsx +262 -42
- package/src/password-protection.tsx +345 -0
- package/src/popover.tsx +1 -1
- package/src/progress.tsx +5 -0
- package/src/questionnaire.tsx +284 -0
- package/src/radio.tsx +4 -2
- package/src/scroll-area.tsx +4 -1
- package/src/select.tsx +5 -2
- package/src/sidebar.tsx +113 -28
- package/src/slider.tsx +66 -6
- package/src/sounds.tsx +6 -10
- package/src/spinner.tsx +105 -32
- package/src/switch.tsx +4 -1
- package/src/table.tsx +82 -15
- package/src/tabs.tsx +189 -38
- package/src/text-animate.tsx +71 -28
- package/src/textarea.tsx +6 -1
- package/src/timeline.tsx +378 -0
- package/src/toast.tsx +214 -35
- package/src/toggle.tsx +4 -2
- package/src/toolbar.tsx +12 -7
- package/src/tooltip.tsx +43 -3
- package/src/video-player.tsx +396 -127
- package/src/image-modal.tsx +0 -110
- package/src/markdown-editor.tsx +0 -302
package/src/modal.tsx
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
4
|
+
import {
|
|
5
|
+
createContext,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
type ComponentProps,
|
|
12
|
+
type ImgHTMLAttributes,
|
|
13
|
+
type KeyboardEvent,
|
|
14
|
+
type ReactElement,
|
|
15
|
+
type ReactNode,
|
|
16
|
+
} from "react"
|
|
17
|
+
import { Button, buttonVariants } from "./button"
|
|
18
|
+
import {
|
|
19
|
+
Carousel,
|
|
20
|
+
CarouselContent,
|
|
21
|
+
CarouselItem,
|
|
22
|
+
type CarouselApi,
|
|
23
|
+
} from "./carousel"
|
|
24
|
+
import {
|
|
25
|
+
Dialog,
|
|
26
|
+
DialogBackdrop,
|
|
27
|
+
DialogClose,
|
|
28
|
+
DialogPopup,
|
|
29
|
+
DialogPortal,
|
|
30
|
+
DialogTitle,
|
|
31
|
+
DialogTrigger,
|
|
32
|
+
} from "./dialog"
|
|
33
|
+
import {
|
|
34
|
+
IconArrowDown,
|
|
35
|
+
IconChevronRightSmall,
|
|
36
|
+
IconCrossSmall,
|
|
37
|
+
} from "./icons"
|
|
38
|
+
import { cn } from "./lib/cn"
|
|
39
|
+
import { focusRing, focusRingBorder } from "./lib/focus"
|
|
40
|
+
import { motion } from "./lib/motion"
|
|
41
|
+
import {
|
|
42
|
+
Tooltip,
|
|
43
|
+
TooltipPopup,
|
|
44
|
+
TooltipPortal,
|
|
45
|
+
TooltipPositioner,
|
|
46
|
+
TooltipProvider,
|
|
47
|
+
TooltipTrigger,
|
|
48
|
+
type TooltipPositionerProps,
|
|
49
|
+
} from "./tooltip"
|
|
50
|
+
|
|
51
|
+
const TRACKPAD_LABEL =
|
|
52
|
+
"Swipe, use the arrow keys, or use the controls to navigate"
|
|
53
|
+
|
|
54
|
+
const modalControlClassName =
|
|
55
|
+
"border-border-inverted bg-surface-inverted/60 text-fg-inverted shadow-md backdrop-blur-md hover:bg-surface-inverted/80 hover:text-fg-inverted outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-surface-inverted focus-visible:ring-ring/20"
|
|
56
|
+
|
|
57
|
+
export const modalContentVariants = cva(
|
|
58
|
+
"h-dvh max-h-dvh w-screen max-w-none gap-0 overflow-hidden rounded-none border-0 bg-transparent p-0 shadow-none max-sm:max-w-none",
|
|
59
|
+
{
|
|
60
|
+
variants: {
|
|
61
|
+
variant: {
|
|
62
|
+
default: "",
|
|
63
|
+
caption: "",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
defaultVariants: {
|
|
67
|
+
variant: "default",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
export type ModalProps = ComponentProps<typeof Dialog>
|
|
73
|
+
export type ModalTriggerProps = ComponentProps<typeof DialogTrigger>
|
|
74
|
+
export type ModalContentProps = Omit<
|
|
75
|
+
ComponentProps<typeof DialogPopup>,
|
|
76
|
+
"children"
|
|
77
|
+
> &
|
|
78
|
+
VariantProps<typeof modalContentVariants> & {
|
|
79
|
+
src: string
|
|
80
|
+
alt: string
|
|
81
|
+
caption?: ReactNode
|
|
82
|
+
downloadSrc?: string
|
|
83
|
+
downloadName?: string
|
|
84
|
+
imgProps?: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "alt">
|
|
85
|
+
children?: ReactNode
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type GalleryItem = {
|
|
89
|
+
src: string
|
|
90
|
+
alt: string
|
|
91
|
+
caption?: ReactNode
|
|
92
|
+
downloadSrc?: string
|
|
93
|
+
downloadName?: string
|
|
94
|
+
imgProps?: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "alt">
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type GalleryProps = Omit<ModalProps, "children"> &
|
|
98
|
+
VariantProps<typeof modalContentVariants> & {
|
|
99
|
+
images: readonly GalleryItem[]
|
|
100
|
+
children: ReactNode
|
|
101
|
+
loop?: boolean
|
|
102
|
+
contentClassName?: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type GalleryTriggerProps = ModalTriggerProps & {
|
|
106
|
+
index: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type ModalTriggerClickEvent = Parameters<
|
|
110
|
+
NonNullable<ModalTriggerProps["onClick"]>
|
|
111
|
+
>[0]
|
|
112
|
+
|
|
113
|
+
type GalleryContextValue = {
|
|
114
|
+
images: readonly GalleryItem[]
|
|
115
|
+
setActiveIndex: (index: number) => void
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const GalleryContext =
|
|
119
|
+
createContext<GalleryContextValue | null>(null)
|
|
120
|
+
|
|
121
|
+
const useGallery = () => {
|
|
122
|
+
const context = useContext(GalleryContext)
|
|
123
|
+
if (!context) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
"GalleryTrigger must be used within <Gallery>",
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
return context
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const Modal = (props: ModalProps) => <Dialog {...props} />
|
|
132
|
+
|
|
133
|
+
export const ModalTrigger = ({
|
|
134
|
+
className,
|
|
135
|
+
...props
|
|
136
|
+
}: ModalTriggerProps) => (
|
|
137
|
+
<DialogTrigger
|
|
138
|
+
className={cn(
|
|
139
|
+
"cursor-pointer rounded-xl",
|
|
140
|
+
focusRingBorder,
|
|
141
|
+
focusRing,
|
|
142
|
+
className,
|
|
143
|
+
)}
|
|
144
|
+
{...props}
|
|
145
|
+
/>
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
const ModalBackdrop = () => (
|
|
149
|
+
<DialogBackdrop className="bg-surface-inverted/95" />
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
const ModalBackground = ({ src }: { src: string }) => (
|
|
153
|
+
<div
|
|
154
|
+
aria-hidden="true"
|
|
155
|
+
className="pointer-events-none absolute inset-0 overflow-hidden"
|
|
156
|
+
>
|
|
157
|
+
{/* Consumers may use any image host, so this package deliberately renders
|
|
158
|
+
the platform-neutral image element rather than Next.js Image. */}
|
|
159
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
160
|
+
<img
|
|
161
|
+
src={src}
|
|
162
|
+
alt=""
|
|
163
|
+
className="size-full scale-110 object-cover opacity-25 blur-3xl"
|
|
164
|
+
/>
|
|
165
|
+
<div className="absolute inset-0 bg-surface-inverted/65" />
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
const ModalImage = ({
|
|
170
|
+
src,
|
|
171
|
+
alt,
|
|
172
|
+
imgProps,
|
|
173
|
+
fit = "viewport",
|
|
174
|
+
}: Pick<GalleryItem, "src" | "alt" | "imgProps"> & {
|
|
175
|
+
fit?: "viewport" | "contain"
|
|
176
|
+
}) => {
|
|
177
|
+
const { className, ...props } = imgProps ?? {}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
181
|
+
<img
|
|
182
|
+
src={src}
|
|
183
|
+
alt={alt}
|
|
184
|
+
draggable={false}
|
|
185
|
+
className={cn(
|
|
186
|
+
"rounded-lg object-contain shadow-lg select-none",
|
|
187
|
+
fit === "contain"
|
|
188
|
+
? "max-h-full max-w-full"
|
|
189
|
+
: "max-h-[82dvh] max-w-[88vw] max-sm:max-h-[calc(100dvh-2rem)] max-sm:max-w-[calc(100vw-2rem)]",
|
|
190
|
+
className,
|
|
191
|
+
)}
|
|
192
|
+
{...props}
|
|
193
|
+
/>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const ModalControlTooltip = ({
|
|
198
|
+
label,
|
|
199
|
+
side = "bottom",
|
|
200
|
+
children,
|
|
201
|
+
}: {
|
|
202
|
+
label: string
|
|
203
|
+
side?: TooltipPositionerProps["side"]
|
|
204
|
+
children: ReactElement
|
|
205
|
+
}) => (
|
|
206
|
+
<Tooltip>
|
|
207
|
+
<TooltipTrigger render={children} />
|
|
208
|
+
<TooltipPortal>
|
|
209
|
+
<TooltipPositioner side={side}>
|
|
210
|
+
<TooltipPopup variant="inverted">{label}</TooltipPopup>
|
|
211
|
+
</TooltipPositioner>
|
|
212
|
+
</TooltipPortal>
|
|
213
|
+
</Tooltip>
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
const ModalDownload = ({
|
|
217
|
+
src,
|
|
218
|
+
name,
|
|
219
|
+
}: {
|
|
220
|
+
src: string
|
|
221
|
+
name?: string
|
|
222
|
+
}) => (
|
|
223
|
+
<ModalControlTooltip label="Download image">
|
|
224
|
+
<a
|
|
225
|
+
href={src}
|
|
226
|
+
download={name ?? ""}
|
|
227
|
+
aria-label="Download image"
|
|
228
|
+
className={cn(
|
|
229
|
+
buttonVariants({
|
|
230
|
+
variant: "ghost",
|
|
231
|
+
size: "md",
|
|
232
|
+
rounded: true,
|
|
233
|
+
iconOnly: true,
|
|
234
|
+
}),
|
|
235
|
+
modalControlClassName,
|
|
236
|
+
"absolute top-4 right-4 z-20",
|
|
237
|
+
)}
|
|
238
|
+
>
|
|
239
|
+
<IconArrowDown aria-hidden />
|
|
240
|
+
</a>
|
|
241
|
+
</ModalControlTooltip>
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
const ModalDismiss = () => (
|
|
245
|
+
<ModalControlTooltip label="Close image">
|
|
246
|
+
<DialogClose
|
|
247
|
+
render={
|
|
248
|
+
<Button
|
|
249
|
+
type="button"
|
|
250
|
+
variant="ghost"
|
|
251
|
+
size="md"
|
|
252
|
+
iconOnly
|
|
253
|
+
rounded
|
|
254
|
+
aria-label="Close image"
|
|
255
|
+
className={cn(
|
|
256
|
+
modalControlClassName,
|
|
257
|
+
"absolute top-4 left-4 z-20",
|
|
258
|
+
)}
|
|
259
|
+
/>
|
|
260
|
+
}
|
|
261
|
+
>
|
|
262
|
+
<IconCrossSmall aria-hidden />
|
|
263
|
+
</DialogClose>
|
|
264
|
+
</ModalControlTooltip>
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
const ModalCaption = ({ children }: { children: ReactNode }) => (
|
|
268
|
+
<p className="mt-3 max-w-[min(40rem,88vw)] shrink-0 px-4 text-center text-sm text-fg-inverted-secondary">
|
|
269
|
+
{children}
|
|
270
|
+
</p>
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
export const ModalContent = ({
|
|
274
|
+
src,
|
|
275
|
+
alt,
|
|
276
|
+
caption,
|
|
277
|
+
downloadSrc,
|
|
278
|
+
downloadName,
|
|
279
|
+
variant = "default",
|
|
280
|
+
className,
|
|
281
|
+
imgProps,
|
|
282
|
+
children,
|
|
283
|
+
...props
|
|
284
|
+
}: ModalContentProps) => (
|
|
285
|
+
<DialogPortal>
|
|
286
|
+
<ModalBackdrop />
|
|
287
|
+
<DialogPopup
|
|
288
|
+
data-slot="modal-content"
|
|
289
|
+
className={cn(
|
|
290
|
+
modalContentVariants({ variant }),
|
|
291
|
+
motion.popupCenter,
|
|
292
|
+
className,
|
|
293
|
+
)}
|
|
294
|
+
{...props}
|
|
295
|
+
>
|
|
296
|
+
<TooltipProvider delay={250}>
|
|
297
|
+
<ModalBackground src={src} />
|
|
298
|
+
<DialogTitle className="sr-only">{alt}</DialogTitle>
|
|
299
|
+
<div
|
|
300
|
+
data-slot="modal-stage"
|
|
301
|
+
className="relative z-10 flex size-full flex-col items-center justify-center p-4"
|
|
302
|
+
>
|
|
303
|
+
<ModalImage src={src} alt={alt} imgProps={imgProps} />
|
|
304
|
+
{variant === "caption" && (caption || children) ? (
|
|
305
|
+
<ModalCaption>{caption ?? children}</ModalCaption>
|
|
306
|
+
) : null}
|
|
307
|
+
</div>
|
|
308
|
+
<ModalDismiss />
|
|
309
|
+
<ModalDownload
|
|
310
|
+
src={downloadSrc ?? src}
|
|
311
|
+
name={downloadName}
|
|
312
|
+
/>
|
|
313
|
+
</TooltipProvider>
|
|
314
|
+
</DialogPopup>
|
|
315
|
+
</DialogPortal>
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
export const GalleryTrigger = ({
|
|
319
|
+
index,
|
|
320
|
+
onClick,
|
|
321
|
+
...props
|
|
322
|
+
}: GalleryTriggerProps) => {
|
|
323
|
+
const { images, setActiveIndex } = useGallery()
|
|
324
|
+
|
|
325
|
+
const handleClick = (event: ModalTriggerClickEvent) => {
|
|
326
|
+
onClick?.(event)
|
|
327
|
+
if (!event.defaultPrevented && images[index]) setActiveIndex(index)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return <ModalTrigger {...props} onClick={handleClick} />
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const GalleryFilmstrip = ({
|
|
334
|
+
images,
|
|
335
|
+
activeIndex,
|
|
336
|
+
onSelect,
|
|
337
|
+
}: {
|
|
338
|
+
images: readonly GalleryItem[]
|
|
339
|
+
activeIndex: number
|
|
340
|
+
onSelect: (index: number) => void
|
|
341
|
+
}) => {
|
|
342
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
343
|
+
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
const list = listRef.current
|
|
346
|
+
if (!list) return
|
|
347
|
+
const active = list.querySelector<HTMLElement>('[aria-selected="true"]')
|
|
348
|
+
active?.scrollIntoView({
|
|
349
|
+
behavior: "smooth",
|
|
350
|
+
block: "nearest",
|
|
351
|
+
inline: "center",
|
|
352
|
+
})
|
|
353
|
+
}, [activeIndex])
|
|
354
|
+
|
|
355
|
+
if (images.length < 2) return null
|
|
356
|
+
|
|
357
|
+
return (
|
|
358
|
+
<div
|
|
359
|
+
data-slot="modal-filmstrip"
|
|
360
|
+
className="relative z-10 w-full shrink-0 overflow-visible"
|
|
361
|
+
>
|
|
362
|
+
<div
|
|
363
|
+
ref={listRef}
|
|
364
|
+
role="tablist"
|
|
365
|
+
aria-label="Gallery images"
|
|
366
|
+
className="flex w-full justify-center gap-2 overflow-x-auto p-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
367
|
+
>
|
|
368
|
+
{images.map((image, index) => {
|
|
369
|
+
const isActive = index === activeIndex
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<button
|
|
373
|
+
key={`${image.src}-${index}`}
|
|
374
|
+
type="button"
|
|
375
|
+
role="tab"
|
|
376
|
+
aria-selected={isActive}
|
|
377
|
+
aria-label={`Show image ${index + 1}: ${image.alt}`}
|
|
378
|
+
onClick={() => onSelect(index)}
|
|
379
|
+
className={cn(
|
|
380
|
+
"size-14 shrink-0 cursor-pointer overflow-hidden rounded-md outline-none",
|
|
381
|
+
"transition-[opacity,box-shadow] duration-[var(--duration-sm)] ease-enter motion-reduce:transition-none",
|
|
382
|
+
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-surface-inverted focus-visible:ring-ring/20",
|
|
383
|
+
focusRingBorder,
|
|
384
|
+
isActive
|
|
385
|
+
? "opacity-100 ring-2 ring-fg-inverted"
|
|
386
|
+
: "opacity-45 hover:opacity-80",
|
|
387
|
+
)}
|
|
388
|
+
>
|
|
389
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
390
|
+
<img
|
|
391
|
+
src={image.src}
|
|
392
|
+
alt=""
|
|
393
|
+
draggable={false}
|
|
394
|
+
className="size-full object-cover"
|
|
395
|
+
/>
|
|
396
|
+
</button>
|
|
397
|
+
)
|
|
398
|
+
})}
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const GalleryContent = ({
|
|
405
|
+
images,
|
|
406
|
+
activeIndex,
|
|
407
|
+
setActiveIndex,
|
|
408
|
+
loop,
|
|
409
|
+
variant,
|
|
410
|
+
className,
|
|
411
|
+
}: {
|
|
412
|
+
images: readonly GalleryItem[]
|
|
413
|
+
activeIndex: number
|
|
414
|
+
setActiveIndex: (index: number) => void
|
|
415
|
+
loop: boolean
|
|
416
|
+
variant: "default" | "caption"
|
|
417
|
+
className?: string
|
|
418
|
+
}) => {
|
|
419
|
+
const [api, setApi] = useState<CarouselApi>()
|
|
420
|
+
const activeImage = images[activeIndex]
|
|
421
|
+
|
|
422
|
+
useEffect(() => {
|
|
423
|
+
api?.scrollTo(activeIndex, true)
|
|
424
|
+
}, [activeIndex, api])
|
|
425
|
+
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
if (!api) return
|
|
428
|
+
const syncIndex = () => setActiveIndex(api.selectedScrollSnap())
|
|
429
|
+
api.on("select", syncIndex)
|
|
430
|
+
api.on("reInit", syncIndex)
|
|
431
|
+
return () => {
|
|
432
|
+
api.off("select", syncIndex)
|
|
433
|
+
api.off("reInit", syncIndex)
|
|
434
|
+
}
|
|
435
|
+
}, [api, setActiveIndex])
|
|
436
|
+
|
|
437
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
438
|
+
if (event.key === "ArrowLeft") {
|
|
439
|
+
event.preventDefault()
|
|
440
|
+
api?.scrollPrev()
|
|
441
|
+
} else if (event.key === "ArrowRight") {
|
|
442
|
+
event.preventDefault()
|
|
443
|
+
api?.scrollNext()
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const handleSelect = (index: number) => {
|
|
448
|
+
setActiveIndex(index)
|
|
449
|
+
api?.scrollTo(index)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (!activeImage) return null
|
|
453
|
+
|
|
454
|
+
return (
|
|
455
|
+
<DialogPortal>
|
|
456
|
+
<ModalBackdrop />
|
|
457
|
+
<DialogPopup
|
|
458
|
+
data-slot="modal-gallery-content"
|
|
459
|
+
aria-describedby={undefined}
|
|
460
|
+
onKeyDownCapture={handleKeyDown}
|
|
461
|
+
className={cn(
|
|
462
|
+
modalContentVariants({ variant }),
|
|
463
|
+
motion.popupCenter,
|
|
464
|
+
className,
|
|
465
|
+
)}
|
|
466
|
+
>
|
|
467
|
+
<TooltipProvider delay={250}>
|
|
468
|
+
<ModalBackground src={activeImage.src} />
|
|
469
|
+
<DialogTitle className="sr-only">{activeImage.alt}</DialogTitle>
|
|
470
|
+
<p className="sr-only" aria-live="polite">
|
|
471
|
+
Image {activeIndex + 1} of {images.length}. {TRACKPAD_LABEL}.
|
|
472
|
+
</p>
|
|
473
|
+
<div className="relative z-10 flex size-full flex-col gap-4 p-4">
|
|
474
|
+
<Carousel
|
|
475
|
+
setApi={setApi}
|
|
476
|
+
opts={{ loop, startIndex: activeIndex, duration: 24, dragFree: false }}
|
|
477
|
+
data-slot="modal-stage"
|
|
478
|
+
className="relative min-h-0 w-full min-w-0 flex-1 [&_[data-slot=carousel-viewport]]:h-full [&_[data-slot=carousel-viewport]]:min-h-0"
|
|
479
|
+
aria-label="Gallery"
|
|
480
|
+
>
|
|
481
|
+
<CarouselContent className="h-full !ml-0 !pr-0">
|
|
482
|
+
{images.map((image, index) => (
|
|
483
|
+
<CarouselItem
|
|
484
|
+
key={`${image.src}-${index}`}
|
|
485
|
+
className="!basis-full flex h-full min-h-0 flex-col items-center justify-center !pl-0"
|
|
486
|
+
aria-label={`${index + 1} of ${images.length}`}
|
|
487
|
+
>
|
|
488
|
+
<div className="flex max-h-full min-h-0 w-full flex-col items-center justify-center">
|
|
489
|
+
<div className="flex min-h-0 w-full items-center justify-center overflow-hidden">
|
|
490
|
+
<ModalImage
|
|
491
|
+
{...image}
|
|
492
|
+
fit="contain"
|
|
493
|
+
imgProps={{
|
|
494
|
+
...image.imgProps,
|
|
495
|
+
className: cn(
|
|
496
|
+
"max-w-[min(94vw,72rem)]",
|
|
497
|
+
image.imgProps?.className,
|
|
498
|
+
),
|
|
499
|
+
}}
|
|
500
|
+
/>
|
|
501
|
+
</div>
|
|
502
|
+
{variant === "caption" && image.caption ? (
|
|
503
|
+
<ModalCaption>{image.caption}</ModalCaption>
|
|
504
|
+
) : null}
|
|
505
|
+
</div>
|
|
506
|
+
</CarouselItem>
|
|
507
|
+
))}
|
|
508
|
+
</CarouselContent>
|
|
509
|
+
</Carousel>
|
|
510
|
+
<GalleryFilmstrip
|
|
511
|
+
images={images}
|
|
512
|
+
activeIndex={activeIndex}
|
|
513
|
+
onSelect={handleSelect}
|
|
514
|
+
/>
|
|
515
|
+
</div>
|
|
516
|
+
<ModalDismiss />
|
|
517
|
+
<ModalDownload
|
|
518
|
+
src={activeImage.downloadSrc ?? activeImage.src}
|
|
519
|
+
name={activeImage.downloadName}
|
|
520
|
+
/>
|
|
521
|
+
{images.length > 1 ? (
|
|
522
|
+
<>
|
|
523
|
+
<ModalControlTooltip label="Previous image" side="right">
|
|
524
|
+
<Button
|
|
525
|
+
type="button"
|
|
526
|
+
variant="ghost"
|
|
527
|
+
size="lg"
|
|
528
|
+
iconOnly
|
|
529
|
+
rounded
|
|
530
|
+
aria-label="Previous image"
|
|
531
|
+
disabled={!loop && !api?.canScrollPrev()}
|
|
532
|
+
onClick={() => api?.scrollPrev()}
|
|
533
|
+
className={cn(
|
|
534
|
+
modalControlClassName,
|
|
535
|
+
"absolute top-1/2 left-4 z-20 -translate-y-1/2 active:!-translate-y-1/2",
|
|
536
|
+
)}
|
|
537
|
+
>
|
|
538
|
+
<IconChevronRightSmall className="rotate-180" aria-hidden />
|
|
539
|
+
</Button>
|
|
540
|
+
</ModalControlTooltip>
|
|
541
|
+
<ModalControlTooltip label="Next image" side="left">
|
|
542
|
+
<Button
|
|
543
|
+
type="button"
|
|
544
|
+
variant="ghost"
|
|
545
|
+
size="lg"
|
|
546
|
+
iconOnly
|
|
547
|
+
rounded
|
|
548
|
+
aria-label="Next image"
|
|
549
|
+
disabled={!loop && !api?.canScrollNext()}
|
|
550
|
+
onClick={() => api?.scrollNext()}
|
|
551
|
+
className={cn(
|
|
552
|
+
modalControlClassName,
|
|
553
|
+
"absolute top-1/2 right-4 z-20 -translate-y-1/2 active:!-translate-y-1/2",
|
|
554
|
+
)}
|
|
555
|
+
>
|
|
556
|
+
<IconChevronRightSmall aria-hidden />
|
|
557
|
+
</Button>
|
|
558
|
+
</ModalControlTooltip>
|
|
559
|
+
</>
|
|
560
|
+
) : null}
|
|
561
|
+
</TooltipProvider>
|
|
562
|
+
</DialogPopup>
|
|
563
|
+
</DialogPortal>
|
|
564
|
+
)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export const Gallery = ({
|
|
568
|
+
images,
|
|
569
|
+
children,
|
|
570
|
+
loop = true,
|
|
571
|
+
variant = "default",
|
|
572
|
+
contentClassName,
|
|
573
|
+
...props
|
|
574
|
+
}: GalleryProps) => {
|
|
575
|
+
const [activeIndex, setActiveIndex] = useState(0)
|
|
576
|
+
const safeIndex = images.length
|
|
577
|
+
? Math.min(activeIndex, images.length - 1)
|
|
578
|
+
: 0
|
|
579
|
+
const setSafeActiveIndex = useCallback(
|
|
580
|
+
(index: number) => {
|
|
581
|
+
if (images[index]) setActiveIndex(index)
|
|
582
|
+
},
|
|
583
|
+
[images],
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
return (
|
|
587
|
+
<Dialog {...props}>
|
|
588
|
+
<GalleryContext.Provider
|
|
589
|
+
value={{
|
|
590
|
+
images,
|
|
591
|
+
setActiveIndex: setSafeActiveIndex,
|
|
592
|
+
}}
|
|
593
|
+
>
|
|
594
|
+
{children}
|
|
595
|
+
{images.length ? (
|
|
596
|
+
<GalleryContent
|
|
597
|
+
images={images}
|
|
598
|
+
activeIndex={safeIndex}
|
|
599
|
+
setActiveIndex={setSafeActiveIndex}
|
|
600
|
+
loop={loop}
|
|
601
|
+
variant={variant ?? "default"}
|
|
602
|
+
className={contentClassName}
|
|
603
|
+
/>
|
|
604
|
+
) : null}
|
|
605
|
+
</GalleryContext.Provider>
|
|
606
|
+
</Dialog>
|
|
607
|
+
)
|
|
608
|
+
}
|