@adres/mgda-ui 0.0.0-stage → 0.0.1
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/brand-knowledge.json +451 -0
- package/knowledge/accordion.md +135 -0
- package/knowledge/activity-rings.md +123 -0
- package/knowledge/alert.md +184 -0
- package/knowledge/answer-actions.md +217 -0
- package/knowledge/aspect-ratio.md +82 -0
- package/knowledge/avatar.md +158 -0
- package/knowledge/badge.md +172 -0
- package/knowledge/bar-graph.md +167 -0
- package/knowledge/bento-grid.md +115 -0
- package/knowledge/blockquote.md +153 -0
- package/knowledge/box-list.md +177 -0
- package/knowledge/bullet-list.md +128 -0
- package/knowledge/button-group.md +179 -0
- package/knowledge/button.md +231 -0
- package/knowledge/calendar.md +168 -0
- package/knowledge/card.md +321 -0
- package/knowledge/cards-nav.md +170 -0
- package/knowledge/chain-of-thought.md +164 -0
- package/knowledge/chat-container.md +135 -0
- package/knowledge/chat-message.md +224 -0
- package/knowledge/checkbox-card.md +172 -0
- package/knowledge/checkbox.md +111 -0
- package/knowledge/citations.md +241 -0
- package/knowledge/combobox.md +265 -0
- package/knowledge/countdown-pill.md +153 -0
- package/knowledge/cta.md +118 -0
- package/knowledge/data-list.md +173 -0
- package/knowledge/data-table.md +220 -0
- package/knowledge/dialog.md +249 -0
- package/knowledge/drawer.md +236 -0
- package/knowledge/dropdown-menu.md +238 -0
- package/knowledge/dropzone.md +153 -0
- package/knowledge/email-chips-input.md +161 -0
- package/knowledge/empty-state.md +106 -0
- package/knowledge/entity-header.md +166 -0
- package/knowledge/feature-grid.md +120 -0
- package/knowledge/feedback-bar.md +127 -0
- package/knowledge/footer.md +116 -0
- package/knowledge/heading.md +189 -0
- package/knowledge/hero.md +361 -0
- package/knowledge/input-button.md +187 -0
- package/knowledge/input-group.md +185 -0
- package/knowledge/input.md +256 -0
- package/knowledge/inset.md +177 -0
- package/knowledge/kpi-card.md +206 -0
- package/knowledge/list-nav.md +181 -0
- package/knowledge/logo-cloud.md +102 -0
- package/knowledge/media-gallery.md +158 -0
- package/knowledge/message-actions.md +175 -0
- package/knowledge/nav-sidebar.md +225 -0
- package/knowledge/number-stepper.md +159 -0
- package/knowledge/page-header.md +109 -0
- package/knowledge/pagination.md +225 -0
- package/knowledge/popover.md +159 -0
- package/knowledge/pricing.md +132 -0
- package/knowledge/progress.md +156 -0
- package/knowledge/prompt-input.md +337 -0
- package/knowledge/prompt-suggestions.md +168 -0
- package/knowledge/quota-bar.md +160 -0
- package/knowledge/quote.md +116 -0
- package/knowledge/radio-card.md +172 -0
- package/knowledge/radio-group.md +135 -0
- package/knowledge/reasoning.md +142 -0
- package/knowledge/search.md +131 -0
- package/knowledge/section-nav.md +162 -0
- package/knowledge/segmented-control.md +181 -0
- package/knowledge/select-button.md +205 -0
- package/knowledge/select.md +210 -0
- package/knowledge/separator.md +75 -0
- package/knowledge/shimmer-text.md +140 -0
- package/knowledge/sidebar.md +227 -0
- package/knowledge/skeleton.md +142 -0
- package/knowledge/slider.md +133 -0
- package/knowledge/slideshow.md +159 -0
- package/knowledge/source.md +156 -0
- package/knowledge/spinner.md +120 -0
- package/knowledge/stat-strip.md +128 -0
- package/knowledge/stats.md +117 -0
- package/knowledge/step-indicator.md +157 -0
- package/knowledge/switch.md +88 -0
- package/knowledge/table.md +190 -0
- package/knowledge/tabs.md +169 -0
- package/knowledge/testimonials.md +115 -0
- package/knowledge/text-link.md +192 -0
- package/knowledge/text.md +194 -0
- package/knowledge/thinking-bar.md +115 -0
- package/knowledge/thinking-steps.md +140 -0
- package/knowledge/toaster.md +127 -0
- package/knowledge/toggle.md +113 -0
- package/knowledge/tool-call-card.md +241 -0
- package/knowledge/tooltip.md +120 -0
- package/knowledge/top-nav.md +138 -0
- package/knowledge.json +19854 -0
- package/package.json +94 -4
- package/src/components/accordion.stories.tsx +46 -0
- package/src/components/accordion.tsx +74 -0
- package/src/components/activity-rings.stories.tsx +73 -0
- package/src/components/activity-rings.tsx +121 -0
- package/src/components/alert.stories.tsx +43 -0
- package/src/components/alert.tsx +132 -0
- package/src/components/answer-actions.stories.tsx +54 -0
- package/src/components/answer-actions.test.tsx +37 -0
- package/src/components/answer-actions.tsx +313 -0
- package/src/components/aspect-ratio.stories.tsx +32 -0
- package/src/components/aspect-ratio.tsx +5 -0
- package/src/components/avatar.stories.tsx +40 -0
- package/src/components/avatar.tsx +154 -0
- package/src/components/badge.stories.tsx +27 -0
- package/src/components/badge.tsx +63 -0
- package/src/components/bar-graph.stories.tsx +28 -0
- package/src/components/bar-graph.tsx +204 -0
- package/src/components/bento-grid.stories.tsx +23 -0
- package/src/components/bento-grid.tsx +67 -0
- package/src/components/blockquote.stories.tsx +28 -0
- package/src/components/blockquote.tsx +88 -0
- package/src/components/box-list.stories.tsx +25 -0
- package/src/components/box-list.tsx +108 -0
- package/src/components/bullet-list.stories.tsx +22 -0
- package/src/components/bullet-list.tsx +101 -0
- package/src/components/button-group.stories.tsx +31 -0
- package/src/components/button-group.tsx +156 -0
- package/src/components/button.stories.tsx +66 -0
- package/src/components/button.test.ts +17 -0
- package/src/components/button.tsx +139 -0
- package/src/components/calendar.stories.tsx +25 -0
- package/src/components/calendar.tsx +367 -0
- package/src/components/card.stories.tsx +44 -0
- package/src/components/card.tsx +392 -0
- package/src/components/cards-nav.stories.tsx +35 -0
- package/src/components/cards-nav.tsx +115 -0
- package/src/components/chain-of-thought.stories.tsx +67 -0
- package/src/components/chain-of-thought.test.tsx +50 -0
- package/src/components/chain-of-thought.tsx +170 -0
- package/src/components/chat-container.stories.tsx +47 -0
- package/src/components/chat-container.test.tsx +49 -0
- package/src/components/chat-container.tsx +222 -0
- package/src/components/chat-message.stories.tsx +52 -0
- package/src/components/chat-message.test.tsx +36 -0
- package/src/components/chat-message.tsx +163 -0
- package/src/components/checkbox-card.stories.tsx +27 -0
- package/src/components/checkbox-card.tsx +200 -0
- package/src/components/checkbox.stories.tsx +26 -0
- package/src/components/checkbox.tsx +61 -0
- package/src/components/citations.stories.tsx +64 -0
- package/src/components/citations.test.tsx +61 -0
- package/src/components/citations.tsx +220 -0
- package/src/components/combobox.stories.tsx +184 -0
- package/src/components/combobox.test.tsx +82 -0
- package/src/components/combobox.tsx +407 -0
- package/src/components/countdown-pill.stories.tsx +24 -0
- package/src/components/countdown-pill.tsx +129 -0
- package/src/components/cta.stories.tsx +29 -0
- package/src/components/cta.tsx +52 -0
- package/src/components/data-list.stories.tsx +50 -0
- package/src/components/data-list.test.tsx +35 -0
- package/src/components/data-list.tsx +181 -0
- package/src/components/data-table.stories.tsx +29 -0
- package/src/components/data-table.tsx +200 -0
- package/src/components/dialog.stories.tsx +42 -0
- package/src/components/dialog.tsx +200 -0
- package/src/components/drawer.stories.tsx +48 -0
- package/src/components/drawer.tsx +385 -0
- package/src/components/dropdown-menu.stories.tsx +41 -0
- package/src/components/dropdown-menu.tsx +252 -0
- package/src/components/dropzone.stories.tsx +19 -0
- package/src/components/dropzone.tsx +250 -0
- package/src/components/email-chips-input.stories.tsx +135 -0
- package/src/components/email-chips-input.tsx +210 -0
- package/src/components/empty-state.stories.tsx +22 -0
- package/src/components/empty-state.tsx +35 -0
- package/src/components/entity-header.stories.tsx +43 -0
- package/src/components/entity-header.test.tsx +35 -0
- package/src/components/entity-header.tsx +92 -0
- package/src/components/feature-grid.stories.tsx +25 -0
- package/src/components/feature-grid.tsx +87 -0
- package/src/components/feedback-bar.stories.tsx +25 -0
- package/src/components/feedback-bar.test.tsx +24 -0
- package/src/components/feedback-bar.tsx +62 -0
- package/src/components/footer.stories.tsx +23 -0
- package/src/components/footer.tsx +78 -0
- package/src/components/heading.stories.tsx +45 -0
- package/src/components/heading.test.tsx +22 -0
- package/src/components/heading.tsx +77 -0
- package/src/components/hero.stories.tsx +38 -0
- package/src/components/hero.tsx +498 -0
- package/src/components/input-button.stories.tsx +19 -0
- package/src/components/input-button.tsx +112 -0
- package/src/components/input-group.stories.tsx +38 -0
- package/src/components/input-group.tsx +157 -0
- package/src/components/input.stories.tsx +31 -0
- package/src/components/input.tsx +284 -0
- package/src/components/inset.stories.tsx +29 -0
- package/src/components/inset.tsx +70 -0
- package/src/components/kpi-card.stories.tsx +29 -0
- package/src/components/kpi-card.tsx +284 -0
- package/src/components/list-nav.stories.tsx +22 -0
- package/src/components/list-nav.tsx +145 -0
- package/src/components/logo-cloud.stories.tsx +23 -0
- package/src/components/logo-cloud.tsx +42 -0
- package/src/components/media-gallery.stories.tsx +25 -0
- package/src/components/media-gallery.tsx +347 -0
- package/src/components/message-actions.stories.tsx +45 -0
- package/src/components/message-actions.test.tsx +32 -0
- package/src/components/message-actions.tsx +119 -0
- package/src/components/nav-sidebar.stories.tsx +58 -0
- package/src/components/nav-sidebar.test.tsx +54 -0
- package/src/components/nav-sidebar.tsx +462 -0
- package/src/components/number-stepper.stories.tsx +19 -0
- package/src/components/number-stepper.tsx +158 -0
- package/src/components/page-header.stories.tsx +25 -0
- package/src/components/page-header.tsx +64 -0
- package/src/components/pagination.stories.tsx +110 -0
- package/src/components/pagination.test.tsx +46 -0
- package/src/components/pagination.tsx +144 -0
- package/src/components/popover.stories.tsx +26 -0
- package/src/components/popover.tsx +62 -0
- package/src/components/pricing.stories.tsx +42 -0
- package/src/components/pricing.tsx +130 -0
- package/src/components/progress.stories.tsx +22 -0
- package/src/components/progress.tsx +189 -0
- package/src/components/prompt-input.stories.tsx +62 -0
- package/src/components/prompt-input.test.tsx +106 -0
- package/src/components/prompt-input.tsx +343 -0
- package/src/components/prompt-suggestions.stories.tsx +53 -0
- package/src/components/prompt-suggestions.test.tsx +37 -0
- package/src/components/prompt-suggestions.tsx +122 -0
- package/src/components/quota-bar.stories.tsx +19 -0
- package/src/components/quota-bar.tsx +191 -0
- package/src/components/quote.stories.tsx +18 -0
- package/src/components/quote.tsx +43 -0
- package/src/components/radio-card.stories.tsx +27 -0
- package/src/components/radio-card.tsx +225 -0
- package/src/components/radio-group.stories.tsx +32 -0
- package/src/components/radio-group.tsx +68 -0
- package/src/components/reasoning.stories.tsx +50 -0
- package/src/components/reasoning.test.tsx +48 -0
- package/src/components/reasoning.tsx +143 -0
- package/src/components/search.stories.tsx +18 -0
- package/src/components/search.tsx +133 -0
- package/src/components/section-nav.stories.tsx +59 -0
- package/src/components/section-nav.test.tsx +36 -0
- package/src/components/section-nav.tsx +146 -0
- package/src/components/segmented-control.stories.tsx +22 -0
- package/src/components/segmented-control.tsx +301 -0
- package/src/components/select-button.stories.tsx +27 -0
- package/src/components/select-button.tsx +174 -0
- package/src/components/select.stories.tsx +27 -0
- package/src/components/select.tsx +198 -0
- package/src/components/separator.stories.tsx +22 -0
- package/src/components/separator.tsx +23 -0
- package/src/components/shimmer-text.stories.tsx +23 -0
- package/src/components/shimmer-text.test.tsx +35 -0
- package/src/components/shimmer-text.tsx +43 -0
- package/src/components/sidebar.stories.tsx +25 -0
- package/src/components/sidebar.tsx +234 -0
- package/src/components/skeleton.stories.tsx +22 -0
- package/src/components/skeleton.tsx +96 -0
- package/src/components/slider.stories.tsx +20 -0
- package/src/components/slider.tsx +176 -0
- package/src/components/slideshow.stories.tsx +30 -0
- package/src/components/slideshow.tsx +291 -0
- package/src/components/source.stories.tsx +32 -0
- package/src/components/source.test.tsx +40 -0
- package/src/components/source.tsx +161 -0
- package/src/components/spinner.stories.tsx +19 -0
- package/src/components/spinner.tsx +76 -0
- package/src/components/stat-strip.stories.tsx +36 -0
- package/src/components/stat-strip.test.tsx +25 -0
- package/src/components/stat-strip.tsx +85 -0
- package/src/components/stats.stories.tsx +24 -0
- package/src/components/stats.tsx +83 -0
- package/src/components/step-indicator.stories.tsx +18 -0
- package/src/components/step-indicator.test.tsx +18 -0
- package/src/components/step-indicator.tsx +286 -0
- package/src/components/switch.stories.tsx +26 -0
- package/src/components/switch.tsx +32 -0
- package/src/components/table.stories.tsx +38 -0
- package/src/components/table.tsx +220 -0
- package/src/components/tabs.stories.tsx +25 -0
- package/src/components/tabs.tsx +276 -0
- package/src/components/testimonials.stories.tsx +22 -0
- package/src/components/testimonials.tsx +109 -0
- package/src/components/text-link.stories.tsx +47 -0
- package/src/components/text-link.test.tsx +18 -0
- package/src/components/text-link.tsx +88 -0
- package/src/components/text.stories.tsx +49 -0
- package/src/components/text.tsx +105 -0
- package/src/components/thinking-bar.stories.tsx +19 -0
- package/src/components/thinking-bar.test.tsx +27 -0
- package/src/components/thinking-bar.tsx +64 -0
- package/src/components/thinking-steps.stories.tsx +44 -0
- package/src/components/thinking-steps.test.tsx +42 -0
- package/src/components/thinking-steps.tsx +92 -0
- package/src/components/toaster.stories.tsx +80 -0
- package/src/components/toaster.tsx +36 -0
- package/src/components/toggle.stories.tsx +21 -0
- package/src/components/toggle.tsx +103 -0
- package/src/components/tool-call-card.stories.tsx +97 -0
- package/src/components/tool-call-card.test.tsx +88 -0
- package/src/components/tool-call-card.tsx +231 -0
- package/src/components/tooltip.stories.tsx +25 -0
- package/src/components/tooltip.tsx +43 -0
- package/src/components/top-nav.stories.tsx +20 -0
- package/src/components/top-nav.tsx +76 -0
- package/src/docs/Colors.stories.tsx +192 -0
- package/src/docs/GettingStarted.mdx +72 -0
- package/src/docs/Mcp.mdx +52 -0
- package/src/docs/Typography.stories.tsx +114 -0
- package/src/hooks/use-in-view.ts +32 -0
- package/src/hooks/use-reduced-motion.ts +26 -0
- package/src/index.ts +343 -0
- package/src/lib/cn.test.ts +67 -0
- package/src/lib/cn.ts +81 -0
- package/src/lib/motion.ts +109 -0
- package/src/lib/prefix-utilities.test.ts +56 -0
- package/src/lib/prefix-utilities.ts +50 -0
- package/src/styles/base.css +489 -0
- package/src/styles/motion.css +69 -0
- package/src/styles/overrides.css +44 -0
- package/src/styles/typography.css +173 -0
- package/src/theme/theme.css +391 -0
- package/src/theme/tokens.ts +29 -0
- package/src/theme/types.ts +26 -0
- package/README.md +0 -3
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
|
5
|
+
import { ChevronLeft, ChevronRight, Images, Play, Rotate3d, X, ZoomIn, ZoomOut } from "lucide-react"
|
|
6
|
+
|
|
7
|
+
import { Button } from "./button"
|
|
8
|
+
import { cn } from "../lib/cn"
|
|
9
|
+
|
|
10
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export type MediaKind = "image" | "video" | "walkthrough"
|
|
13
|
+
|
|
14
|
+
/** A single item in a media gallery. Images render in an <img>; video and
|
|
15
|
+
* walkthrough render in an HTML5 <video> (a walkthrough is a labelled video:
|
|
16
|
+
* point `src` at a clip or a 360°/tour capture). */
|
|
17
|
+
export interface MediaItem {
|
|
18
|
+
type: MediaKind
|
|
19
|
+
/** Image URL, or video file URL for video/walkthrough. */
|
|
20
|
+
src: string
|
|
21
|
+
/** Poster frame for video/walkthrough (an image URL). */
|
|
22
|
+
poster?: string
|
|
23
|
+
/** Short label shown on the tile + in the lightbox (e.g. "Walkthrough"). */
|
|
24
|
+
label?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n))
|
|
28
|
+
const MIN_ZOOM = 1
|
|
29
|
+
const MAX_ZOOM = 5
|
|
30
|
+
|
|
31
|
+
/** Poster/thumbnail source for any item - the image itself, or the video poster. */
|
|
32
|
+
const tileSrc = (m: MediaItem) => (m.type === "image" ? m.src : (m.poster ?? ""))
|
|
33
|
+
|
|
34
|
+
// ─── MediaGallery ───────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export interface MediaGalleryProps {
|
|
37
|
+
/** Ordered media. The first item is the hero; the rest fill the thumbnails. */
|
|
38
|
+
media: MediaItem[]
|
|
39
|
+
/** Used for alt text and the lightbox's accessible title. */
|
|
40
|
+
title?: string
|
|
41
|
+
/** Thumbnails shown beside the hero (defaults to 4). */
|
|
42
|
+
thumbnailCount?: number
|
|
43
|
+
className?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A responsive media gallery: a hero + thumbnail grid that opens a fullscreen
|
|
48
|
+
* lightbox with a keyboard-navigable slideshow, image zoom/pan, and inline
|
|
49
|
+
* video/walkthrough playback. Framework-agnostic. Pass any `MediaItem[]`.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* <MediaGallery media={[{ type: "image", src: "/a.jpg" }, …]} title="Unit 2304" />
|
|
53
|
+
*/
|
|
54
|
+
export function MediaGallery({ media, title = "", thumbnailCount = 4, className }: MediaGalleryProps) {
|
|
55
|
+
const [openIndex, setOpenIndex] = React.useState<number | null>(null)
|
|
56
|
+
|
|
57
|
+
const hero = media[0]
|
|
58
|
+
if (!hero) return null
|
|
59
|
+
|
|
60
|
+
const thumbs = media.slice(1, 1 + thumbnailCount)
|
|
61
|
+
const hiddenCount = media.length - (1 + thumbs.length)
|
|
62
|
+
const walkthroughIndex = media.findIndex((m) => m.type === "walkthrough")
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
<div className={cn("ax:grid ax:gap-xs ax:sm:grid-cols-4 ax:sm:grid-rows-2", className)}>
|
|
67
|
+
{/* Hero */}
|
|
68
|
+
<div className="ax:group ax:relative ax:aspect-[3/2] ax:overflow-hidden ax:rounded-md ax:bg-muted ax:sm:col-span-2 ax:sm:row-span-2 ax:sm:aspect-auto">
|
|
69
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
70
|
+
<img
|
|
71
|
+
src={tileSrc(hero)}
|
|
72
|
+
alt={title}
|
|
73
|
+
className="ax:h-full ax:w-full ax:object-cover ax:transition-transform ax:duration-300 ax:group-hover:scale-[1.03]"
|
|
74
|
+
/>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
aria-label="Open gallery"
|
|
78
|
+
onClick={() => setOpenIndex(0)}
|
|
79
|
+
className="ax:absolute ax:inset-0 ax:z-0"
|
|
80
|
+
/>
|
|
81
|
+
{walkthroughIndex >= 0 ? (
|
|
82
|
+
<Button
|
|
83
|
+
size="sm"
|
|
84
|
+
onClick={() => setOpenIndex(walkthroughIndex)}
|
|
85
|
+
className="ax:absolute ax:bottom-3 ax:left-3 ax:z-10 ax:bg-background/90 ax:text-foreground ax:backdrop-blur-sm ax:hover:bg-background"
|
|
86
|
+
>
|
|
87
|
+
<Rotate3d className="ax:size-4" /> Walkthrough
|
|
88
|
+
</Button>
|
|
89
|
+
) : null}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{/* Thumbnails */}
|
|
93
|
+
{thumbs.map((m, i) => {
|
|
94
|
+
const mediaIndex = i + 1
|
|
95
|
+
const showMore = i === thumbs.length - 1 && hiddenCount > 0
|
|
96
|
+
return (
|
|
97
|
+
<button
|
|
98
|
+
key={mediaIndex}
|
|
99
|
+
type="button"
|
|
100
|
+
aria-label={showMore ? `View all ${media.length} items` : `Open item ${mediaIndex + 1}`}
|
|
101
|
+
onClick={() => setOpenIndex(mediaIndex)}
|
|
102
|
+
className="ax:group ax:relative ax:hidden ax:aspect-[3/2] ax:overflow-hidden ax:rounded-md ax:bg-muted ax:sm:block"
|
|
103
|
+
>
|
|
104
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
105
|
+
<img
|
|
106
|
+
src={tileSrc(m)}
|
|
107
|
+
alt={`${title} - ${mediaIndex + 1}`}
|
|
108
|
+
className="ax:h-full ax:w-full ax:object-cover ax:transition-transform ax:duration-300 ax:group-hover:scale-[1.03]"
|
|
109
|
+
/>
|
|
110
|
+
{m.type !== "image" && !showMore ? (
|
|
111
|
+
<span className="ax:absolute ax:inset-0 ax:grid ax:place-items-center ax:bg-black/25">
|
|
112
|
+
<span className="ax:grid ax:size-10 ax:place-items-center ax:rounded-full ax:bg-background/90">
|
|
113
|
+
<Play className="ax:size-5 ax:fill-foreground ax:text-foreground" />
|
|
114
|
+
</span>
|
|
115
|
+
</span>
|
|
116
|
+
) : null}
|
|
117
|
+
{showMore ? (
|
|
118
|
+
<span className="ax:absolute ax:inset-0 ax:grid ax:place-items-center ax:bg-black/55 ax:text-white">
|
|
119
|
+
<span className="ax:inline-flex ax:items-center ax:gap-1.5 ax:t-label-sm">
|
|
120
|
+
<Images className="ax:size-4" /> +{hiddenCount} Photos
|
|
121
|
+
</span>
|
|
122
|
+
</span>
|
|
123
|
+
) : null}
|
|
124
|
+
</button>
|
|
125
|
+
)
|
|
126
|
+
})}
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{openIndex !== null ? (
|
|
130
|
+
<Lightbox
|
|
131
|
+
media={media}
|
|
132
|
+
index={openIndex}
|
|
133
|
+
onIndexChange={setOpenIndex}
|
|
134
|
+
onClose={() => setOpenIndex(null)}
|
|
135
|
+
title={title}
|
|
136
|
+
/>
|
|
137
|
+
) : null}
|
|
138
|
+
</>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── Lightbox ─────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
export interface LightboxProps {
|
|
145
|
+
media: MediaItem[]
|
|
146
|
+
index: number
|
|
147
|
+
onIndexChange: (index: number) => void
|
|
148
|
+
onClose: () => void
|
|
149
|
+
title?: string
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Fullscreen media viewer: keyboard-navigable slideshow (←/→, Esc), image
|
|
154
|
+
* zoom/pan, and inline playback for video/walkthroughs. Built on the Radix
|
|
155
|
+
* Dialog primitive (focus trap, scroll lock) with a dark immersive surface.
|
|
156
|
+
* Usually driven by `MediaGallery`, but exported for standalone use.
|
|
157
|
+
*/
|
|
158
|
+
export function Lightbox({ media, index, onIndexChange, onClose, title = "" }: LightboxProps) {
|
|
159
|
+
const count = media.length
|
|
160
|
+
const go = React.useCallback(
|
|
161
|
+
(delta: number) => onIndexChange((index + delta + count) % count),
|
|
162
|
+
[index, count, onIndexChange],
|
|
163
|
+
)
|
|
164
|
+
const current = media[index]
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<DialogPrimitive.Root open onOpenChange={(open) => !open && onClose()}>
|
|
168
|
+
<DialogPrimitive.Portal>
|
|
169
|
+
<DialogPrimitive.Overlay className="ax:fixed ax:inset-0 ax:z-[1200] ax:bg-black/90 ax:backdrop-blur-sm ax:data-[state=open]:animate-in ax:data-[state=open]:fade-in-0" />
|
|
170
|
+
<DialogPrimitive.Content
|
|
171
|
+
aria-describedby={undefined}
|
|
172
|
+
onKeyDown={(e) => {
|
|
173
|
+
if (e.key === "ArrowRight") go(1)
|
|
174
|
+
else if (e.key === "ArrowLeft") go(-1)
|
|
175
|
+
}}
|
|
176
|
+
className="ax:fixed ax:inset-0 ax:z-[1200] ax:flex ax:flex-col ax:text-white ax:outline-none ax:data-[state=open]:animate-in ax:data-[state=open]:fade-in-0"
|
|
177
|
+
>
|
|
178
|
+
<DialogPrimitive.Title className="ax:sr-only">{title || "Media gallery"}</DialogPrimitive.Title>
|
|
179
|
+
|
|
180
|
+
{/* Top bar */}
|
|
181
|
+
<div className="ax:flex ax:shrink-0 ax:items-center ax:justify-between ax:gap-md ax:px-md ax:py-sm">
|
|
182
|
+
<span className="ax:t-label-sm ax:text-white/80">
|
|
183
|
+
{index + 1} / {count}
|
|
184
|
+
{current?.label ? <span className="ax:ml-2 ax:text-white/60">· {current.label}</span> : null}
|
|
185
|
+
</span>
|
|
186
|
+
<DialogPrimitive.Close asChild>
|
|
187
|
+
<IconButton label="Close">
|
|
188
|
+
<X className="ax:size-5" />
|
|
189
|
+
</IconButton>
|
|
190
|
+
</DialogPrimitive.Close>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{/* Stage */}
|
|
194
|
+
<div className="ax:relative ax:flex ax:min-h-0 ax:flex-1 ax:items-center ax:justify-center">
|
|
195
|
+
{count > 1 ? (
|
|
196
|
+
<IconButton label="Previous" onClick={() => go(-1)} className="ax:absolute ax:left-md ax:top-1/2 ax:z-10 ax:-translate-y-1/2">
|
|
197
|
+
<ChevronLeft className="ax:size-6" />
|
|
198
|
+
</IconButton>
|
|
199
|
+
) : null}
|
|
200
|
+
|
|
201
|
+
{current?.type === "image" ? (
|
|
202
|
+
<ZoomableImage key={index} src={current.src} alt={`${title} - ${index + 1} of ${count}`} />
|
|
203
|
+
) : current ? (
|
|
204
|
+
<video
|
|
205
|
+
key={index}
|
|
206
|
+
src={current.src}
|
|
207
|
+
poster={current.poster}
|
|
208
|
+
controls
|
|
209
|
+
autoPlay
|
|
210
|
+
playsInline
|
|
211
|
+
className="ax:max-h-full ax:max-w-full"
|
|
212
|
+
/>
|
|
213
|
+
) : null}
|
|
214
|
+
|
|
215
|
+
{count > 1 ? (
|
|
216
|
+
<IconButton label="Next" onClick={() => go(1)} className="ax:absolute ax:right-md ax:top-1/2 ax:z-10 ax:-translate-y-1/2">
|
|
217
|
+
<ChevronRight className="ax:size-6" />
|
|
218
|
+
</IconButton>
|
|
219
|
+
) : null}
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
{/* Thumbnail strip */}
|
|
223
|
+
<div className="ax:shrink-0 ax:overflow-x-auto ax:px-md ax:py-md">
|
|
224
|
+
<div className="ax:mx-auto ax:flex ax:w-max ax:gap-xs">
|
|
225
|
+
{media.map((m, i) => (
|
|
226
|
+
<button
|
|
227
|
+
key={i}
|
|
228
|
+
type="button"
|
|
229
|
+
aria-label={`Go to item ${i + 1}`}
|
|
230
|
+
aria-current={i === index}
|
|
231
|
+
onClick={() => onIndexChange(i)}
|
|
232
|
+
className={cn(
|
|
233
|
+
"ax:relative ax:h-14 ax:w-20 ax:shrink-0 ax:overflow-hidden ax:rounded-sm ax:ring-offset-2 ax:ring-offset-black ax:transition",
|
|
234
|
+
i === index ? "ax:ring-2 ax:ring-white" : "ax:opacity-60 ax:hover:opacity-100",
|
|
235
|
+
)}
|
|
236
|
+
>
|
|
237
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
238
|
+
<img src={tileSrc(m)} alt="" className="ax:h-full ax:w-full ax:object-cover" draggable={false} />
|
|
239
|
+
{m.type !== "image" ? (
|
|
240
|
+
<span className="ax:absolute ax:inset-0 ax:grid ax:place-items-center ax:bg-black/30">
|
|
241
|
+
<Play className="ax:size-4 ax:fill-white ax:text-white" />
|
|
242
|
+
</span>
|
|
243
|
+
) : null}
|
|
244
|
+
</button>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
</DialogPrimitive.Content>
|
|
249
|
+
</DialogPrimitive.Portal>
|
|
250
|
+
</DialogPrimitive.Root>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── Zoomable image ─────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
function ZoomableImage({ src, alt }: { src: string; alt: string }) {
|
|
257
|
+
const [scale, setScale] = React.useState(1)
|
|
258
|
+
const [pos, setPos] = React.useState({ x: 0, y: 0 })
|
|
259
|
+
const dragging = React.useRef(false)
|
|
260
|
+
const last = React.useRef({ x: 0, y: 0 })
|
|
261
|
+
|
|
262
|
+
const reset = () => {
|
|
263
|
+
setScale(1)
|
|
264
|
+
setPos({ x: 0, y: 0 })
|
|
265
|
+
}
|
|
266
|
+
// Panning offset is only meaningful while zoomed in.
|
|
267
|
+
React.useEffect(() => {
|
|
268
|
+
if (scale === 1) setPos({ x: 0, y: 0 })
|
|
269
|
+
}, [scale])
|
|
270
|
+
|
|
271
|
+
const zoomBy = (factor: number) => setScale((s) => clamp(+(s * factor).toFixed(3), MIN_ZOOM, MAX_ZOOM))
|
|
272
|
+
|
|
273
|
+
const onPointerDown = (e: React.PointerEvent<HTMLImageElement>) => {
|
|
274
|
+
if (scale <= 1) return
|
|
275
|
+
dragging.current = true
|
|
276
|
+
last.current = { x: e.clientX, y: e.clientY }
|
|
277
|
+
e.currentTarget.setPointerCapture(e.pointerId)
|
|
278
|
+
}
|
|
279
|
+
const onPointerMove = (e: React.PointerEvent<HTMLImageElement>) => {
|
|
280
|
+
if (!dragging.current) return
|
|
281
|
+
setPos((p) => ({ x: p.x + (e.clientX - last.current.x), y: p.y + (e.clientY - last.current.y) }))
|
|
282
|
+
last.current = { x: e.clientX, y: e.clientY }
|
|
283
|
+
}
|
|
284
|
+
const endDrag = () => {
|
|
285
|
+
dragging.current = false
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<div
|
|
290
|
+
className="ax:relative ax:flex ax:h-full ax:w-full ax:items-center ax:justify-center ax:overflow-hidden"
|
|
291
|
+
onWheel={(e) => zoomBy(e.deltaY < 0 ? 1.15 : 1 / 1.15)}
|
|
292
|
+
>
|
|
293
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
294
|
+
<img
|
|
295
|
+
src={src}
|
|
296
|
+
alt={alt}
|
|
297
|
+
draggable={false}
|
|
298
|
+
onDoubleClick={() => (scale > 1 ? reset() : setScale(2.5))}
|
|
299
|
+
onPointerDown={onPointerDown}
|
|
300
|
+
onPointerMove={onPointerMove}
|
|
301
|
+
onPointerUp={endDrag}
|
|
302
|
+
onPointerCancel={endDrag}
|
|
303
|
+
className={cn(
|
|
304
|
+
"ax:max-h-full ax:max-w-full ax:select-none ax:object-contain",
|
|
305
|
+
!dragging.current && "ax:transition-transform",
|
|
306
|
+
scale > 1 ? "ax:cursor-grab ax:active:cursor-grabbing" : "ax:cursor-zoom-in",
|
|
307
|
+
)}
|
|
308
|
+
style={{ transform: `translate(${pos.x}px, ${pos.y}px) scale(${scale})` }}
|
|
309
|
+
/>
|
|
310
|
+
|
|
311
|
+
<div className="ax:absolute ax:bottom-4 ax:right-4 ax:flex ax:gap-xs">
|
|
312
|
+
<IconButton label="Zoom out" onClick={() => zoomBy(1 / 1.3)} disabled={scale <= MIN_ZOOM}>
|
|
313
|
+
<ZoomOut className="ax:size-5" />
|
|
314
|
+
</IconButton>
|
|
315
|
+
<IconButton label="Zoom in" onClick={() => zoomBy(1.3)} disabled={scale >= MAX_ZOOM}>
|
|
316
|
+
<ZoomIn className="ax:size-5" />
|
|
317
|
+
</IconButton>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ─── Dark-surface icon button ───────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
function IconButton({
|
|
326
|
+
label,
|
|
327
|
+
className,
|
|
328
|
+
children,
|
|
329
|
+
...props
|
|
330
|
+
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {
|
|
331
|
+
return (
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
aria-label={label}
|
|
335
|
+
title={label}
|
|
336
|
+
className={cn(
|
|
337
|
+
"ax:grid ax:size-10 ax:place-items-center ax:rounded-full ax:bg-white/10 ax:text-white ax:backdrop-blur-sm ax:transition",
|
|
338
|
+
"ax:hover:bg-white/20 ax:focus-visible:outline-none ax:focus-visible:ring-2 ax:focus-visible:ring-white",
|
|
339
|
+
"ax:disabled:pointer-events-none ax:disabled:opacity-40",
|
|
340
|
+
className,
|
|
341
|
+
)}
|
|
342
|
+
{...props}
|
|
343
|
+
>
|
|
344
|
+
{children}
|
|
345
|
+
</button>
|
|
346
|
+
)
|
|
347
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { MoreHorizontal } from "lucide-react"
|
|
4
|
+
import { MessageAction, MessageActions, type MessageRating } from "./message-actions"
|
|
5
|
+
import { TooltipProvider } from "./tooltip"
|
|
6
|
+
|
|
7
|
+
const ANSWER = "Yas Acres townhouses with a private garden start at AED 4.2M."
|
|
8
|
+
|
|
9
|
+
const meta: Meta<typeof MessageActions> = {
|
|
10
|
+
title: "Components/MessageActions",
|
|
11
|
+
component: MessageActions,
|
|
12
|
+
tags: ["autodocs"],
|
|
13
|
+
decorators: [(Story) => <TooltipProvider><Story /></TooltipProvider>],
|
|
14
|
+
argTypes: { onRate: { control: false }, onRegenerate: { control: false }, onCopy: { control: false } },
|
|
15
|
+
args: { copyText: ANSWER },
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default meta
|
|
19
|
+
type Story = StoryObj<typeof MessageActions>
|
|
20
|
+
|
|
21
|
+
function RatingDemo(props: React.ComponentProps<typeof MessageActions>) {
|
|
22
|
+
const [rating, setRating] = React.useState<MessageRating | null>(null)
|
|
23
|
+
return (
|
|
24
|
+
<MessageActions
|
|
25
|
+
{...props}
|
|
26
|
+
rating={rating}
|
|
27
|
+
onRate={(next) => setRating((current) => (current === next ? null : next))}
|
|
28
|
+
onRegenerate={() => undefined}
|
|
29
|
+
/>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const Default: Story = { render: (args) => <RatingDemo {...args} /> }
|
|
34
|
+
|
|
35
|
+
export const CopyOnly: Story = {}
|
|
36
|
+
|
|
37
|
+
export const Regenerating: Story = { render: (args) => <RatingDemo {...args} regenerating /> }
|
|
38
|
+
|
|
39
|
+
export const WithExtraAction: Story = {
|
|
40
|
+
render: (args) => (
|
|
41
|
+
<RatingDemo {...args} role="toolbar" aria-label="Answer actions">
|
|
42
|
+
<MessageAction label="More actions"><MoreHorizontal /></MessageAction>
|
|
43
|
+
</RatingDemo>
|
|
44
|
+
),
|
|
45
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
4
|
+
import { MessageActions } from "./message-actions"
|
|
5
|
+
import { TooltipProvider } from "./tooltip"
|
|
6
|
+
|
|
7
|
+
const render = (node: React.ReactNode) => renderToStaticMarkup(<TooltipProvider>{node}</TooltipProvider>)
|
|
8
|
+
|
|
9
|
+
describe("MessageActions", () => {
|
|
10
|
+
it("shows only the actions it has handlers or text for", () => {
|
|
11
|
+
const html = render(<MessageActions copyText="Answer" />)
|
|
12
|
+
expect(html).toContain('aria-label="Copy"')
|
|
13
|
+
expect(html).not.toContain("Good response")
|
|
14
|
+
expect(html).not.toContain("Regenerate")
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("marks the current rating as pressed and the other as not", () => {
|
|
18
|
+
const html = render(<MessageActions rating="up" onRate={() => undefined} />)
|
|
19
|
+
expect(html).toMatch(/aria-label="Good response" aria-pressed="true"/)
|
|
20
|
+
expect(html).toMatch(/aria-label="Bad response" aria-pressed="false"/)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("disables Regenerate while regenerating", () => {
|
|
24
|
+
const html = render(<MessageActions onRegenerate={() => undefined} regenerating />)
|
|
25
|
+
expect(html).toMatch(/<button[^>]*disabled=""[^>]*aria-label="Regenerate"|<button[^>]*aria-label="Regenerate"[^>]*disabled=""/)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("hides until hover or focus only when asked", () => {
|
|
29
|
+
expect(render(<MessageActions copyText="Answer" />)).not.toContain("ax:opacity-0")
|
|
30
|
+
expect(render(<MessageActions copyText="Answer" revealOnHover />)).toContain("ax:group-hover/message:opacity-100")
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react"
|
|
5
|
+
import { cn } from "../lib/cn"
|
|
6
|
+
import { Button, type ButtonProps } from "./button"
|
|
7
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
|
|
8
|
+
|
|
9
|
+
export type MessageRating = "up" | "down"
|
|
10
|
+
|
|
11
|
+
export interface MessageActionProps extends Omit<ButtonProps, "variant" | "size" | "asChild"> {
|
|
12
|
+
/** Accessible name and tooltip text. */
|
|
13
|
+
label: string
|
|
14
|
+
/** Renders as a toggle: sets `aria-pressed` and tints the icon. */
|
|
15
|
+
pressed?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A round icon-only ghost button with a tooltip, sized for the action row under a message. */
|
|
19
|
+
export const MessageAction = React.forwardRef<HTMLButtonElement, MessageActionProps>(
|
|
20
|
+
({ label, pressed, className, ...props }, ref) => (
|
|
21
|
+
<Tooltip>
|
|
22
|
+
<TooltipTrigger asChild>
|
|
23
|
+
<Button
|
|
24
|
+
ref={ref}
|
|
25
|
+
variant="ghost"
|
|
26
|
+
size="icon-sm"
|
|
27
|
+
aria-label={label}
|
|
28
|
+
aria-pressed={pressed}
|
|
29
|
+
className={cn("ax:rounded-full ax:text-muted-foreground ax:hover:text-foreground", pressed && "ax:text-primary-foreground", className)}
|
|
30
|
+
{...props}
|
|
31
|
+
/>
|
|
32
|
+
</TooltipTrigger>
|
|
33
|
+
<TooltipContent>{label}</TooltipContent>
|
|
34
|
+
</Tooltip>
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
MessageAction.displayName = "MessageAction"
|
|
38
|
+
|
|
39
|
+
const COPIED_MS = 1500
|
|
40
|
+
|
|
41
|
+
export interface MessageActionsProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
42
|
+
/** Text the Copy action writes to the clipboard. Omit to hide Copy. */
|
|
43
|
+
copyText?: string
|
|
44
|
+
onCopy?: () => void
|
|
45
|
+
rating?: MessageRating | null
|
|
46
|
+
/** Called with the clicked rating; the caller decides whether a second click clears it. Omit to hide rating. */
|
|
47
|
+
onRate?: (rating: MessageRating) => void
|
|
48
|
+
/** Omit to hide Regenerate. */
|
|
49
|
+
onRegenerate?: () => void
|
|
50
|
+
/** Spins the Regenerate icon and disables it. */
|
|
51
|
+
regenerating?: boolean
|
|
52
|
+
regenerateDisabled?: boolean
|
|
53
|
+
/**
|
|
54
|
+
* Hides the row until the message is hovered or the row holds focus. Needs a
|
|
55
|
+
* `group/message` ancestor (ChatMessage sets one); touch screens always show it.
|
|
56
|
+
*/
|
|
57
|
+
revealOnHover?: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* MessageActions - the row of icon actions under an assistant message: copy,
|
|
62
|
+
* rate up or down, and regenerate. Extra actions (a version pager, a More
|
|
63
|
+
* menu) go in `children`, after the built-in ones.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* <MessageActions copyText={answer} rating={rating} onRate={setRating} onRegenerate={regenerate} />
|
|
67
|
+
*/
|
|
68
|
+
export const MessageActions = React.forwardRef<HTMLDivElement, MessageActionsProps>(
|
|
69
|
+
({ copyText, onCopy, rating = null, onRate, onRegenerate, regenerating, regenerateDisabled, revealOnHover, className, children, ...props }, ref) => {
|
|
70
|
+
const [copied, setCopied] = React.useState(false)
|
|
71
|
+
const timer = React.useRef<number | undefined>(undefined)
|
|
72
|
+
React.useEffect(() => () => window.clearTimeout(timer.current), [])
|
|
73
|
+
|
|
74
|
+
function copy() {
|
|
75
|
+
if (copyText === undefined) return
|
|
76
|
+
void navigator.clipboard?.writeText(copyText)
|
|
77
|
+
setCopied(true)
|
|
78
|
+
window.clearTimeout(timer.current)
|
|
79
|
+
timer.current = window.setTimeout(() => setCopied(false), COPIED_MS)
|
|
80
|
+
onCopy?.()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
ref={ref}
|
|
86
|
+
data-slot="message-actions"
|
|
87
|
+
className={cn(
|
|
88
|
+
"ax:-ms-xs ax:flex ax:flex-wrap ax:items-center",
|
|
89
|
+
revealOnHover && "ax:opacity-0 ax:transition-opacity ax:duration-150 ax:focus-within:opacity-100 ax:group-hover/message:opacity-100 ax:pointer-coarse:opacity-100 ax:motion-reduce:transition-none",
|
|
90
|
+
className,
|
|
91
|
+
)}
|
|
92
|
+
{...props}
|
|
93
|
+
>
|
|
94
|
+
{copyText !== undefined && (
|
|
95
|
+
<MessageAction label={copied ? "Copied" : "Copy"} onClick={copy}>
|
|
96
|
+
{copied ? <Check /> : <Copy />}
|
|
97
|
+
</MessageAction>
|
|
98
|
+
)}
|
|
99
|
+
{onRate && (
|
|
100
|
+
<>
|
|
101
|
+
<MessageAction label="Good response" pressed={rating === "up"} onClick={() => onRate("up")}>
|
|
102
|
+
<ThumbsUp className={cn(rating === "up" && "ax:fill-current")} />
|
|
103
|
+
</MessageAction>
|
|
104
|
+
<MessageAction label="Bad response" pressed={rating === "down"} onClick={() => onRate("down")}>
|
|
105
|
+
<ThumbsDown className={cn(rating === "down" && "ax:fill-current")} />
|
|
106
|
+
</MessageAction>
|
|
107
|
+
</>
|
|
108
|
+
)}
|
|
109
|
+
{onRegenerate && (
|
|
110
|
+
<MessageAction label="Regenerate" disabled={regenerating || regenerateDisabled} onClick={onRegenerate}>
|
|
111
|
+
<RotateCcw className={cn(regenerating && "ax:animate-spin ax:motion-reduce:animate-none")} />
|
|
112
|
+
</MessageAction>
|
|
113
|
+
)}
|
|
114
|
+
{children}
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
MessageActions.displayName = "MessageActions"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { Bookmark, FilePen, Home, Radio, Settings } from "lucide-react"
|
|
4
|
+
import { NavSidebar } from "./nav-sidebar"
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof NavSidebar> = {
|
|
7
|
+
title: "Components/NavSidebar",
|
|
8
|
+
component: NavSidebar,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof NavSidebar>
|
|
14
|
+
|
|
15
|
+
export const Default: Story = {
|
|
16
|
+
render: () => (
|
|
17
|
+
<div className="ax:h-[320px] ax:border ax:border-border ax:rounded-md ax:overflow-hidden">
|
|
18
|
+
<NavSidebar
|
|
19
|
+
currentPath="/"
|
|
20
|
+
brand={<span className="ax:t-label-sm ax:font-medium">Adres</span>}
|
|
21
|
+
sections={[
|
|
22
|
+
{
|
|
23
|
+
items: [
|
|
24
|
+
{ label: "Home", href: "/", icon: <Home className="ax:size-4" /> },
|
|
25
|
+
{ label: "Settings", href: "/settings", icon: <Settings className="ax:size-4" /> },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
]}
|
|
29
|
+
/>
|
|
30
|
+
</div>
|
|
31
|
+
),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Sections with labels and trailing counts; a count of 0 is hidden. */
|
|
35
|
+
export const WithCounts: Story = {
|
|
36
|
+
render: () => (
|
|
37
|
+
<div className="ax:h-[420px] ax:border ax:border-border ax:rounded-md ax:overflow-hidden">
|
|
38
|
+
<NavSidebar
|
|
39
|
+
currentPath="/saved-properties"
|
|
40
|
+
brand={<span className="ax:t-label-sm ax:font-medium">Adres</span>}
|
|
41
|
+
sections={[
|
|
42
|
+
{ items: [{ label: "Dashboard", href: "/dashboard", icon: <Home className="ax:size-4" /> }] },
|
|
43
|
+
{
|
|
44
|
+
label: "Discover",
|
|
45
|
+
items: [{ label: "Saved Properties", href: "/saved-properties", icon: <Bookmark className="ax:size-4" />, count: 3 }],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
label: "My Properties",
|
|
49
|
+
items: [
|
|
50
|
+
{ label: "Drafts", href: "/my-properties/drafts", icon: <FilePen className="ax:size-4" />, count: 4 },
|
|
51
|
+
{ label: "Live", href: "/my-properties/live", icon: <Radio className="ax:size-4" />, count: 0 },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
]}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
),
|
|
58
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
4
|
+
import { NavSidebar, type NavSection } from "./nav-sidebar"
|
|
5
|
+
|
|
6
|
+
const SECTIONS: NavSection[] = [
|
|
7
|
+
{
|
|
8
|
+
items: [
|
|
9
|
+
{ href: "/saved", label: "Saved Properties", icon: <svg />, count: 3 },
|
|
10
|
+
{ href: "/drafts", label: "Drafts", icon: <svg />, count: 0 },
|
|
11
|
+
{ href: "/deals", label: "Deals", icon: <svg />, count: 5, children: [{ href: "/deals/open", label: "Open", count: 2 }] },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
describe("NavSidebar counts", () => {
|
|
17
|
+
it("renders a top-level item's count with an accessible label", () => {
|
|
18
|
+
const html = renderToStaticMarkup(<NavSidebar sections={SECTIONS} currentPath="/saved" />)
|
|
19
|
+
expect(html).toContain('aria-label="3 items"')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("hides a zero count", () => {
|
|
23
|
+
const html = renderToStaticMarkup(<NavSidebar sections={SECTIONS} currentPath="/saved" />)
|
|
24
|
+
expect(html).not.toContain('aria-label="0 items"')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("leaves the trailing space to the chevron on items with children", () => {
|
|
28
|
+
const html = renderToStaticMarkup(<NavSidebar sections={SECTIONS} currentPath="/saved" />)
|
|
29
|
+
expect(html).not.toContain('aria-label="5 items"')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("still renders sub-item counts when the branch is open", () => {
|
|
33
|
+
const html = renderToStaticMarkup(<NavSidebar sections={SECTIONS} currentPath="/deals/open" />)
|
|
34
|
+
expect(html).toContain('aria-label="2 items"')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("drops counts from collapsed icon-only rows", () => {
|
|
38
|
+
const html = renderToStaticMarkup(<NavSidebar sections={SECTIONS} currentPath="/saved" collapsed />)
|
|
39
|
+
expect(html).not.toContain('aria-label="3 items"')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe("NavSidebar collapsed", () => {
|
|
44
|
+
it("names icon-only rows with their label and count", () => {
|
|
45
|
+
const html = renderToStaticMarkup(<NavSidebar sections={SECTIONS} currentPath="/saved" collapsed />)
|
|
46
|
+
expect(html).toContain('aria-label="Saved Properties, 3 items"')
|
|
47
|
+
expect(html).toContain('aria-label="Drafts"')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("does not add an aria-label while expanded, where the visible label names the row", () => {
|
|
51
|
+
const html = renderToStaticMarkup(<NavSidebar sections={SECTIONS} currentPath="/saved" />)
|
|
52
|
+
expect(html).not.toContain('aria-label="Saved Properties')
|
|
53
|
+
})
|
|
54
|
+
})
|