@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,407 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
|
5
|
+
import { Search as SearchIcon, X as ClearIcon } from "lucide-react"
|
|
6
|
+
import { Spinner } from "./spinner"
|
|
7
|
+
import { Input, TextFieldRoot, TextFieldSlot, type InputSize } from "./input"
|
|
8
|
+
import { cn } from "../lib/cn"
|
|
9
|
+
|
|
10
|
+
export interface ComboboxItem {
|
|
11
|
+
/** Unique, stable identifier. Also what `value` / `onValueChange` carry. */
|
|
12
|
+
value: string
|
|
13
|
+
label: string
|
|
14
|
+
/** Secondary text shown at the end of the row, e.g. a price or count. */
|
|
15
|
+
description?: string
|
|
16
|
+
/** Leading visual, e.g. an icon or a small Avatar. Mark decorative icons aria-hidden. */
|
|
17
|
+
icon?: React.ReactNode
|
|
18
|
+
/** Rows with the same group render under one heading, in first-seen order. */
|
|
19
|
+
group?: string
|
|
20
|
+
/** Extra text matched by the default filter but not shown. */
|
|
21
|
+
keywords?: string
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ComboboxSize = InputSize
|
|
26
|
+
|
|
27
|
+
export interface ComboboxProps {
|
|
28
|
+
items: ComboboxItem[]
|
|
29
|
+
/** Selected item's value. Set it (with onValueChange) for a picker that shows the choice in the input. */
|
|
30
|
+
value?: string | null
|
|
31
|
+
onValueChange?: (value: string | null) => void
|
|
32
|
+
/** Called on every pick. Without `value`, the input clears after a pick (search launcher). */
|
|
33
|
+
onSelect?: (item: ComboboxItem) => void
|
|
34
|
+
/** Controlled query. Leave unset to let the combobox own it. */
|
|
35
|
+
query?: string
|
|
36
|
+
onQueryChange?: (query: string) => void
|
|
37
|
+
/** Return true to keep an item. Default: case-insensitive match on label, description and keywords. */
|
|
38
|
+
filter?: (item: ComboboxItem, query: string) => boolean
|
|
39
|
+
/** Shown instead of the item list while the query is empty, e.g. recent searches. */
|
|
40
|
+
startContent?: React.ReactNode
|
|
41
|
+
/** Shown when the query matches nothing. Receives the trimmed query. */
|
|
42
|
+
emptyMessage?: React.ReactNode | ((query: string) => React.ReactNode)
|
|
43
|
+
/** Show a pending row instead of results, e.g. while fetching remote items. */
|
|
44
|
+
loading?: boolean
|
|
45
|
+
loadingMessage?: React.ReactNode
|
|
46
|
+
/** Cap the rows shown per group (or overall when ungrouped). */
|
|
47
|
+
maxPerGroup?: number
|
|
48
|
+
/** Bold the matched part of each label. Default true. */
|
|
49
|
+
highlightMatches?: boolean
|
|
50
|
+
placeholder?: string
|
|
51
|
+
/** Accessible name. Required unless a <Label htmlFor={id}> names the input. */
|
|
52
|
+
"aria-label"?: string
|
|
53
|
+
id?: string
|
|
54
|
+
name?: string
|
|
55
|
+
size?: ComboboxSize
|
|
56
|
+
invalid?: boolean
|
|
57
|
+
disabled?: boolean
|
|
58
|
+
className?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function defaultComboboxFilter(item: ComboboxItem, query: string) {
|
|
62
|
+
const haystack = `${item.label} ${item.description ?? ""} ${item.keywords ?? ""}`.toLowerCase()
|
|
63
|
+
return haystack.includes(query.toLowerCase())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Groups items in first-seen order, capping each group. Ungrouped items share one unnamed group. */
|
|
67
|
+
export function groupComboboxItems(items: ComboboxItem[], maxPerGroup = Infinity) {
|
|
68
|
+
const groups: { name: string | undefined; items: ComboboxItem[] }[] = []
|
|
69
|
+
for (const item of items) {
|
|
70
|
+
let group = groups.find((entry) => entry.name === item.group)
|
|
71
|
+
if (!group) {
|
|
72
|
+
group = { name: item.group, items: [] }
|
|
73
|
+
groups.push(group)
|
|
74
|
+
}
|
|
75
|
+
if (group.items.length < maxPerGroup) group.items.push(item)
|
|
76
|
+
}
|
|
77
|
+
return groups
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Splits a label around the first case-insensitive match so it can be emphasised. */
|
|
81
|
+
export function splitComboboxMatch(text: string, query: string) {
|
|
82
|
+
const index = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1
|
|
83
|
+
if (index < 0) return null
|
|
84
|
+
return { before: text.slice(0, index), match: text.slice(index, index + query.length), after: text.slice(index + query.length) }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Next enabled index in `direction`, wrapping around; -1 when nothing is enabled. */
|
|
88
|
+
export function nextEnabledIndex(items: ComboboxItem[], from: number, direction: 1 | -1) {
|
|
89
|
+
for (let step = 1; step <= items.length; step++) {
|
|
90
|
+
const index = (from + direction * step + items.length * step) % items.length
|
|
91
|
+
if (!items[index]?.disabled) return index
|
|
92
|
+
}
|
|
93
|
+
return -1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const SIZE_TEXT: Record<ComboboxSize, string> = {
|
|
97
|
+
sm: "ax:t-label-sm",
|
|
98
|
+
md: "ax:t-body-sm",
|
|
99
|
+
lg: "ax:t-body-md",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function Highlight({ text, query }: { text: string; query: string }) {
|
|
103
|
+
const parts = splitComboboxMatch(text, query)
|
|
104
|
+
if (!parts) return <>{text}</>
|
|
105
|
+
return (
|
|
106
|
+
<>
|
|
107
|
+
{parts.before}
|
|
108
|
+
<strong className="ax:font-semibold ax:text-foreground-strong">{parts.match}</strong>
|
|
109
|
+
{parts.after}
|
|
110
|
+
</>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Combobox - a text input with a filtered, keyboard-navigable list of options.
|
|
116
|
+
*
|
|
117
|
+
* Follows the WAI-ARIA combobox pattern: focus stays in the input while arrow
|
|
118
|
+
* keys move `aria-activedescendant` through the listbox, Enter picks, Escape
|
|
119
|
+
* closes. The list renders in a Popover portal anchored to the input, so
|
|
120
|
+
* overflow-hidden containers cannot clip it.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* <Combobox
|
|
124
|
+
* items={communities}
|
|
125
|
+
* value={community}
|
|
126
|
+
* onValueChange={setCommunity}
|
|
127
|
+
* placeholder="Choose a community"
|
|
128
|
+
* aria-label="Community"
|
|
129
|
+
* />
|
|
130
|
+
*/
|
|
131
|
+
export function Combobox({
|
|
132
|
+
items,
|
|
133
|
+
value,
|
|
134
|
+
onValueChange,
|
|
135
|
+
onSelect,
|
|
136
|
+
query: queryProp,
|
|
137
|
+
onQueryChange,
|
|
138
|
+
filter = defaultComboboxFilter,
|
|
139
|
+
startContent,
|
|
140
|
+
emptyMessage = (query: string) => `No results for "${query}"`,
|
|
141
|
+
loading = false,
|
|
142
|
+
loadingMessage = "Searching",
|
|
143
|
+
maxPerGroup,
|
|
144
|
+
highlightMatches = true,
|
|
145
|
+
placeholder,
|
|
146
|
+
"aria-label": ariaLabel,
|
|
147
|
+
id,
|
|
148
|
+
name,
|
|
149
|
+
size = "md",
|
|
150
|
+
invalid,
|
|
151
|
+
disabled,
|
|
152
|
+
className,
|
|
153
|
+
}: ComboboxProps) {
|
|
154
|
+
const autoId = React.useId()
|
|
155
|
+
const inputId = id ?? `${autoId}-input`
|
|
156
|
+
const listId = `${autoId}-listbox`
|
|
157
|
+
const anchorRef = React.useRef<HTMLDivElement>(null)
|
|
158
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
159
|
+
|
|
160
|
+
const isPicker = value !== undefined
|
|
161
|
+
// Remote results may no longer include the picked item, so remember it to keep its label.
|
|
162
|
+
const [lastPicked, setLastPicked] = React.useState<ComboboxItem>()
|
|
163
|
+
const selected = isPicker
|
|
164
|
+
? items.find((item) => item.value === value) ?? (lastPicked?.value === value ? lastPicked : undefined)
|
|
165
|
+
: undefined
|
|
166
|
+
|
|
167
|
+
const [ownQuery, setOwnQuery] = React.useState("")
|
|
168
|
+
const query = queryProp ?? ownQuery
|
|
169
|
+
const setQuery = (next: string) => {
|
|
170
|
+
if (queryProp === undefined) setOwnQuery(next)
|
|
171
|
+
onQueryChange?.(next)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const [open, setOpen] = React.useState(false)
|
|
175
|
+
const [editing, setEditing] = React.useState(false)
|
|
176
|
+
const [active, setActive] = React.useState(-1)
|
|
177
|
+
|
|
178
|
+
const trimmed = query.trim()
|
|
179
|
+
const showStart = !trimmed && startContent !== undefined
|
|
180
|
+
const matches = trimmed ? items.filter((item) => filter(item, trimmed)) : items
|
|
181
|
+
const groups = groupComboboxItems(showStart ? [] : matches, maxPerGroup)
|
|
182
|
+
const visible = groups.flatMap((group) => group.items)
|
|
183
|
+
const activeItem = open && active >= 0 ? visible[active] : undefined
|
|
184
|
+
const optionId = (item: ComboboxItem) => `${autoId}-option-${visible.indexOf(item)}`
|
|
185
|
+
|
|
186
|
+
// In picker mode the input shows the chosen label until the user starts editing.
|
|
187
|
+
const inputValue = isPicker && !editing ? selected?.label ?? "" : query
|
|
188
|
+
|
|
189
|
+
// Typing highlights the first enabled match so Enter picks it straight away. Results
|
|
190
|
+
// that arrive later (remote search) get the same treatment if nothing valid is highlighted.
|
|
191
|
+
const lastQuery = React.useRef(trimmed)
|
|
192
|
+
React.useEffect(() => {
|
|
193
|
+
if (!open || !trimmed) return
|
|
194
|
+
const queryChanged = lastQuery.current !== trimmed
|
|
195
|
+
lastQuery.current = trimmed
|
|
196
|
+
if (queryChanged || active < 0 || active >= visible.length) setActive(nextEnabledIndex(visible, -1, 1))
|
|
197
|
+
}, [open, trimmed, visible.length, active])
|
|
198
|
+
|
|
199
|
+
React.useEffect(() => {
|
|
200
|
+
if (!activeItem) return
|
|
201
|
+
document.getElementById(optionId(activeItem))?.scrollIntoView({ block: "nearest" })
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
function openList() {
|
|
205
|
+
if (disabled) return
|
|
206
|
+
setOpen(true)
|
|
207
|
+
const selectedIndex = selected ? visible.indexOf(selected) : -1
|
|
208
|
+
setActive((current) => (current >= 0 ? current : selectedIndex >= 0 ? selectedIndex : nextEnabledIndex(visible, -1, 1)))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function close() {
|
|
212
|
+
setOpen(false)
|
|
213
|
+
setEditing(false)
|
|
214
|
+
setActive(-1)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function pick(item: ComboboxItem) {
|
|
218
|
+
if (item.disabled) return
|
|
219
|
+
setLastPicked(item)
|
|
220
|
+
onSelect?.(item)
|
|
221
|
+
if (isPicker) onValueChange?.(item.value)
|
|
222
|
+
setQuery("")
|
|
223
|
+
close()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
|
227
|
+
switch (event.key) {
|
|
228
|
+
case "ArrowDown":
|
|
229
|
+
case "ArrowUp": {
|
|
230
|
+
event.preventDefault()
|
|
231
|
+
if (!open) return openList()
|
|
232
|
+
const direction = event.key === "ArrowDown" ? 1 : -1
|
|
233
|
+
setActive((current) => nextEnabledIndex(visible, current < 0 && direction < 0 ? 0 : current, direction))
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
case "Home":
|
|
237
|
+
case "End":
|
|
238
|
+
if (!open || !visible.length) return
|
|
239
|
+
event.preventDefault()
|
|
240
|
+
setActive(event.key === "Home" ? nextEnabledIndex(visible, -1, 1) : nextEnabledIndex(visible, 0, -1))
|
|
241
|
+
return
|
|
242
|
+
case "Enter":
|
|
243
|
+
if (open && activeItem) {
|
|
244
|
+
event.preventDefault()
|
|
245
|
+
pick(activeItem)
|
|
246
|
+
}
|
|
247
|
+
return
|
|
248
|
+
case "Escape":
|
|
249
|
+
if (open) {
|
|
250
|
+
event.preventDefault()
|
|
251
|
+
close()
|
|
252
|
+
}
|
|
253
|
+
return
|
|
254
|
+
case "Tab":
|
|
255
|
+
close()
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const listContent = loading ? (
|
|
260
|
+
<div className="ax:flex ax:items-center ax:gap-xs ax:px-sm ax:py-sm ax:text-muted-foreground" role="status">
|
|
261
|
+
<Spinner size="sm" decorative className="ax:text-current" />
|
|
262
|
+
<span className={SIZE_TEXT[size]}>{loadingMessage}</span>
|
|
263
|
+
</div>
|
|
264
|
+
) : showStart ? (
|
|
265
|
+
// Keep focus in the input when start content is clicked; keyboard users can still Tab into it.
|
|
266
|
+
<div onMouseDown={(event) => event.preventDefault()}>{startContent}</div>
|
|
267
|
+
) : visible.length === 0 ? (
|
|
268
|
+
<p className={cn("ax:px-sm ax:py-sm ax:text-muted-foreground", SIZE_TEXT[size])}>
|
|
269
|
+
{typeof emptyMessage === "function" ? emptyMessage(trimmed) : emptyMessage}
|
|
270
|
+
</p>
|
|
271
|
+
) : (
|
|
272
|
+
<div role="listbox" id={listId} aria-label={ariaLabel}>
|
|
273
|
+
{groups.map((group, groupIndex) => (
|
|
274
|
+
<div
|
|
275
|
+
key={group.name ?? `group-${groupIndex}`}
|
|
276
|
+
role={group.name ? "group" : undefined}
|
|
277
|
+
aria-labelledby={group.name ? `${autoId}-group-${groupIndex}` : undefined}
|
|
278
|
+
className="ax:py-2xs"
|
|
279
|
+
>
|
|
280
|
+
{group.name && (
|
|
281
|
+
<div id={`${autoId}-group-${groupIndex}`} className="ax:px-sm ax:pt-xs ax:pb-2xs ax:t-overline ax:text-muted-foreground">
|
|
282
|
+
{group.name}
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
285
|
+
{group.items.map((item) => {
|
|
286
|
+
const isActive = activeItem === item
|
|
287
|
+
const isSelected = selected?.value === item.value
|
|
288
|
+
return (
|
|
289
|
+
<div
|
|
290
|
+
key={item.value}
|
|
291
|
+
id={optionId(item)}
|
|
292
|
+
role="option"
|
|
293
|
+
aria-selected={isPicker ? isSelected : isActive}
|
|
294
|
+
aria-disabled={item.disabled || undefined}
|
|
295
|
+
data-active={isActive || undefined}
|
|
296
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
297
|
+
onMouseMove={() => !item.disabled && setActive(visible.indexOf(item))}
|
|
298
|
+
onClick={() => pick(item)}
|
|
299
|
+
className={cn(
|
|
300
|
+
"ax:flex ax:cursor-pointer ax:select-none ax:items-center ax:gap-sm ax:rounded-(--input-radius) ax:px-sm ax:py-xs",
|
|
301
|
+
"ax:text-foreground ax:transition-colors",
|
|
302
|
+
SIZE_TEXT[size],
|
|
303
|
+
isActive && "ax:bg-muted",
|
|
304
|
+
isSelected && "ax:font-medium",
|
|
305
|
+
item.disabled && "ax:cursor-not-allowed ax:opacity-50",
|
|
306
|
+
)}
|
|
307
|
+
>
|
|
308
|
+
{item.icon && (
|
|
309
|
+
<span className="ax:flex ax:size-6 ax:shrink-0 ax:items-center ax:justify-center ax:text-muted-foreground ax:[&_svg]:size-4">
|
|
310
|
+
{item.icon}
|
|
311
|
+
</span>
|
|
312
|
+
)}
|
|
313
|
+
<span className="ax:min-w-0 ax:flex-1 ax:truncate">
|
|
314
|
+
{highlightMatches ? <Highlight text={item.label} query={trimmed} /> : item.label}
|
|
315
|
+
</span>
|
|
316
|
+
{item.description && (
|
|
317
|
+
<span className="ax:shrink-0 ax:t-caption ax:text-muted-foreground">{item.description}</span>
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
)
|
|
321
|
+
})}
|
|
322
|
+
</div>
|
|
323
|
+
))}
|
|
324
|
+
</div>
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<PopoverPrimitive.Root open={open} onOpenChange={(next) => (next ? openList() : close())} modal={false}>
|
|
329
|
+
<PopoverPrimitive.Anchor asChild>
|
|
330
|
+
<div ref={anchorRef} className={cn("ax:w-full", className)} data-slot="combobox">
|
|
331
|
+
<TextFieldRoot size={size} invalid={invalid}>
|
|
332
|
+
<TextFieldSlot>
|
|
333
|
+
<SearchIcon className="ax:size-4" aria-hidden />
|
|
334
|
+
</TextFieldSlot>
|
|
335
|
+
<Input
|
|
336
|
+
ref={inputRef}
|
|
337
|
+
id={inputId}
|
|
338
|
+
name={name}
|
|
339
|
+
type="text"
|
|
340
|
+
disabled={disabled}
|
|
341
|
+
aria-invalid={invalid || undefined}
|
|
342
|
+
value={inputValue}
|
|
343
|
+
placeholder={placeholder}
|
|
344
|
+
aria-label={ariaLabel}
|
|
345
|
+
role="combobox"
|
|
346
|
+
aria-expanded={open}
|
|
347
|
+
aria-controls={open && !showStart && visible.length > 0 ? listId : undefined}
|
|
348
|
+
aria-autocomplete="list"
|
|
349
|
+
aria-activedescendant={activeItem ? optionId(activeItem) : undefined}
|
|
350
|
+
autoComplete="off"
|
|
351
|
+
onChange={(event) => {
|
|
352
|
+
setEditing(true)
|
|
353
|
+
setQuery(event.target.value)
|
|
354
|
+
setOpen(true)
|
|
355
|
+
}}
|
|
356
|
+
onFocus={openList}
|
|
357
|
+
onClick={openList}
|
|
358
|
+
onKeyDown={onKeyDown}
|
|
359
|
+
/>
|
|
360
|
+
{inputValue && !disabled && (
|
|
361
|
+
<TextFieldSlot>
|
|
362
|
+
<button
|
|
363
|
+
type="button"
|
|
364
|
+
aria-label={isPicker ? "Clear selection" : "Clear search"}
|
|
365
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
366
|
+
onClick={() => {
|
|
367
|
+
setQuery("")
|
|
368
|
+
setEditing(true)
|
|
369
|
+
if (isPicker) onValueChange?.(null)
|
|
370
|
+
inputRef.current?.focus()
|
|
371
|
+
}}
|
|
372
|
+
className="ax:rounded-sm ax:text-muted-foreground ax:transition-colors ax:hover:text-foreground ax:focus-visible:outline-none ax:focus-visible:ring-2 ax:focus-visible:ring-ring"
|
|
373
|
+
>
|
|
374
|
+
<ClearIcon className="ax:size-4" />
|
|
375
|
+
</button>
|
|
376
|
+
</TextFieldSlot>
|
|
377
|
+
)}
|
|
378
|
+
</TextFieldRoot>
|
|
379
|
+
<span className="ax:sr-only" aria-live="polite">
|
|
380
|
+
{open && trimmed && !loading ? `${visible.length} ${visible.length === 1 ? "result" : "results"}` : ""}
|
|
381
|
+
</span>
|
|
382
|
+
</div>
|
|
383
|
+
</PopoverPrimitive.Anchor>
|
|
384
|
+
<PopoverPrimitive.Portal>
|
|
385
|
+
<PopoverPrimitive.Content
|
|
386
|
+
// A listbox popup, not a dialog: focus never leaves the input.
|
|
387
|
+
role="presentation"
|
|
388
|
+
align="start"
|
|
389
|
+
sideOffset={6}
|
|
390
|
+
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
391
|
+
onCloseAutoFocus={(event) => event.preventDefault()}
|
|
392
|
+
onInteractOutside={(event) => {
|
|
393
|
+
if (anchorRef.current?.contains(event.target as Node)) event.preventDefault()
|
|
394
|
+
}}
|
|
395
|
+
className={cn(
|
|
396
|
+
"ax:z-[1100] ax:w-(--radix-popper-anchor-width) ax:max-h-80 ax:overflow-y-auto",
|
|
397
|
+
"ax:rounded-(--input-radius) ax:border ax:border-border ax:bg-popover ax:p-2xs ax:text-popover-foreground ax:shadow-md ax:outline-none",
|
|
398
|
+
"ax:data-[state=open]:animate-in ax:data-[state=closed]:animate-out ax:data-[state=closed]:fade-out-0 ax:data-[state=open]:fade-in-0",
|
|
399
|
+
"ax:[animation-duration:var(--motion-fast)] ax:motion-reduce:animate-none",
|
|
400
|
+
)}
|
|
401
|
+
>
|
|
402
|
+
{listContent}
|
|
403
|
+
</PopoverPrimitive.Content>
|
|
404
|
+
</PopoverPrimitive.Portal>
|
|
405
|
+
</PopoverPrimitive.Root>
|
|
406
|
+
)
|
|
407
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { CountdownPill } from "./countdown-pill"
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof CountdownPill> = {
|
|
6
|
+
title: "Components/CountdownPill",
|
|
7
|
+
component: CountdownPill,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
args: {
|
|
10
|
+
label: "Starts in",
|
|
11
|
+
value: "2h 15m",
|
|
12
|
+
tone: "info",
|
|
13
|
+
size: "md",
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default meta
|
|
18
|
+
type Story = StoryObj<typeof CountdownPill>
|
|
19
|
+
|
|
20
|
+
export const Default: Story = {}
|
|
21
|
+
|
|
22
|
+
export const Critical: Story = {
|
|
23
|
+
args: { tone: "critical", value: "5m", label: "Ends in" },
|
|
24
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Clock } from "lucide-react"
|
|
5
|
+
import { cn } from "../lib/cn"
|
|
6
|
+
|
|
7
|
+
export type CountdownTone = "neutral" | "info" | "success" | "warning" | "critical"
|
|
8
|
+
export type CountdownSize = "sm" | "md" | "lg"
|
|
9
|
+
|
|
10
|
+
export interface CountdownPillProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
11
|
+
/** The countdown text itself - the bit that gets emphasized.
|
|
12
|
+
* e.g. `"2h 15m"`, `"3 days"`, `"Live now"`. */
|
|
13
|
+
value: string
|
|
14
|
+
/** Optional context label rendered before the value.
|
|
15
|
+
* e.g. `"Starts in"`, `"Bidding ends in"`. */
|
|
16
|
+
label?: string
|
|
17
|
+
/** Visual urgency tier. Drives the value (and icon) color so the consumer
|
|
18
|
+
* can dial attention up or down without changing markup. */
|
|
19
|
+
tone?: CountdownTone
|
|
20
|
+
/** Visual density. */
|
|
21
|
+
size?: CountdownSize
|
|
22
|
+
/** Optional icon override. Defaults to a Clock glyph. Pass `null` to omit. */
|
|
23
|
+
icon?: React.ReactNode | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Value uses raw `text-* font-bold` (not `t-h*` tokens) on purpose: the
|
|
27
|
+
// typography tokens hard-code `text-foreground-strong`, which would override
|
|
28
|
+
// the semantic tone color and defeat the urgency cue.
|
|
29
|
+
|
|
30
|
+
const VALUE_CLASS: Record<CountdownSize, string> = {
|
|
31
|
+
sm: "ax:text-sm ax:font-bold",
|
|
32
|
+
md: "ax:text-2xl ax:font-bold", // 24px
|
|
33
|
+
lg: "ax:text-4xl ax:font-bold", // 36px
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const LABEL_CLASS: Record<CountdownSize, string> = {
|
|
37
|
+
sm: "ax:t-caption",
|
|
38
|
+
md: "ax:t-label-sm",
|
|
39
|
+
lg: "ax:t-overline",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ICON_CLASS: Record<CountdownSize, string> = {
|
|
43
|
+
sm: "ax:size-3.5 ax:[&>svg]:size-3.5",
|
|
44
|
+
md: "ax:size-4 ax:[&>svg]:size-4",
|
|
45
|
+
lg: "ax:size-5 ax:[&>svg]:size-5",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const GAP_CLASS: Record<CountdownSize, string> = {
|
|
49
|
+
sm: "ax:gap-2xs",
|
|
50
|
+
md: "ax:gap-xs",
|
|
51
|
+
lg: "ax:gap-sm",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const TONE_VALUE: Record<CountdownTone, string> = {
|
|
55
|
+
neutral: "ax:text-foreground",
|
|
56
|
+
info: "ax:text-primary",
|
|
57
|
+
success: "ax:text-success-foreground",
|
|
58
|
+
warning: "ax:text-warning-foreground",
|
|
59
|
+
critical: "ax:text-destructive-foreground",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// `neutral` keeps the icon muted so it doesn't compete with the label/value;
|
|
63
|
+
// every other tone matches the value color so the icon, label, and value
|
|
64
|
+
// read as a single urgency unit.
|
|
65
|
+
const TONE_ICON: Record<CountdownTone, string> = {
|
|
66
|
+
neutral: "ax:text-muted-foreground",
|
|
67
|
+
info: "ax:text-primary",
|
|
68
|
+
success: "ax:text-success-foreground",
|
|
69
|
+
warning: "ax:text-warning-foreground",
|
|
70
|
+
critical: "ax:text-destructive-foreground",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Inline countdown display for time-to-event copy (sessions, deadlines,
|
|
74
|
+
* auctions). Designed to *raise* attention: pairs a muted context label
|
|
75
|
+
* with a large, tone-colored value. Three sizes and five urgency tones.
|
|
76
|
+
*
|
|
77
|
+
* The component is presentational only. Compute the `value` string and
|
|
78
|
+
* pick a `tone` in the consumer. Use [[getCountdownLabel]] to format an
|
|
79
|
+
* ISO timestamp into a label like `"Starts in 2h 15m"`. */
|
|
80
|
+
export const CountdownPill = React.forwardRef<HTMLDivElement, CountdownPillProps>(
|
|
81
|
+
({ value, label, tone = "neutral", size = "md", icon, className, ...props }, ref) => {
|
|
82
|
+
const iconNode = icon === null ? null : icon ?? <Clock />
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
ref={ref}
|
|
87
|
+
className={cn("ax:inline-flex ax:items-center", GAP_CLASS[size], className)}
|
|
88
|
+
{...props}
|
|
89
|
+
>
|
|
90
|
+
{iconNode && (
|
|
91
|
+
<span className={cn("ax:shrink-0", ICON_CLASS[size], TONE_ICON[tone])}>
|
|
92
|
+
{iconNode}
|
|
93
|
+
</span>
|
|
94
|
+
)}
|
|
95
|
+
{label && (
|
|
96
|
+
<span className={cn(LABEL_CLASS[size], "ax:text-muted-foreground")}>
|
|
97
|
+
{label}
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
<span className={cn(VALUE_CLASS[size], TONE_VALUE[tone])}>
|
|
101
|
+
{value}
|
|
102
|
+
</span>
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
CountdownPill.displayName = "CountdownPill"
|
|
108
|
+
|
|
109
|
+
/** Format an ISO timestamp into a countdown label.
|
|
110
|
+
*
|
|
111
|
+
* • Past / now → the `ongoingLabel` you pass (e.g. `"Live now"`)
|
|
112
|
+
* • Within 24h → `"2h 15m"`
|
|
113
|
+
* • Further out → `"3 days"`
|
|
114
|
+
*
|
|
115
|
+
* Urgency tone is intentionally NOT inferred. Pick it in the consumer
|
|
116
|
+
* based on your own thresholds and pass it via the `tone` prop. */
|
|
117
|
+
export function getCountdownLabel(dateIso: string, ongoingLabel: string, now = Date.now()): string {
|
|
118
|
+
const diff = new Date(dateIso).getTime() - now
|
|
119
|
+
if (diff <= 0) return ongoingLabel
|
|
120
|
+
|
|
121
|
+
const hours = Math.floor(diff / (1000 * 60 * 60))
|
|
122
|
+
if (hours < 24) {
|
|
123
|
+
const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
|
124
|
+
return `${hours}h ${mins}m`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const days = Math.floor(hours / 24)
|
|
128
|
+
return `${days} day${days !== 1 ? "s" : ""}`
|
|
129
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { CTA } from "./cta"
|
|
4
|
+
import { Button } from "./button"
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof CTA> = {
|
|
7
|
+
title: "Components/CTA",
|
|
8
|
+
component: CTA,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
args: {
|
|
11
|
+
title: "Ready to get started?",
|
|
12
|
+
description: "Join thousands of teams building with Adres.",
|
|
13
|
+
actions: (
|
|
14
|
+
<>
|
|
15
|
+
<Button>Start free trial</Button>
|
|
16
|
+
<Button variant="outline">Talk to sales</Button>
|
|
17
|
+
</>
|
|
18
|
+
),
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default meta
|
|
23
|
+
type Story = StoryObj<typeof CTA>
|
|
24
|
+
|
|
25
|
+
export const Default: Story = {}
|
|
26
|
+
|
|
27
|
+
export const Primary: Story = {
|
|
28
|
+
args: { emphasis: "primary" },
|
|
29
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cn } from "../lib/cn"
|
|
5
|
+
|
|
6
|
+
export interface CTAProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
|
|
7
|
+
title: React.ReactNode
|
|
8
|
+
description?: React.ReactNode
|
|
9
|
+
actions: React.ReactNode
|
|
10
|
+
/** Visual emphasis - 'subtle' uses muted surface, 'primary' uses brand surface. */
|
|
11
|
+
emphasis?: "subtle" | "primary"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* CTA - closing call-to-action section for marketing pages.
|
|
16
|
+
* Two emphasis levels so it can adapt to where it sits on the page.
|
|
17
|
+
*/
|
|
18
|
+
export const CTA = React.forwardRef<HTMLElement, CTAProps>(
|
|
19
|
+
({ className, title, description, actions, emphasis = "subtle", ...props }, ref) => (
|
|
20
|
+
<section
|
|
21
|
+
ref={ref}
|
|
22
|
+
className={cn("ax:w-full ax:px-xl ax:md:px-3xl ax:py-5xl", className)}
|
|
23
|
+
{...props}
|
|
24
|
+
>
|
|
25
|
+
<div
|
|
26
|
+
className={cn(
|
|
27
|
+
"ax:mx-auto ax:max-w-5xl ax:rounded-xl ax:px-2xl ax:md:px-4xl ax:py-4xl ax:md:py-5xl",
|
|
28
|
+
"ax:flex ax:flex-col ax:items-center ax:text-center ax:gap-xl",
|
|
29
|
+
emphasis === "primary"
|
|
30
|
+
? "ax:bg-primary ax:text-primary-foreground"
|
|
31
|
+
: "ax:bg-muted ax:text-foreground"
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
<h2 className="ax:t-display-md ax:md:t-display-lg ax:[text-wrap:balance]">
|
|
35
|
+
{title}
|
|
36
|
+
</h2>
|
|
37
|
+
{description && (
|
|
38
|
+
<p
|
|
39
|
+
className={cn(
|
|
40
|
+
"ax:t-body-md ax:md:t-body-lg ax:[text-wrap:pretty] ax:max-w-2xl",
|
|
41
|
+
emphasis === "primary" ? "ax:text-primary-foreground/80" : "ax:text-muted-foreground"
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
{description}
|
|
45
|
+
</p>
|
|
46
|
+
)}
|
|
47
|
+
<div className="ax:flex ax:flex-wrap ax:gap-sm ax:justify-center ax:pt-xs">{actions}</div>
|
|
48
|
+
</div>
|
|
49
|
+
</section>
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
CTA.displayName = "CTA"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { DataList, DataListItem, DataListLabel, DataListValue } from "./data-list"
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof DataList> = {
|
|
6
|
+
title: "Components/DataList",
|
|
7
|
+
component: DataList,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof DataList>
|
|
13
|
+
|
|
14
|
+
export const Default: Story = {
|
|
15
|
+
render: () => (
|
|
16
|
+
<DataList className="ax:max-w-md">
|
|
17
|
+
<DataListItem>
|
|
18
|
+
<DataListLabel>Status</DataListLabel>
|
|
19
|
+
<DataListValue>Active</DataListValue>
|
|
20
|
+
</DataListItem>
|
|
21
|
+
<DataListItem>
|
|
22
|
+
<DataListLabel>Plan</DataListLabel>
|
|
23
|
+
<DataListValue>Professional</DataListValue>
|
|
24
|
+
</DataListItem>
|
|
25
|
+
<DataListItem>
|
|
26
|
+
<DataListLabel>Renewal</DataListLabel>
|
|
27
|
+
<DataListValue>Sep 1, 2026</DataListValue>
|
|
28
|
+
</DataListItem>
|
|
29
|
+
</DataList>
|
|
30
|
+
),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Short facts in columns, label above value, without dividers. */
|
|
34
|
+
export const Grid: Story = {
|
|
35
|
+
render: () => (
|
|
36
|
+
<DataList orientation="grid" columns={4} size="sm">
|
|
37
|
+
{[
|
|
38
|
+
["Trade licence", "2120561384412A"],
|
|
39
|
+
["Brokerage licence", "8812612491127"],
|
|
40
|
+
["Licence expiry", "15 May 2028"],
|
|
41
|
+
["Phone", "00 1098231807"],
|
|
42
|
+
].map(([label, value]) => (
|
|
43
|
+
<DataListItem key={label}>
|
|
44
|
+
<DataListLabel>{label}</DataListLabel>
|
|
45
|
+
<DataListValue>{value}</DataListValue>
|
|
46
|
+
</DataListItem>
|
|
47
|
+
))}
|
|
48
|
+
</DataList>
|
|
49
|
+
),
|
|
50
|
+
}
|