@boyernick/standard-ui-react 0.1.1-canary.19 → 0.1.1-canary.20

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyernick/standard-ui-react",
3
- "version": "0.1.1-canary.19",
3
+ "version": "0.1.1-canary.20",
4
4
  "description": "React components for StandardUI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,7 +14,7 @@ const breadcrumbSeparatorVariants = cva("text-fg-tertiary", {
14
14
  variants: {
15
15
  variant: {
16
16
  chevron: "[&_svg]:size-3.5",
17
- slash: "px-0.5",
17
+ slash: "",
18
18
  },
19
19
  },
20
20
  defaultVariants: {
@@ -39,7 +39,7 @@ export const BreadcrumbList = ({
39
39
  ...props
40
40
  }: BreadcrumbListProps) => (
41
41
  <ol
42
- className={cn("flex flex-wrap items-center gap-0.5", className)}
42
+ className={cn("flex flex-wrap items-center gap-x-2 gap-y-1.5", className)}
43
43
  {...props}
44
44
  />
45
45
  )
@@ -49,7 +49,7 @@ export const BreadcrumbItem = ({
49
49
  ...props
50
50
  }: BreadcrumbItemProps) => (
51
51
  <li
52
- className={cn("inline-flex items-center gap-1.5 px-1", className)}
52
+ className={cn("inline-flex items-center gap-1.5", className)}
53
53
  {...props}
54
54
  />
55
55
  )
package/src/command.tsx CHANGED
@@ -384,6 +384,15 @@ export const CommandTitle = ({ className, children }: CommandTitleProps) => (
384
384
  <span className={cn("min-w-0 truncate", className)}>{children}</span>
385
385
  )
386
386
 
387
+ export type CommandMetaProps = HTMLAttributes<HTMLSpanElement>
388
+
389
+ export const CommandMeta = ({ className, ...props }: CommandMetaProps) => (
390
+ <span
391
+ className={cn("shrink-0 text-xs font-normal text-fg-tertiary", className)}
392
+ {...props}
393
+ />
394
+ )
395
+
387
396
  export const CommandDialogTitle = ({
388
397
  className,
389
398
  ...props
package/src/icons.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import type { CentralIconBaseProps } from "@central-icons-react/round-outlined-radius-2-stroke-2/CentralIconBase"
4
+ import { IconArrowDown as IconArrowDownBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconArrowDown"
4
5
  import { IconBell as IconBellBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconBell"
5
6
  import { IconCalendar1 as IconCalendar1Base } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconCalendar1"
6
7
  import { IconChainLink1 as IconChainLink1Base } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconChainLink1"
@@ -54,6 +55,7 @@ export const withCentralIconDefaults = (
54
55
  return CentralIcon
55
56
  }
56
57
 
58
+ export const IconArrowDown = withCentralIconDefaults(IconArrowDownBase)
57
59
  export const IconBell = withCentralIconDefaults(IconBellBase)
58
60
  export const IconCalendar1 = withCentralIconDefaults(IconCalendar1Base)
59
61
  export const IconChainLink1 = withCentralIconDefaults(IconChainLink1Base)
@@ -1,7 +1,25 @@
1
1
  "use client"
2
2
 
3
- import type { ComponentProps, ImgHTMLAttributes } from "react"
4
- import { Button } from "./button"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+ import {
5
+ createContext,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useState,
10
+ type ComponentProps,
11
+ type ImgHTMLAttributes,
12
+ type KeyboardEvent,
13
+ type ReactElement,
14
+ type ReactNode,
15
+ } from "react"
16
+ import { Button, buttonVariants } from "./button"
17
+ import {
18
+ Carousel,
19
+ CarouselContent,
20
+ CarouselItem,
21
+ type CarouselApi,
22
+ } from "./carousel"
5
23
  import {
6
24
  Dialog,
7
25
  DialogBackdrop,
@@ -11,18 +29,103 @@ import {
11
29
  DialogTitle,
12
30
  DialogTrigger,
13
31
  } from "./dialog"
32
+ import {
33
+ IconArrowDown,
34
+ IconChevronRightSmall,
35
+ IconCrossSmall,
36
+ } from "./icons"
14
37
  import { cn } from "./lib/cn"
15
38
  import { motion } from "./lib/motion"
39
+ import {
40
+ Tooltip,
41
+ TooltipPopup,
42
+ TooltipPortal,
43
+ TooltipPositioner,
44
+ TooltipProvider,
45
+ TooltipTrigger,
46
+ type TooltipPositionerProps,
47
+ } from "./tooltip"
48
+
49
+ const TRACKPAD_LABEL =
50
+ "Swipe, use the arrow keys, or use the controls to navigate"
51
+
52
+ const imageModalControlClassName =
53
+ "border-border-inverted bg-surface-inverted/60 text-fg-inverted shadow-md backdrop-blur-md hover:bg-surface-inverted/80 hover:text-fg-inverted focus-visible:border-ring focus-visible:ring-border-inverted focus-visible:ring-offset-surface-inverted"
54
+
55
+ export const imageModalContentVariants = cva(
56
+ "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",
57
+ {
58
+ variants: {
59
+ variant: {
60
+ default: "[&_[data-slot=image-modal-stage]]:pb-0",
61
+ caption: "[&_[data-slot=image-modal-stage]]:pb-20",
62
+ },
63
+ },
64
+ defaultVariants: {
65
+ variant: "default",
66
+ },
67
+ },
68
+ )
16
69
 
17
70
  export type ImageModalProps = ComponentProps<typeof Dialog>
18
71
  export type ImageModalTriggerProps = ComponentProps<typeof DialogTrigger>
19
- export type ImageModalContentProps = ComponentProps<typeof DialogPopup> & {
72
+ export type ImageModalContentProps = Omit<
73
+ ComponentProps<typeof DialogPopup>,
74
+ "children"
75
+ > &
76
+ VariantProps<typeof imageModalContentVariants> & {
77
+ src: string
78
+ alt: string
79
+ caption?: ReactNode
80
+ downloadSrc?: string
81
+ downloadName?: string
82
+ imgProps?: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "alt">
83
+ children?: ReactNode
84
+ }
85
+
86
+ export type ImageModalItem = {
20
87
  src: string
21
88
  alt: string
22
- caption?: string
89
+ caption?: ReactNode
90
+ downloadSrc?: string
91
+ downloadName?: string
23
92
  imgProps?: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "alt">
24
93
  }
25
94
 
95
+ export type ImageModalGalleryProps = Omit<ImageModalProps, "children"> &
96
+ VariantProps<typeof imageModalContentVariants> & {
97
+ images: readonly ImageModalItem[]
98
+ children: ReactNode
99
+ loop?: boolean
100
+ contentClassName?: string
101
+ }
102
+
103
+ export type ImageModalGalleryTriggerProps = ImageModalTriggerProps & {
104
+ index: number
105
+ }
106
+
107
+ type ImageModalTriggerClickEvent = Parameters<
108
+ NonNullable<ImageModalTriggerProps["onClick"]>
109
+ >[0]
110
+
111
+ type ImageModalGalleryContextValue = {
112
+ images: readonly ImageModalItem[]
113
+ setActiveIndex: (index: number) => void
114
+ }
115
+
116
+ const ImageModalGalleryContext =
117
+ createContext<ImageModalGalleryContextValue | null>(null)
118
+
119
+ const useImageModalGallery = () => {
120
+ const context = useContext(ImageModalGalleryContext)
121
+ if (!context) {
122
+ throw new Error(
123
+ "ImageModalGalleryTrigger must be used within <ImageModalGallery>",
124
+ )
125
+ }
126
+ return context
127
+ }
128
+
26
129
  export const ImageModal = (props: ImageModalProps) => <Dialog {...props} />
27
130
 
28
131
  export const ImageModalTrigger = ({
@@ -38,73 +141,361 @@ export const ImageModalTrigger = ({
38
141
  />
39
142
  )
40
143
 
144
+ const ImageModalBackdrop = () => (
145
+ <DialogBackdrop className="bg-surface-inverted/95" />
146
+ )
147
+
148
+ const ImageModalBackground = ({ src }: { src: string }) => (
149
+ <div
150
+ aria-hidden="true"
151
+ className="pointer-events-none absolute inset-0 overflow-hidden"
152
+ >
153
+ {/* Consumers may use any image host, so this package deliberately renders
154
+ the platform-neutral image element rather than Next.js Image. */}
155
+ {/* eslint-disable-next-line @next/next/no-img-element */}
156
+ <img
157
+ src={src}
158
+ alt=""
159
+ className="size-full scale-110 object-cover opacity-25 blur-3xl"
160
+ />
161
+ <div className="absolute inset-0 bg-surface-inverted/65" />
162
+ </div>
163
+ )
164
+
165
+ const ImageModalImage = ({
166
+ src,
167
+ alt,
168
+ imgProps,
169
+ }: Pick<ImageModalItem, "src" | "alt" | "imgProps">) => {
170
+ const { className, ...props } = imgProps ?? {}
171
+
172
+ return (
173
+ // eslint-disable-next-line @next/next/no-img-element
174
+ <img
175
+ src={src}
176
+ alt={alt}
177
+ draggable={false}
178
+ className={cn(
179
+ "max-h-[82dvh] max-w-[88vw] rounded-lg object-contain shadow-lg select-none max-sm:max-h-[calc(100dvh-2rem)] max-sm:max-w-[calc(100vw-2rem)]",
180
+ className,
181
+ )}
182
+ {...props}
183
+ />
184
+ )
185
+ }
186
+
187
+ const ImageModalControlTooltip = ({
188
+ label,
189
+ side = "bottom",
190
+ children,
191
+ }: {
192
+ label: string
193
+ side?: TooltipPositionerProps["side"]
194
+ children: ReactElement
195
+ }) => (
196
+ <Tooltip>
197
+ <TooltipTrigger render={children} />
198
+ <TooltipPortal>
199
+ <TooltipPositioner side={side}>
200
+ <TooltipPopup variant="inverted">{label}</TooltipPopup>
201
+ </TooltipPositioner>
202
+ </TooltipPortal>
203
+ </Tooltip>
204
+ )
205
+
206
+ const ImageModalDownload = ({
207
+ src,
208
+ name,
209
+ }: {
210
+ src: string
211
+ name?: string
212
+ }) => (
213
+ <ImageModalControlTooltip label="Download image">
214
+ <a
215
+ href={src}
216
+ download={name ?? ""}
217
+ aria-label="Download image"
218
+ className={cn(
219
+ buttonVariants({
220
+ variant: "ghost",
221
+ size: "md",
222
+ rounded: true,
223
+ iconOnly: true,
224
+ }),
225
+ imageModalControlClassName,
226
+ "absolute top-4 right-4 z-20",
227
+ )}
228
+ >
229
+ <IconArrowDown aria-hidden />
230
+ </a>
231
+ </ImageModalControlTooltip>
232
+ )
233
+
234
+ const ImageModalDismiss = () => (
235
+ <ImageModalControlTooltip label="Close image">
236
+ <DialogClose
237
+ render={
238
+ <Button
239
+ type="button"
240
+ variant="ghost"
241
+ size="md"
242
+ iconOnly
243
+ rounded
244
+ aria-label="Close image"
245
+ className={cn(
246
+ imageModalControlClassName,
247
+ "absolute top-4 left-4 z-20",
248
+ )}
249
+ />
250
+ }
251
+ >
252
+ <IconCrossSmall aria-hidden />
253
+ </DialogClose>
254
+ </ImageModalControlTooltip>
255
+ )
256
+
257
+ const ImageModalCaption = ({ children }: { children: ReactNode }) => (
258
+ <div className="absolute inset-x-0 bottom-0 z-10 flex min-h-20 items-center justify-center bg-surface-inverted/65 px-6 py-4 text-center text-sm text-fg-inverted-secondary backdrop-blur-md">
259
+ {children}
260
+ </div>
261
+ )
262
+
41
263
  export const ImageModalContent = ({
42
264
  src,
43
265
  alt,
44
266
  caption,
267
+ downloadSrc,
268
+ downloadName,
269
+ variant = "default",
45
270
  className,
46
271
  imgProps,
47
272
  children,
48
273
  ...props
49
274
  }: ImageModalContentProps) => (
50
275
  <DialogPortal>
51
- <DialogBackdrop className="bg-black/70 dark:bg-black/80" />
276
+ <ImageModalBackdrop />
52
277
  <DialogPopup
53
278
  data-slot="image-modal-content"
54
279
  className={cn(
55
- "max-h-[90vh] w-full max-w-4xl gap-0 overflow-hidden border-border-primary bg-surface p-0 shadow-lg max-sm:max-w-[calc(100vw-1.5rem)]",
280
+ imageModalContentVariants({ variant }),
56
281
  motion.popupCenter,
57
282
  className,
58
283
  )}
59
284
  {...props}
60
285
  >
61
- <div className="relative">
286
+ <TooltipProvider delay={250}>
287
+ <ImageModalBackground src={src} />
62
288
  <DialogTitle className="sr-only">{alt}</DialogTitle>
63
- {/* eslint-disable-next-line @next/next/no-img-element */}
64
- <img
65
- src={src}
66
- alt={alt}
67
- className={cn(
68
- "max-h-[min(80vh,56rem)] w-full object-contain bg-background-secondary",
69
- imgProps?.className,
70
- )}
71
- {...imgProps}
72
- />
73
- <DialogClose
74
- render={
75
- <Button
76
- type="button"
77
- variant="secondary"
78
- size="sm"
79
- iconOnly
80
- rounded
81
- aria-label="Close"
82
- className="absolute top-3 right-3"
83
- />
84
- }
289
+ <div
290
+ data-slot="image-modal-stage"
291
+ className="relative z-10 flex size-full items-center justify-center p-4"
85
292
  >
86
- <CloseIcon />
87
- </DialogClose>
88
- </div>
89
- {caption || children ? (
90
- <div className="border-t border-border-primary px-4 py-3">
91
- {caption ? (
92
- <p className="text-sm text-fg-secondary">{caption}</p>
93
- ) : null}
94
- {children}
293
+ <ImageModalImage src={src} alt={alt} imgProps={imgProps} />
95
294
  </div>
96
- ) : null}
295
+ <ImageModalDismiss />
296
+ <ImageModalDownload
297
+ src={downloadSrc ?? src}
298
+ name={downloadName}
299
+ />
300
+ {variant === "caption" && (caption || children) ? (
301
+ <ImageModalCaption>{caption ?? children}</ImageModalCaption>
302
+ ) : null}
303
+ </TooltipProvider>
97
304
  </DialogPopup>
98
305
  </DialogPortal>
99
306
  )
100
307
 
101
- const CloseIcon = () => (
102
- <svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
103
- <path
104
- d="M7 7l10 10M17 7 7 17"
105
- stroke="currentColor"
106
- strokeWidth="2"
107
- strokeLinecap="round"
108
- />
109
- </svg>
110
- )
308
+ export const ImageModalGalleryTrigger = ({
309
+ index,
310
+ onClick,
311
+ ...props
312
+ }: ImageModalGalleryTriggerProps) => {
313
+ const { images, setActiveIndex } = useImageModalGallery()
314
+
315
+ const handleClick = (event: ImageModalTriggerClickEvent) => {
316
+ onClick?.(event)
317
+ if (!event.defaultPrevented && images[index]) setActiveIndex(index)
318
+ }
319
+
320
+ return <ImageModalTrigger onClick={handleClick} {...props} />
321
+ }
322
+
323
+ const ImageModalGalleryContent = ({
324
+ images,
325
+ activeIndex,
326
+ setActiveIndex,
327
+ loop,
328
+ variant,
329
+ className,
330
+ }: {
331
+ images: readonly ImageModalItem[]
332
+ activeIndex: number
333
+ setActiveIndex: (index: number) => void
334
+ loop: boolean
335
+ variant: "default" | "caption"
336
+ className?: string
337
+ }) => {
338
+ const [api, setApi] = useState<CarouselApi>()
339
+ const activeImage = images[activeIndex]
340
+
341
+ useEffect(() => {
342
+ api?.scrollTo(activeIndex, true)
343
+ }, [activeIndex, api])
344
+
345
+ useEffect(() => {
346
+ if (!api) return
347
+ const syncIndex = () => setActiveIndex(api.selectedScrollSnap())
348
+ api.on("select", syncIndex)
349
+ api.on("reInit", syncIndex)
350
+ return () => {
351
+ api.off("select", syncIndex)
352
+ api.off("reInit", syncIndex)
353
+ }
354
+ }, [api, setActiveIndex])
355
+
356
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
357
+ if (event.key === "ArrowLeft") {
358
+ event.preventDefault()
359
+ api?.scrollPrev()
360
+ } else if (event.key === "ArrowRight") {
361
+ event.preventDefault()
362
+ api?.scrollNext()
363
+ }
364
+ }
365
+
366
+ if (!activeImage) return null
367
+
368
+ return (
369
+ <DialogPortal>
370
+ <ImageModalBackdrop />
371
+ <DialogPopup
372
+ data-slot="image-modal-gallery-content"
373
+ aria-describedby={undefined}
374
+ onKeyDownCapture={handleKeyDown}
375
+ className={cn(
376
+ imageModalContentVariants({ variant }),
377
+ motion.popupCenter,
378
+ className,
379
+ )}
380
+ >
381
+ <TooltipProvider delay={250}>
382
+ <ImageModalBackground src={activeImage.src} />
383
+ <DialogTitle className="sr-only">{activeImage.alt}</DialogTitle>
384
+ <p className="sr-only" aria-live="polite">
385
+ Image {activeIndex + 1} of {images.length}. {TRACKPAD_LABEL}.
386
+ </p>
387
+ <Carousel
388
+ setApi={setApi}
389
+ opts={{ loop, startIndex: activeIndex, duration: 24 }}
390
+ data-slot="image-modal-stage"
391
+ className="relative z-10 size-full"
392
+ aria-label="Image gallery"
393
+ >
394
+ <CarouselContent className="h-full -ml-0">
395
+ {images.map((image, index) => (
396
+ <CarouselItem
397
+ key={`${image.src}-${index}`}
398
+ className="flex h-full items-center justify-center pl-0 p-4"
399
+ aria-label={`${index + 1} of ${images.length}`}
400
+ >
401
+ <ImageModalImage {...image} />
402
+ </CarouselItem>
403
+ ))}
404
+ </CarouselContent>
405
+ </Carousel>
406
+ <ImageModalDismiss />
407
+ <ImageModalDownload
408
+ src={activeImage.downloadSrc ?? activeImage.src}
409
+ name={activeImage.downloadName}
410
+ />
411
+ {images.length > 1 ? (
412
+ <>
413
+ <ImageModalControlTooltip label="Previous image" side="right">
414
+ <Button
415
+ type="button"
416
+ variant="ghost"
417
+ size="lg"
418
+ iconOnly
419
+ rounded
420
+ aria-label="Previous image"
421
+ disabled={!loop && !api?.canScrollPrev()}
422
+ onClick={() => api?.scrollPrev()}
423
+ className={cn(
424
+ imageModalControlClassName,
425
+ "absolute top-1/2 left-4 z-20 -translate-y-1/2 active:!-translate-y-1/2",
426
+ )}
427
+ >
428
+ <IconChevronRightSmall className="rotate-180" aria-hidden />
429
+ </Button>
430
+ </ImageModalControlTooltip>
431
+ <ImageModalControlTooltip label="Next image" side="left">
432
+ <Button
433
+ type="button"
434
+ variant="ghost"
435
+ size="lg"
436
+ iconOnly
437
+ rounded
438
+ aria-label="Next image"
439
+ disabled={!loop && !api?.canScrollNext()}
440
+ onClick={() => api?.scrollNext()}
441
+ className={cn(
442
+ imageModalControlClassName,
443
+ "absolute top-1/2 right-4 z-20 -translate-y-1/2 active:!-translate-y-1/2",
444
+ )}
445
+ >
446
+ <IconChevronRightSmall aria-hidden />
447
+ </Button>
448
+ </ImageModalControlTooltip>
449
+ </>
450
+ ) : null}
451
+ {variant === "caption" && activeImage.caption ? (
452
+ <ImageModalCaption>{activeImage.caption}</ImageModalCaption>
453
+ ) : null}
454
+ </TooltipProvider>
455
+ </DialogPopup>
456
+ </DialogPortal>
457
+ )
458
+ }
459
+
460
+ export const ImageModalGallery = ({
461
+ images,
462
+ children,
463
+ loop = true,
464
+ variant = "default",
465
+ contentClassName,
466
+ ...props
467
+ }: ImageModalGalleryProps) => {
468
+ const [activeIndex, setActiveIndex] = useState(0)
469
+ const safeIndex = images.length
470
+ ? Math.min(activeIndex, images.length - 1)
471
+ : 0
472
+ const setSafeActiveIndex = useCallback(
473
+ (index: number) => {
474
+ if (images[index]) setActiveIndex(index)
475
+ },
476
+ [images],
477
+ )
478
+
479
+ return (
480
+ <Dialog {...props}>
481
+ <ImageModalGalleryContext.Provider
482
+ value={{
483
+ images,
484
+ setActiveIndex: setSafeActiveIndex,
485
+ }}
486
+ >
487
+ {children}
488
+ {images.length ? (
489
+ <ImageModalGalleryContent
490
+ images={images}
491
+ activeIndex={safeIndex}
492
+ setActiveIndex={setSafeActiveIndex}
493
+ loop={loop}
494
+ variant={variant ?? "default"}
495
+ className={contentClassName}
496
+ />
497
+ ) : null}
498
+ </ImageModalGalleryContext.Provider>
499
+ </Dialog>
500
+ )
501
+ }
package/src/index.ts CHANGED
@@ -172,6 +172,7 @@ export {
172
172
  CommandItem,
173
173
  CommandEmpty,
174
174
  CommandTitle,
175
+ CommandMeta,
175
176
  CommandDialogTitle,
176
177
  type CommandProps,
177
178
  type CommandTriggerProps,
@@ -191,6 +192,7 @@ export {
191
192
  type CommandItemProps,
192
193
  type CommandEmptyProps,
193
194
  type CommandTitleProps,
195
+ type CommandMetaProps,
194
196
  type CommandDialogTitleProps,
195
197
  } from "./command";
196
198
  export {
@@ -873,9 +875,15 @@ export {
873
875
  ImageModal,
874
876
  ImageModalTrigger,
875
877
  ImageModalContent,
878
+ ImageModalGallery,
879
+ ImageModalGalleryTrigger,
880
+ imageModalContentVariants,
876
881
  type ImageModalProps,
877
882
  type ImageModalTriggerProps,
878
883
  type ImageModalContentProps,
884
+ type ImageModalGalleryProps,
885
+ type ImageModalGalleryTriggerProps,
886
+ type ImageModalItem,
879
887
  } from "./image-modal";
880
888
  export {
881
889
  Ticker,
@@ -922,6 +930,7 @@ export {
922
930
  CENTRAL_ICON_SIZE,
923
931
  withCentralIconDefaults,
924
932
  iconGallery,
933
+ IconArrowDown,
925
934
  IconBell,
926
935
  IconCalendar1,
927
936
  IconChainLink1,