@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.
Files changed (325) hide show
  1. package/brand-knowledge.json +451 -0
  2. package/knowledge/accordion.md +135 -0
  3. package/knowledge/activity-rings.md +123 -0
  4. package/knowledge/alert.md +184 -0
  5. package/knowledge/answer-actions.md +217 -0
  6. package/knowledge/aspect-ratio.md +82 -0
  7. package/knowledge/avatar.md +158 -0
  8. package/knowledge/badge.md +172 -0
  9. package/knowledge/bar-graph.md +167 -0
  10. package/knowledge/bento-grid.md +115 -0
  11. package/knowledge/blockquote.md +153 -0
  12. package/knowledge/box-list.md +177 -0
  13. package/knowledge/bullet-list.md +128 -0
  14. package/knowledge/button-group.md +179 -0
  15. package/knowledge/button.md +231 -0
  16. package/knowledge/calendar.md +168 -0
  17. package/knowledge/card.md +321 -0
  18. package/knowledge/cards-nav.md +170 -0
  19. package/knowledge/chain-of-thought.md +164 -0
  20. package/knowledge/chat-container.md +135 -0
  21. package/knowledge/chat-message.md +224 -0
  22. package/knowledge/checkbox-card.md +172 -0
  23. package/knowledge/checkbox.md +111 -0
  24. package/knowledge/citations.md +241 -0
  25. package/knowledge/combobox.md +265 -0
  26. package/knowledge/countdown-pill.md +153 -0
  27. package/knowledge/cta.md +118 -0
  28. package/knowledge/data-list.md +173 -0
  29. package/knowledge/data-table.md +220 -0
  30. package/knowledge/dialog.md +249 -0
  31. package/knowledge/drawer.md +236 -0
  32. package/knowledge/dropdown-menu.md +238 -0
  33. package/knowledge/dropzone.md +153 -0
  34. package/knowledge/email-chips-input.md +161 -0
  35. package/knowledge/empty-state.md +106 -0
  36. package/knowledge/entity-header.md +166 -0
  37. package/knowledge/feature-grid.md +120 -0
  38. package/knowledge/feedback-bar.md +127 -0
  39. package/knowledge/footer.md +116 -0
  40. package/knowledge/heading.md +189 -0
  41. package/knowledge/hero.md +361 -0
  42. package/knowledge/input-button.md +187 -0
  43. package/knowledge/input-group.md +185 -0
  44. package/knowledge/input.md +256 -0
  45. package/knowledge/inset.md +177 -0
  46. package/knowledge/kpi-card.md +206 -0
  47. package/knowledge/list-nav.md +181 -0
  48. package/knowledge/logo-cloud.md +102 -0
  49. package/knowledge/media-gallery.md +158 -0
  50. package/knowledge/message-actions.md +175 -0
  51. package/knowledge/nav-sidebar.md +225 -0
  52. package/knowledge/number-stepper.md +159 -0
  53. package/knowledge/page-header.md +109 -0
  54. package/knowledge/pagination.md +225 -0
  55. package/knowledge/popover.md +159 -0
  56. package/knowledge/pricing.md +132 -0
  57. package/knowledge/progress.md +156 -0
  58. package/knowledge/prompt-input.md +337 -0
  59. package/knowledge/prompt-suggestions.md +168 -0
  60. package/knowledge/quota-bar.md +160 -0
  61. package/knowledge/quote.md +116 -0
  62. package/knowledge/radio-card.md +172 -0
  63. package/knowledge/radio-group.md +135 -0
  64. package/knowledge/reasoning.md +142 -0
  65. package/knowledge/search.md +131 -0
  66. package/knowledge/section-nav.md +162 -0
  67. package/knowledge/segmented-control.md +181 -0
  68. package/knowledge/select-button.md +205 -0
  69. package/knowledge/select.md +210 -0
  70. package/knowledge/separator.md +75 -0
  71. package/knowledge/shimmer-text.md +140 -0
  72. package/knowledge/sidebar.md +227 -0
  73. package/knowledge/skeleton.md +142 -0
  74. package/knowledge/slider.md +133 -0
  75. package/knowledge/slideshow.md +159 -0
  76. package/knowledge/source.md +156 -0
  77. package/knowledge/spinner.md +120 -0
  78. package/knowledge/stat-strip.md +128 -0
  79. package/knowledge/stats.md +117 -0
  80. package/knowledge/step-indicator.md +157 -0
  81. package/knowledge/switch.md +88 -0
  82. package/knowledge/table.md +190 -0
  83. package/knowledge/tabs.md +169 -0
  84. package/knowledge/testimonials.md +115 -0
  85. package/knowledge/text-link.md +192 -0
  86. package/knowledge/text.md +194 -0
  87. package/knowledge/thinking-bar.md +115 -0
  88. package/knowledge/thinking-steps.md +140 -0
  89. package/knowledge/toaster.md +127 -0
  90. package/knowledge/toggle.md +113 -0
  91. package/knowledge/tool-call-card.md +241 -0
  92. package/knowledge/tooltip.md +120 -0
  93. package/knowledge/top-nav.md +138 -0
  94. package/knowledge.json +19854 -0
  95. package/package.json +94 -4
  96. package/src/components/accordion.stories.tsx +46 -0
  97. package/src/components/accordion.tsx +74 -0
  98. package/src/components/activity-rings.stories.tsx +73 -0
  99. package/src/components/activity-rings.tsx +121 -0
  100. package/src/components/alert.stories.tsx +43 -0
  101. package/src/components/alert.tsx +132 -0
  102. package/src/components/answer-actions.stories.tsx +54 -0
  103. package/src/components/answer-actions.test.tsx +37 -0
  104. package/src/components/answer-actions.tsx +313 -0
  105. package/src/components/aspect-ratio.stories.tsx +32 -0
  106. package/src/components/aspect-ratio.tsx +5 -0
  107. package/src/components/avatar.stories.tsx +40 -0
  108. package/src/components/avatar.tsx +154 -0
  109. package/src/components/badge.stories.tsx +27 -0
  110. package/src/components/badge.tsx +63 -0
  111. package/src/components/bar-graph.stories.tsx +28 -0
  112. package/src/components/bar-graph.tsx +204 -0
  113. package/src/components/bento-grid.stories.tsx +23 -0
  114. package/src/components/bento-grid.tsx +67 -0
  115. package/src/components/blockquote.stories.tsx +28 -0
  116. package/src/components/blockquote.tsx +88 -0
  117. package/src/components/box-list.stories.tsx +25 -0
  118. package/src/components/box-list.tsx +108 -0
  119. package/src/components/bullet-list.stories.tsx +22 -0
  120. package/src/components/bullet-list.tsx +101 -0
  121. package/src/components/button-group.stories.tsx +31 -0
  122. package/src/components/button-group.tsx +156 -0
  123. package/src/components/button.stories.tsx +66 -0
  124. package/src/components/button.test.ts +17 -0
  125. package/src/components/button.tsx +139 -0
  126. package/src/components/calendar.stories.tsx +25 -0
  127. package/src/components/calendar.tsx +367 -0
  128. package/src/components/card.stories.tsx +44 -0
  129. package/src/components/card.tsx +392 -0
  130. package/src/components/cards-nav.stories.tsx +35 -0
  131. package/src/components/cards-nav.tsx +115 -0
  132. package/src/components/chain-of-thought.stories.tsx +67 -0
  133. package/src/components/chain-of-thought.test.tsx +50 -0
  134. package/src/components/chain-of-thought.tsx +170 -0
  135. package/src/components/chat-container.stories.tsx +47 -0
  136. package/src/components/chat-container.test.tsx +49 -0
  137. package/src/components/chat-container.tsx +222 -0
  138. package/src/components/chat-message.stories.tsx +52 -0
  139. package/src/components/chat-message.test.tsx +36 -0
  140. package/src/components/chat-message.tsx +163 -0
  141. package/src/components/checkbox-card.stories.tsx +27 -0
  142. package/src/components/checkbox-card.tsx +200 -0
  143. package/src/components/checkbox.stories.tsx +26 -0
  144. package/src/components/checkbox.tsx +61 -0
  145. package/src/components/citations.stories.tsx +64 -0
  146. package/src/components/citations.test.tsx +61 -0
  147. package/src/components/citations.tsx +220 -0
  148. package/src/components/combobox.stories.tsx +184 -0
  149. package/src/components/combobox.test.tsx +82 -0
  150. package/src/components/combobox.tsx +407 -0
  151. package/src/components/countdown-pill.stories.tsx +24 -0
  152. package/src/components/countdown-pill.tsx +129 -0
  153. package/src/components/cta.stories.tsx +29 -0
  154. package/src/components/cta.tsx +52 -0
  155. package/src/components/data-list.stories.tsx +50 -0
  156. package/src/components/data-list.test.tsx +35 -0
  157. package/src/components/data-list.tsx +181 -0
  158. package/src/components/data-table.stories.tsx +29 -0
  159. package/src/components/data-table.tsx +200 -0
  160. package/src/components/dialog.stories.tsx +42 -0
  161. package/src/components/dialog.tsx +200 -0
  162. package/src/components/drawer.stories.tsx +48 -0
  163. package/src/components/drawer.tsx +385 -0
  164. package/src/components/dropdown-menu.stories.tsx +41 -0
  165. package/src/components/dropdown-menu.tsx +252 -0
  166. package/src/components/dropzone.stories.tsx +19 -0
  167. package/src/components/dropzone.tsx +250 -0
  168. package/src/components/email-chips-input.stories.tsx +135 -0
  169. package/src/components/email-chips-input.tsx +210 -0
  170. package/src/components/empty-state.stories.tsx +22 -0
  171. package/src/components/empty-state.tsx +35 -0
  172. package/src/components/entity-header.stories.tsx +43 -0
  173. package/src/components/entity-header.test.tsx +35 -0
  174. package/src/components/entity-header.tsx +92 -0
  175. package/src/components/feature-grid.stories.tsx +25 -0
  176. package/src/components/feature-grid.tsx +87 -0
  177. package/src/components/feedback-bar.stories.tsx +25 -0
  178. package/src/components/feedback-bar.test.tsx +24 -0
  179. package/src/components/feedback-bar.tsx +62 -0
  180. package/src/components/footer.stories.tsx +23 -0
  181. package/src/components/footer.tsx +78 -0
  182. package/src/components/heading.stories.tsx +45 -0
  183. package/src/components/heading.test.tsx +22 -0
  184. package/src/components/heading.tsx +77 -0
  185. package/src/components/hero.stories.tsx +38 -0
  186. package/src/components/hero.tsx +498 -0
  187. package/src/components/input-button.stories.tsx +19 -0
  188. package/src/components/input-button.tsx +112 -0
  189. package/src/components/input-group.stories.tsx +38 -0
  190. package/src/components/input-group.tsx +157 -0
  191. package/src/components/input.stories.tsx +31 -0
  192. package/src/components/input.tsx +284 -0
  193. package/src/components/inset.stories.tsx +29 -0
  194. package/src/components/inset.tsx +70 -0
  195. package/src/components/kpi-card.stories.tsx +29 -0
  196. package/src/components/kpi-card.tsx +284 -0
  197. package/src/components/list-nav.stories.tsx +22 -0
  198. package/src/components/list-nav.tsx +145 -0
  199. package/src/components/logo-cloud.stories.tsx +23 -0
  200. package/src/components/logo-cloud.tsx +42 -0
  201. package/src/components/media-gallery.stories.tsx +25 -0
  202. package/src/components/media-gallery.tsx +347 -0
  203. package/src/components/message-actions.stories.tsx +45 -0
  204. package/src/components/message-actions.test.tsx +32 -0
  205. package/src/components/message-actions.tsx +119 -0
  206. package/src/components/nav-sidebar.stories.tsx +58 -0
  207. package/src/components/nav-sidebar.test.tsx +54 -0
  208. package/src/components/nav-sidebar.tsx +462 -0
  209. package/src/components/number-stepper.stories.tsx +19 -0
  210. package/src/components/number-stepper.tsx +158 -0
  211. package/src/components/page-header.stories.tsx +25 -0
  212. package/src/components/page-header.tsx +64 -0
  213. package/src/components/pagination.stories.tsx +110 -0
  214. package/src/components/pagination.test.tsx +46 -0
  215. package/src/components/pagination.tsx +144 -0
  216. package/src/components/popover.stories.tsx +26 -0
  217. package/src/components/popover.tsx +62 -0
  218. package/src/components/pricing.stories.tsx +42 -0
  219. package/src/components/pricing.tsx +130 -0
  220. package/src/components/progress.stories.tsx +22 -0
  221. package/src/components/progress.tsx +189 -0
  222. package/src/components/prompt-input.stories.tsx +62 -0
  223. package/src/components/prompt-input.test.tsx +106 -0
  224. package/src/components/prompt-input.tsx +343 -0
  225. package/src/components/prompt-suggestions.stories.tsx +53 -0
  226. package/src/components/prompt-suggestions.test.tsx +37 -0
  227. package/src/components/prompt-suggestions.tsx +122 -0
  228. package/src/components/quota-bar.stories.tsx +19 -0
  229. package/src/components/quota-bar.tsx +191 -0
  230. package/src/components/quote.stories.tsx +18 -0
  231. package/src/components/quote.tsx +43 -0
  232. package/src/components/radio-card.stories.tsx +27 -0
  233. package/src/components/radio-card.tsx +225 -0
  234. package/src/components/radio-group.stories.tsx +32 -0
  235. package/src/components/radio-group.tsx +68 -0
  236. package/src/components/reasoning.stories.tsx +50 -0
  237. package/src/components/reasoning.test.tsx +48 -0
  238. package/src/components/reasoning.tsx +143 -0
  239. package/src/components/search.stories.tsx +18 -0
  240. package/src/components/search.tsx +133 -0
  241. package/src/components/section-nav.stories.tsx +59 -0
  242. package/src/components/section-nav.test.tsx +36 -0
  243. package/src/components/section-nav.tsx +146 -0
  244. package/src/components/segmented-control.stories.tsx +22 -0
  245. package/src/components/segmented-control.tsx +301 -0
  246. package/src/components/select-button.stories.tsx +27 -0
  247. package/src/components/select-button.tsx +174 -0
  248. package/src/components/select.stories.tsx +27 -0
  249. package/src/components/select.tsx +198 -0
  250. package/src/components/separator.stories.tsx +22 -0
  251. package/src/components/separator.tsx +23 -0
  252. package/src/components/shimmer-text.stories.tsx +23 -0
  253. package/src/components/shimmer-text.test.tsx +35 -0
  254. package/src/components/shimmer-text.tsx +43 -0
  255. package/src/components/sidebar.stories.tsx +25 -0
  256. package/src/components/sidebar.tsx +234 -0
  257. package/src/components/skeleton.stories.tsx +22 -0
  258. package/src/components/skeleton.tsx +96 -0
  259. package/src/components/slider.stories.tsx +20 -0
  260. package/src/components/slider.tsx +176 -0
  261. package/src/components/slideshow.stories.tsx +30 -0
  262. package/src/components/slideshow.tsx +291 -0
  263. package/src/components/source.stories.tsx +32 -0
  264. package/src/components/source.test.tsx +40 -0
  265. package/src/components/source.tsx +161 -0
  266. package/src/components/spinner.stories.tsx +19 -0
  267. package/src/components/spinner.tsx +76 -0
  268. package/src/components/stat-strip.stories.tsx +36 -0
  269. package/src/components/stat-strip.test.tsx +25 -0
  270. package/src/components/stat-strip.tsx +85 -0
  271. package/src/components/stats.stories.tsx +24 -0
  272. package/src/components/stats.tsx +83 -0
  273. package/src/components/step-indicator.stories.tsx +18 -0
  274. package/src/components/step-indicator.test.tsx +18 -0
  275. package/src/components/step-indicator.tsx +286 -0
  276. package/src/components/switch.stories.tsx +26 -0
  277. package/src/components/switch.tsx +32 -0
  278. package/src/components/table.stories.tsx +38 -0
  279. package/src/components/table.tsx +220 -0
  280. package/src/components/tabs.stories.tsx +25 -0
  281. package/src/components/tabs.tsx +276 -0
  282. package/src/components/testimonials.stories.tsx +22 -0
  283. package/src/components/testimonials.tsx +109 -0
  284. package/src/components/text-link.stories.tsx +47 -0
  285. package/src/components/text-link.test.tsx +18 -0
  286. package/src/components/text-link.tsx +88 -0
  287. package/src/components/text.stories.tsx +49 -0
  288. package/src/components/text.tsx +105 -0
  289. package/src/components/thinking-bar.stories.tsx +19 -0
  290. package/src/components/thinking-bar.test.tsx +27 -0
  291. package/src/components/thinking-bar.tsx +64 -0
  292. package/src/components/thinking-steps.stories.tsx +44 -0
  293. package/src/components/thinking-steps.test.tsx +42 -0
  294. package/src/components/thinking-steps.tsx +92 -0
  295. package/src/components/toaster.stories.tsx +80 -0
  296. package/src/components/toaster.tsx +36 -0
  297. package/src/components/toggle.stories.tsx +21 -0
  298. package/src/components/toggle.tsx +103 -0
  299. package/src/components/tool-call-card.stories.tsx +97 -0
  300. package/src/components/tool-call-card.test.tsx +88 -0
  301. package/src/components/tool-call-card.tsx +231 -0
  302. package/src/components/tooltip.stories.tsx +25 -0
  303. package/src/components/tooltip.tsx +43 -0
  304. package/src/components/top-nav.stories.tsx +20 -0
  305. package/src/components/top-nav.tsx +76 -0
  306. package/src/docs/Colors.stories.tsx +192 -0
  307. package/src/docs/GettingStarted.mdx +72 -0
  308. package/src/docs/Mcp.mdx +52 -0
  309. package/src/docs/Typography.stories.tsx +114 -0
  310. package/src/hooks/use-in-view.ts +32 -0
  311. package/src/hooks/use-reduced-motion.ts +26 -0
  312. package/src/index.ts +343 -0
  313. package/src/lib/cn.test.ts +67 -0
  314. package/src/lib/cn.ts +81 -0
  315. package/src/lib/motion.ts +109 -0
  316. package/src/lib/prefix-utilities.test.ts +56 -0
  317. package/src/lib/prefix-utilities.ts +50 -0
  318. package/src/styles/base.css +489 -0
  319. package/src/styles/motion.css +69 -0
  320. package/src/styles/overrides.css +44 -0
  321. package/src/styles/typography.css +173 -0
  322. package/src/theme/theme.css +391 -0
  323. package/src/theme/tokens.ts +29 -0
  324. package/src/theme/types.ts +26 -0
  325. 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
+ })