@adres/mgda-ui 0.0.0-stage → 0.0.2
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,220 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { ExternalLink, FileText, TriangleAlert } from "lucide-react"
|
|
5
|
+
import { cn } from "../lib/cn"
|
|
6
|
+
import { Avatar, AvatarFallback } from "./avatar"
|
|
7
|
+
import { Badge } from "./badge"
|
|
8
|
+
import { Button } from "./button"
|
|
9
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover"
|
|
10
|
+
import { Text } from "./text"
|
|
11
|
+
import { TextLink } from "./text-link"
|
|
12
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
|
|
13
|
+
|
|
14
|
+
/** What kind of source it is, e.g. `{ label: "Listing", icon: Home }`. */
|
|
15
|
+
export interface CitationSourceKind {
|
|
16
|
+
label: string
|
|
17
|
+
icon: React.ElementType
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CitationSource {
|
|
21
|
+
/** The number shown in markers and the list. */
|
|
22
|
+
id: number
|
|
23
|
+
title: string
|
|
24
|
+
publisher: string
|
|
25
|
+
/** ISO date (YYYY-MM-DD) the source was published or last updated. */
|
|
26
|
+
date: string
|
|
27
|
+
url: string
|
|
28
|
+
/** The passage the answer relied on. */
|
|
29
|
+
excerpt: string
|
|
30
|
+
kind?: CitationSourceKind
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DAY_MS = 86_400_000
|
|
34
|
+
const dateFormat = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" })
|
|
35
|
+
|
|
36
|
+
/** "6 Oct 2026", always in UTC so server and client render the same. */
|
|
37
|
+
export const formatSourceDate = (iso: string) => dateFormat.format(new Date(`${iso}T00:00:00Z`))
|
|
38
|
+
|
|
39
|
+
/** True when `date` is more than `days` before `asOf` (both ISO dates). */
|
|
40
|
+
export const isStale = (date: string, asOf: string, days = 183) => (Date.parse(asOf) - Date.parse(date)) / DAY_MS > days
|
|
41
|
+
|
|
42
|
+
export interface SourceAvatarProps {
|
|
43
|
+
kind?: CitationSourceKind
|
|
44
|
+
size?: "xs" | "sm"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The source kind's icon in a small muted avatar; stacks in an `AvatarStack`. */
|
|
48
|
+
export function SourceAvatar({ kind, size = "sm" }: SourceAvatarProps) {
|
|
49
|
+
const Icon = kind?.icon ?? FileText
|
|
50
|
+
return (
|
|
51
|
+
<Avatar size={size}>
|
|
52
|
+
<AvatarFallback className="ax:bg-muted ax:text-muted-foreground">
|
|
53
|
+
<Icon className={size === "xs" ? "ax:size-3" : "ax:size-4"} aria-hidden />
|
|
54
|
+
</AvatarFallback>
|
|
55
|
+
</Avatar>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CitationMarkerProps {
|
|
60
|
+
source: CitationSource
|
|
61
|
+
/** Adds "Show in sources" to the preview, e.g. to scroll to and highlight the source in a list. */
|
|
62
|
+
onShow?: (id: number) => void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const PILL = "ax:inline-flex ax:h-5 ax:items-center ax:gap-1 ax:rounded-full ax:bg-muted ax:px-1.5 ax:text-xs ax:tabular-nums ax:text-muted-foreground"
|
|
66
|
+
|
|
67
|
+
/** A small numbered pill in running text; opens a preview of the source and the passage used. */
|
|
68
|
+
export function CitationMarker({ source, onShow }: CitationMarkerProps) {
|
|
69
|
+
const Icon = source.kind?.icon
|
|
70
|
+
return (
|
|
71
|
+
<Popover>
|
|
72
|
+
<PopoverTrigger asChild>
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
aria-label={`Source ${source.id}: ${source.title}`}
|
|
76
|
+
className={cn(
|
|
77
|
+
PILL,
|
|
78
|
+
"ax:mx-0.5 ax:align-text-top ax:transition-colors ax:hover:bg-muted-foreground/20 ax:hover:text-foreground",
|
|
79
|
+
"ax:focus-visible:outline-none ax:focus-visible:ring-2 ax:focus-visible:ring-ring",
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
{Icon && <Icon className="ax:size-3" aria-hidden />}
|
|
83
|
+
{source.id}
|
|
84
|
+
</button>
|
|
85
|
+
</PopoverTrigger>
|
|
86
|
+
<PopoverContent align="start" className="ax:w-80 ax:p-0 ax:shadow-xs">
|
|
87
|
+
<div className="ax:flex ax:flex-col ax:gap-xs ax:p-sm">
|
|
88
|
+
<div className="ax:flex ax:items-center ax:gap-1.5">
|
|
89
|
+
<SourceAvatar kind={source.kind} size="xs" />
|
|
90
|
+
<Text size="1" color="muted" truncate>{source.publisher} · {formatSourceDate(source.date)}</Text>
|
|
91
|
+
</div>
|
|
92
|
+
<Text as="p" size="2" weight="medium" className="ax:line-clamp-2">{source.title}</Text>
|
|
93
|
+
<Text as="p" size="2" color="muted" className="ax:line-clamp-3">{source.excerpt}</Text>
|
|
94
|
+
</div>
|
|
95
|
+
<div className="ax:flex ax:items-center ax:justify-between ax:gap-sm ax:border-t ax:border-border ax:px-sm ax:py-xs">
|
|
96
|
+
{onShow ? (
|
|
97
|
+
<TextLink asChild size="1">
|
|
98
|
+
<button type="button" onClick={() => onShow(source.id)}>Show in sources</button>
|
|
99
|
+
</TextLink>
|
|
100
|
+
) : <span />}
|
|
101
|
+
<TextLink href={source.url} size="1" className="ax:inline-flex ax:items-center ax:gap-1">
|
|
102
|
+
Open <ExternalLink className="ax:size-3" aria-hidden />
|
|
103
|
+
</TextLink>
|
|
104
|
+
</div>
|
|
105
|
+
</PopoverContent>
|
|
106
|
+
</Popover>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface UnsupportedClaimProps {
|
|
111
|
+
children: React.ReactNode
|
|
112
|
+
/** Tooltip explaining the mark. */
|
|
113
|
+
note?: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A claim no source backs: the text gets a dashed warning underline and a
|
|
118
|
+
* warning icon. The icon is a real button so keyboard users reach the note.
|
|
119
|
+
*/
|
|
120
|
+
export function UnsupportedClaim({ children, note = "Not backed by a source. Treat this as the assistant's estimate." }: UnsupportedClaimProps) {
|
|
121
|
+
return (
|
|
122
|
+
<>
|
|
123
|
+
<span className="ax:underline ax:decoration-warning ax:decoration-dashed ax:underline-offset-4">{children}</span>
|
|
124
|
+
<Tooltip>
|
|
125
|
+
<TooltipTrigger asChild>
|
|
126
|
+
<TextLink asChild underline="none" color="default" className="ax:ms-1 ax:align-text-top">
|
|
127
|
+
<button type="button" aria-label="Not backed by a source">
|
|
128
|
+
<TriangleAlert className="ax:size-3.5 ax:text-warning-foreground" aria-hidden />
|
|
129
|
+
</button>
|
|
130
|
+
</TextLink>
|
|
131
|
+
</TooltipTrigger>
|
|
132
|
+
<TooltipContent>{note}</TooltipContent>
|
|
133
|
+
</Tooltip>
|
|
134
|
+
</>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface SourceCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
139
|
+
source: CitationSource
|
|
140
|
+
/** ISO "today" for the age check. Without it, no stale badge. Pass a fixed date to render the same on server and client. */
|
|
141
|
+
asOf?: string
|
|
142
|
+
/** Days after which the source is marked old. */
|
|
143
|
+
staleAfterDays?: number
|
|
144
|
+
staleLabel?: string
|
|
145
|
+
/** Rings the card, e.g. after "Show in sources". */
|
|
146
|
+
highlighted?: boolean
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One numbered source: linked title, publisher, kind and date, an age warning, and the passage used. */
|
|
150
|
+
export const SourceCard = React.forwardRef<HTMLDivElement, SourceCardProps>(
|
|
151
|
+
({ source, asOf, staleAfterDays = 183, staleLabel = "Older than 6 months", highlighted, className, ...props }, ref) => {
|
|
152
|
+
const stale = asOf !== undefined && isStale(source.date, asOf, staleAfterDays)
|
|
153
|
+
return (
|
|
154
|
+
<div
|
|
155
|
+
ref={ref}
|
|
156
|
+
data-slot="source-card"
|
|
157
|
+
tabIndex={-1}
|
|
158
|
+
className={cn(
|
|
159
|
+
"ax:flex ax:items-start ax:gap-sm ax:rounded-md ax:border ax:border-border ax:bg-card ax:p-sm ax:transition-shadow ax:outline-none",
|
|
160
|
+
highlighted && "ax:ring-2 ax:ring-ring",
|
|
161
|
+
className,
|
|
162
|
+
)}
|
|
163
|
+
{...props}
|
|
164
|
+
>
|
|
165
|
+
<span className={cn(PILL, "ax:mt-0.5 ax:shrink-0")}><span className="ax:sr-only">Source </span>{source.id}</span>
|
|
166
|
+
<div className="ax:flex ax:min-w-0 ax:flex-1 ax:flex-col ax:gap-1">
|
|
167
|
+
<TextLink href={source.url} color="default" size="2" className="ax:inline-flex ax:items-center ax:gap-1">
|
|
168
|
+
<span className="ax:truncate">{source.title}</span>
|
|
169
|
+
<ExternalLink className="ax:size-3 ax:shrink-0" aria-hidden />
|
|
170
|
+
</TextLink>
|
|
171
|
+
<div className="ax:flex ax:flex-wrap ax:items-center ax:gap-x-sm ax:gap-y-1">
|
|
172
|
+
<Text size="1" color="muted">{source.publisher}</Text>
|
|
173
|
+
{source.kind && <Text size="1" color="muted">{source.kind.label}</Text>}
|
|
174
|
+
<Text size="1" color="muted">{formatSourceDate(source.date)}</Text>
|
|
175
|
+
{stale && <Badge variant="warning" size="sm">{staleLabel}</Badge>}
|
|
176
|
+
</div>
|
|
177
|
+
{/* The rule hangs in the gutter (-ms = border + ps) so the quote's text lines up with the title. */}
|
|
178
|
+
<Text as="p" size="2" color="muted" className="ax:mt-xs ax:-ms-2 ax:border-s-2 ax:border-muted ax:ps-1.5">{source.excerpt}</Text>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
SourceCard.displayName = "SourceCard"
|
|
185
|
+
|
|
186
|
+
export interface SourceListProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
187
|
+
sources: CitationSource[]
|
|
188
|
+
asOf?: string
|
|
189
|
+
/** DOM id for each card, so a marker's "Show in sources" can scroll to and focus it. */
|
|
190
|
+
getSourceId?: (id: number) => string
|
|
191
|
+
/** The source id to ring. */
|
|
192
|
+
highlighted?: number | null
|
|
193
|
+
/** How many show before "Show N more". Defaults to all. */
|
|
194
|
+
initial?: number
|
|
195
|
+
onExpand?: () => void
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Numbered sources; beyond `initial` they collapse behind "Show N more". */
|
|
199
|
+
export const SourceList = React.forwardRef<HTMLDivElement, SourceListProps>(
|
|
200
|
+
({ sources, asOf, getSourceId, highlighted, initial = sources.length, onExpand, className, ...props }, ref) => {
|
|
201
|
+
const hidden = sources.length - initial
|
|
202
|
+
return (
|
|
203
|
+
<div ref={ref} data-slot="source-list" className={cn("ax:flex ax:flex-col ax:gap-sm", className)} {...props}>
|
|
204
|
+
<ol className="ax:flex ax:flex-col ax:gap-sm" aria-label="Sources">
|
|
205
|
+
{sources.slice(0, initial).map((source) => (
|
|
206
|
+
<li key={source.id}>
|
|
207
|
+
<SourceCard source={source} asOf={asOf} id={getSourceId?.(source.id)} highlighted={highlighted === source.id} />
|
|
208
|
+
</li>
|
|
209
|
+
))}
|
|
210
|
+
</ol>
|
|
211
|
+
{hidden > 0 && onExpand && (
|
|
212
|
+
<Button variant="ghost" size="sm" className="ax:self-start ax:rounded-full" onClick={onExpand}>
|
|
213
|
+
Show {hidden} more {hidden === 1 ? "source" : "sources"}
|
|
214
|
+
</Button>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
)
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
SourceList.displayName = "SourceList"
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { expect, userEvent, waitFor, within } from "@storybook/test"
|
|
4
|
+
import { Clock, Home, MapPin } from "lucide-react"
|
|
5
|
+
import { Combobox, type ComboboxItem } from "./combobox"
|
|
6
|
+
import { Label } from "./input"
|
|
7
|
+
|
|
8
|
+
const COMMUNITIES: ComboboxItem[] = [
|
|
9
|
+
{ value: "al-maryah", label: "Al Maryah Island" },
|
|
10
|
+
{ value: "al-reem", label: "Al Reem Island" },
|
|
11
|
+
{ value: "saadiyat", label: "Al Saadiyat Island", keywords: "Saadiyat" },
|
|
12
|
+
{ value: "khalifa-city", label: "Khalifa City" },
|
|
13
|
+
{ value: "yas", label: "Yas Island" },
|
|
14
|
+
{ value: "jubail", label: "Jubail Island", disabled: true, description: "Coming soon" },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
const EVERYTHING: ComboboxItem[] = [
|
|
18
|
+
{ value: "p-1316", label: "Saadiyat Lagoons, Apt 1316", group: "Properties", description: "AED 4,162,000", keywords: "Al Saadiyat Island", icon: <Home aria-hidden /> },
|
|
19
|
+
{ value: "p-910", label: "Soho Square, Apt 910", group: "Properties", description: "AED 5,793,000", keywords: "Al Saadiyat Island", icon: <Home aria-hidden /> },
|
|
20
|
+
{ value: "p-218", label: "Yas Acres, Townhouse 218", group: "Properties", description: "AED 4,478,000", keywords: "Yas Island", icon: <Home aria-hidden /> },
|
|
21
|
+
{ value: "c-saadiyat", label: "Al Saadiyat Island", group: "Communities", description: "2 listings", icon: <MapPin aria-hidden /> },
|
|
22
|
+
{ value: "c-yas", label: "Yas Island", group: "Communities", description: "1 listing", icon: <MapPin aria-hidden /> },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
const meta: Meta<typeof Combobox> = {
|
|
26
|
+
title: "Components/Combobox",
|
|
27
|
+
component: Combobox,
|
|
28
|
+
tags: ["autodocs"],
|
|
29
|
+
args: { items: COMMUNITIES, placeholder: "Choose a community", "aria-label": "Community" },
|
|
30
|
+
decorators: [(Story) => <div style={{ maxWidth: 420, minHeight: 360 }}><Story /></div>],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default meta
|
|
34
|
+
type Story = StoryObj<typeof Combobox>
|
|
35
|
+
|
|
36
|
+
/** Picker: the chosen label stays in the input. */
|
|
37
|
+
export const Default: Story = {
|
|
38
|
+
render: (args) => {
|
|
39
|
+
const [value, setValue] = React.useState<string | null>(null)
|
|
40
|
+
return <Combobox {...args} value={value} onValueChange={setValue} />
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const WithLabel: Story = {
|
|
45
|
+
render: (args) => {
|
|
46
|
+
const [value, setValue] = React.useState<string | null>("yas")
|
|
47
|
+
return (
|
|
48
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
49
|
+
<Label htmlFor="community">Community</Label>
|
|
50
|
+
<Combobox {...args} id="community" aria-label={undefined} value={value} onValueChange={setValue} />
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Search launcher: grouped results, recent searches while empty, the input clears after a pick. */
|
|
57
|
+
export const SearchLauncher: Story = {
|
|
58
|
+
args: { items: EVERYTHING, placeholder: "Search properties or communities", "aria-label": "Search", size: "lg", maxPerGroup: 4 },
|
|
59
|
+
render: (args) => {
|
|
60
|
+
const [query, setQuery] = React.useState("")
|
|
61
|
+
const [last, setLast] = React.useState<string>()
|
|
62
|
+
return (
|
|
63
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
64
|
+
<Combobox
|
|
65
|
+
{...args}
|
|
66
|
+
query={query}
|
|
67
|
+
onQueryChange={setQuery}
|
|
68
|
+
onSelect={(item) => setLast(item.label)}
|
|
69
|
+
startContent={
|
|
70
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 4, padding: 8 }}>
|
|
71
|
+
{["Saadiyat", "Yas"].map((term) => (
|
|
72
|
+
<button key={term} type="button" onClick={() => setQuery(term)} style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
|
73
|
+
<Clock size={14} aria-hidden /> {term}
|
|
74
|
+
</button>
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
}
|
|
78
|
+
/>
|
|
79
|
+
<span data-testid="last-pick">{last ? `Opened ${last}` : ""}</span>
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const Loading: Story = {
|
|
86
|
+
args: { loading: true, query: "yas", loadingMessage: "Searching communities" },
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const Invalid: Story = {
|
|
90
|
+
args: { invalid: true },
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Interaction test: type, move with the keyboard, pick with Enter. */
|
|
94
|
+
export const KeyboardSelection: Story = {
|
|
95
|
+
...Default,
|
|
96
|
+
play: async ({ canvasElement }) => {
|
|
97
|
+
const canvas = within(canvasElement)
|
|
98
|
+
const page = within(canvasElement.ownerDocument.body)
|
|
99
|
+
const input = canvas.getByRole("combobox", { name: "Community" })
|
|
100
|
+
|
|
101
|
+
await userEvent.click(input)
|
|
102
|
+
await userEvent.type(input, "island")
|
|
103
|
+
await waitFor(() => expect(page.getByRole("listbox")).toBeVisible())
|
|
104
|
+
await expect(input).toHaveAttribute("aria-expanded", "true")
|
|
105
|
+
|
|
106
|
+
// First match is highlighted; ArrowDown moves on; the disabled Jubail row is skipped.
|
|
107
|
+
await userEvent.keyboard("{ArrowDown}")
|
|
108
|
+
const activeId = input.getAttribute("aria-activedescendant")
|
|
109
|
+
await expect(canvasElement.ownerDocument.getElementById(activeId ?? "")).toHaveTextContent("Al Reem Island")
|
|
110
|
+
|
|
111
|
+
await userEvent.keyboard("{Enter}")
|
|
112
|
+
await expect(input).toHaveValue("Al Reem Island")
|
|
113
|
+
await expect(input).toHaveAttribute("aria-expanded", "false")
|
|
114
|
+
|
|
115
|
+
await userEvent.keyboard("{ArrowDown}")
|
|
116
|
+
await waitFor(() => expect(page.getByRole("listbox")).toBeVisible())
|
|
117
|
+
await userEvent.keyboard("{Escape}")
|
|
118
|
+
await expect(input).toHaveAttribute("aria-expanded", "false")
|
|
119
|
+
await expect(input).toHaveValue("Al Reem Island")
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Interaction test: grouped results by mouse, then the input clears for the next search. */
|
|
124
|
+
export const SearchSelection: Story = {
|
|
125
|
+
...SearchLauncher,
|
|
126
|
+
play: async ({ canvasElement }) => {
|
|
127
|
+
const canvas = within(canvasElement)
|
|
128
|
+
const page = within(canvasElement.ownerDocument.body)
|
|
129
|
+
const input = canvas.getByRole("combobox", { name: "Search" })
|
|
130
|
+
|
|
131
|
+
await userEvent.type(input, "saadiyat")
|
|
132
|
+
await waitFor(() => expect(page.getAllByRole("group")).toHaveLength(2))
|
|
133
|
+
await expect(page.getAllByRole("option")).toHaveLength(3)
|
|
134
|
+
|
|
135
|
+
await userEvent.click(page.getByRole("option", { name: /Soho Square/ }))
|
|
136
|
+
await expect(canvas.getByTestId("last-pick")).toHaveTextContent("Opened Soho Square, Apt 910")
|
|
137
|
+
await expect(input).toHaveValue("")
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const DEVELOPERS = ["Aldar Properties", "Bloom Holding", "Imkan", "Modon", "Reportage"]
|
|
142
|
+
|
|
143
|
+
/** Remote picker: results arrive after a delay; the picked label survives the list emptying. */
|
|
144
|
+
export const RemoteResults: Story = {
|
|
145
|
+
args: { "aria-label": "Developer", placeholder: "Search developers" },
|
|
146
|
+
render: (args) => {
|
|
147
|
+
const [query, setQuery] = React.useState("")
|
|
148
|
+
const [value, setValue] = React.useState<string | null>(null)
|
|
149
|
+
const [items, setItems] = React.useState<ComboboxItem[]>([])
|
|
150
|
+
const [loading, setLoading] = React.useState(false)
|
|
151
|
+
React.useEffect(() => {
|
|
152
|
+
const term = query.trim().toLowerCase()
|
|
153
|
+
if (!term) return setItems([])
|
|
154
|
+
setLoading(true)
|
|
155
|
+
const timer = setTimeout(() => {
|
|
156
|
+
setItems(DEVELOPERS.filter((name) => name.toLowerCase().includes(term)).map((name) => ({ value: name, label: name })))
|
|
157
|
+
setLoading(false)
|
|
158
|
+
}, 300)
|
|
159
|
+
return () => clearTimeout(timer)
|
|
160
|
+
}, [query])
|
|
161
|
+
return (
|
|
162
|
+
<Combobox
|
|
163
|
+
{...args}
|
|
164
|
+
items={items}
|
|
165
|
+
query={query}
|
|
166
|
+
onQueryChange={setQuery}
|
|
167
|
+
filter={() => true}
|
|
168
|
+
loading={loading}
|
|
169
|
+
value={value}
|
|
170
|
+
onValueChange={setValue}
|
|
171
|
+
/>
|
|
172
|
+
)
|
|
173
|
+
},
|
|
174
|
+
play: async ({ canvasElement }) => {
|
|
175
|
+
const canvas = within(canvasElement)
|
|
176
|
+
const page = within(canvasElement.ownerDocument.body)
|
|
177
|
+
const input = canvas.getByRole("combobox", { name: "Developer" })
|
|
178
|
+
|
|
179
|
+
await userEvent.type(input, "o")
|
|
180
|
+
await waitFor(() => expect(page.getAllByRole("option")).toHaveLength(4))
|
|
181
|
+
await userEvent.keyboard("{ArrowDown}{Enter}")
|
|
182
|
+
await expect(input).toHaveValue("Bloom Holding")
|
|
183
|
+
},
|
|
184
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
4
|
+
import {
|
|
5
|
+
Combobox, defaultComboboxFilter, groupComboboxItems, nextEnabledIndex, splitComboboxMatch,
|
|
6
|
+
type ComboboxItem,
|
|
7
|
+
} from "./combobox"
|
|
8
|
+
|
|
9
|
+
const ITEMS: ComboboxItem[] = [
|
|
10
|
+
{ value: "yas", label: "Yas Island", group: "Communities", keywords: "Abu Dhabi" },
|
|
11
|
+
{ value: "reem", label: "Al Reem Island", group: "Communities", disabled: true },
|
|
12
|
+
{ value: "saadiyat", label: "Saadiyat Lagoons", group: "Projects", description: "AED 4.1M" },
|
|
13
|
+
{ value: "soho", label: "Soho Square", group: "Projects" },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
describe("defaultComboboxFilter", () => {
|
|
17
|
+
it("matches label, description and keywords without case", () => {
|
|
18
|
+
expect(defaultComboboxFilter(ITEMS[0]!, "ISLAND")).toBe(true)
|
|
19
|
+
expect(defaultComboboxFilter(ITEMS[0]!, "abu dhabi")).toBe(true)
|
|
20
|
+
expect(defaultComboboxFilter(ITEMS[2]!, "4.1m")).toBe(true)
|
|
21
|
+
expect(defaultComboboxFilter(ITEMS[3]!, "yas")).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe("groupComboboxItems", () => {
|
|
26
|
+
it("keeps first-seen group order and caps each group", () => {
|
|
27
|
+
const groups = groupComboboxItems(ITEMS, 1)
|
|
28
|
+
expect(groups.map((group) => group.name)).toEqual(["Communities", "Projects"])
|
|
29
|
+
expect(groups.map((group) => group.items.map((item) => item.value))).toEqual([["yas"], ["saadiyat"]])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("puts ungrouped items in one unnamed group", () => {
|
|
33
|
+
const groups = groupComboboxItems([{ value: "a", label: "A" }, { value: "b", label: "B" }])
|
|
34
|
+
expect(groups).toHaveLength(1)
|
|
35
|
+
expect(groups[0]?.name).toBeUndefined()
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe("splitComboboxMatch", () => {
|
|
40
|
+
it("splits around the first case-insensitive match, keeping the original casing", () => {
|
|
41
|
+
expect(splitComboboxMatch("Yas Island", "isl")).toEqual({ before: "Yas ", match: "Isl", after: "and" })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("returns null without a match or query", () => {
|
|
45
|
+
expect(splitComboboxMatch("Yas Island", "reem")).toBeNull()
|
|
46
|
+
expect(splitComboboxMatch("Yas Island", "")).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe("nextEnabledIndex", () => {
|
|
51
|
+
it("skips disabled items and wraps in both directions", () => {
|
|
52
|
+
expect(nextEnabledIndex(ITEMS, 0, 1)).toBe(2)
|
|
53
|
+
expect(nextEnabledIndex(ITEMS, 3, 1)).toBe(0)
|
|
54
|
+
expect(nextEnabledIndex(ITEMS, 0, -1)).toBe(3)
|
|
55
|
+
expect(nextEnabledIndex(ITEMS, -1, 1)).toBe(0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("returns -1 when every item is disabled", () => {
|
|
59
|
+
expect(nextEnabledIndex([{ value: "x", label: "X", disabled: true }], -1, 1)).toBe(-1)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe("Combobox", () => {
|
|
64
|
+
it("renders a collapsed combobox input with an accessible name", () => {
|
|
65
|
+
const html = renderToStaticMarkup(<Combobox items={ITEMS} aria-label="Community" />)
|
|
66
|
+
expect(html).toContain('role="combobox"')
|
|
67
|
+
expect(html).toContain('aria-expanded="false"')
|
|
68
|
+
expect(html).toContain('aria-autocomplete="list"')
|
|
69
|
+
expect(html).toContain('aria-label="Community"')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("leaves the name to a visible label when aria-label is not set", () => {
|
|
73
|
+
const html = renderToStaticMarkup(<Combobox items={ITEMS} id="community" />)
|
|
74
|
+
expect(html).toContain('id="community"')
|
|
75
|
+
expect(html).not.toContain("aria-label=")
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("shows the selected label in picker mode", () => {
|
|
79
|
+
const html = renderToStaticMarkup(<Combobox items={ITEMS} value="soho" onValueChange={() => {}} aria-label="Project" />)
|
|
80
|
+
expect(html).toContain('value="Soho Square"')
|
|
81
|
+
})
|
|
82
|
+
})
|