@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,210 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { X } from "lucide-react"
|
|
5
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"
|
|
6
|
+
import { cn } from "../lib/cn"
|
|
7
|
+
|
|
8
|
+
function isValidEmail(email: string): boolean {
|
|
9
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Splits a pasted blob into candidate addresses. Handles the shapes users
|
|
13
|
+
// realistically paste: comma-, semicolon-, newline-, tab-, or whitespace-
|
|
14
|
+
// separated lists (incl. mixed). Empty fragments are filtered out by callers.
|
|
15
|
+
const SPLIT_RE = /[\s,;]+/
|
|
16
|
+
|
|
17
|
+
export type EmailChipsInputVariant = "default" | "primary" | "destructive"
|
|
18
|
+
|
|
19
|
+
export interface EmailChip {
|
|
20
|
+
value: string
|
|
21
|
+
valid: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface EmailChipsInputProps
|
|
25
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
26
|
+
chips: EmailChip[]
|
|
27
|
+
onChange: (chips: EmailChip[]) => void
|
|
28
|
+
placeholder?: string
|
|
29
|
+
/** Fired when the user tries to add an address that's already in the
|
|
30
|
+
* list. Useful for surfacing a non-blocking warning toast. */
|
|
31
|
+
onDuplicate?: () => void
|
|
32
|
+
/** Tone applied to valid chips. `default` is neutral, `primary` tints
|
|
33
|
+
* brand, `destructive` tints destructive. Invalid chips always render in
|
|
34
|
+
* destructive regardless of variant - the error signal stays distinct. */
|
|
35
|
+
variant?: EmailChipsInputVariant
|
|
36
|
+
/** Disables the input and chip-removal buttons. */
|
|
37
|
+
disabled?: boolean
|
|
38
|
+
/** Tints the wrapper border destructive and sets `aria-invalid` on the
|
|
39
|
+
* inner input. Consumer-controlled - independent of per-chip validity. */
|
|
40
|
+
invalid?: boolean
|
|
41
|
+
/** id for the inner <input>, so a sibling <Label htmlFor> binds correctly. */
|
|
42
|
+
id?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Valid-chip tint per variant. Invalid chips bypass this map and always
|
|
46
|
+
// render in the destructive treatment so the error reads unambiguously.
|
|
47
|
+
const VARIANT_VALID_CLASS: Record<EmailChipsInputVariant, string> = {
|
|
48
|
+
default: "ax:bg-muted ax:dark:bg-neutral ax:text-foreground ax:dark:text-neutral-muted",
|
|
49
|
+
primary: "ax:bg-primary-weakest ax:dark:bg-primary-strong ax:text-primary-foreground ax:dark:text-primary-muted",
|
|
50
|
+
destructive: "ax:bg-destructive-weakest ax:dark:bg-destructive-strong ax:text-destructive-foreground ax:dark:text-destructive-muted",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Multi-email input that turns each accepted entry into a chip. Adds on
|
|
54
|
+
* `Enter` or `,`. Backspace on an empty input removes the last chip.
|
|
55
|
+
* Pasting a `, ; \n \t`-separated list adds each address as a chip.
|
|
56
|
+
*
|
|
57
|
+
* Validation is local (regex-based) and non-blocking: invalid chips
|
|
58
|
+
* render in a destructive tint with a tooltip explaining why. Consumers
|
|
59
|
+
* filter `chip.valid` before submitting. */
|
|
60
|
+
export const EmailChipsInput = React.forwardRef<HTMLDivElement, EmailChipsInputProps>(
|
|
61
|
+
(
|
|
62
|
+
{
|
|
63
|
+
chips,
|
|
64
|
+
onChange,
|
|
65
|
+
placeholder = "Enter email and press Enter…",
|
|
66
|
+
onDuplicate,
|
|
67
|
+
variant = "default",
|
|
68
|
+
disabled = false,
|
|
69
|
+
invalid = false,
|
|
70
|
+
id,
|
|
71
|
+
className,
|
|
72
|
+
...props
|
|
73
|
+
},
|
|
74
|
+
ref,
|
|
75
|
+
) => {
|
|
76
|
+
const [input, setInput] = React.useState("")
|
|
77
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
78
|
+
|
|
79
|
+
function addChip(raw: string) {
|
|
80
|
+
const value = raw.trim().toLowerCase()
|
|
81
|
+
if (!value) return
|
|
82
|
+
if (chips.some((c) => c.value === value)) {
|
|
83
|
+
onDuplicate?.()
|
|
84
|
+
setInput("")
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
onChange([...chips, { value, valid: isValidEmail(value) }])
|
|
88
|
+
setInput("")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Multi-add for paste: split, de-dupe against current chips AND against
|
|
92
|
+
// earlier entries in the same paste, then emit a single onChange update.
|
|
93
|
+
function addMany(raws: string[]) {
|
|
94
|
+
const next: EmailChip[] = [...chips]
|
|
95
|
+
const seen = new Set(chips.map((c) => c.value))
|
|
96
|
+
let duped = false
|
|
97
|
+
|
|
98
|
+
for (const raw of raws) {
|
|
99
|
+
const value = raw.trim().toLowerCase()
|
|
100
|
+
if (!value) continue
|
|
101
|
+
if (seen.has(value)) { duped = true; continue }
|
|
102
|
+
seen.add(value)
|
|
103
|
+
next.push({ value, valid: isValidEmail(value) })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (next.length !== chips.length) onChange(next)
|
|
107
|
+
if (duped) onDuplicate?.()
|
|
108
|
+
setInput("")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function removeChip(index: number) {
|
|
112
|
+
if (disabled) return
|
|
113
|
+
onChange(chips.filter((_, i) => i !== index))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
|
117
|
+
if (e.key === "Enter" || e.key === ",") {
|
|
118
|
+
e.preventDefault()
|
|
119
|
+
addChip(input)
|
|
120
|
+
} else if (e.key === "Backspace" && !input && chips.length > 0) {
|
|
121
|
+
removeChip(chips.length - 1)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
|
|
126
|
+
const pasted = e.clipboardData.getData("text")
|
|
127
|
+
if (!pasted) return
|
|
128
|
+
// Only intercept if the paste looks list-shaped: single-address
|
|
129
|
+
// pastes still go through the native flow + Enter/blur.
|
|
130
|
+
if (SPLIT_RE.test(pasted)) {
|
|
131
|
+
e.preventDefault()
|
|
132
|
+
// Include any partial input the user already typed before pasting.
|
|
133
|
+
const combined = input ? [input, pasted].join(" ") : pasted
|
|
134
|
+
addMany(combined.split(SPLIT_RE))
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<TooltipProvider>
|
|
140
|
+
<div
|
|
141
|
+
ref={ref}
|
|
142
|
+
className={cn(
|
|
143
|
+
"ax:min-h-3xl ax:flex ax:flex-wrap ax:gap-xs ax:p-xs",
|
|
144
|
+
"ax:rounded-(--input-radius) ax:border ax:border-input ax:bg-background",
|
|
145
|
+
"ax:focus-within:ring-2 ax:focus-within:ring-ring ax:focus-within:ring-offset-0",
|
|
146
|
+
"ax:cursor-text",
|
|
147
|
+
invalid && "ax:border-destructive ax:focus-within:ring-destructive",
|
|
148
|
+
disabled && "ax:opacity-60 ax:cursor-not-allowed ax:bg-muted/30",
|
|
149
|
+
className,
|
|
150
|
+
)}
|
|
151
|
+
onClick={() => !disabled && inputRef.current?.focus()}
|
|
152
|
+
{...props}
|
|
153
|
+
>
|
|
154
|
+
{chips.map((chip, i) => (
|
|
155
|
+
<Tooltip key={`${chip.value}-${i}`}>
|
|
156
|
+
<TooltipTrigger asChild>
|
|
157
|
+
<span
|
|
158
|
+
className={cn(
|
|
159
|
+
"ax:inline-flex ax:items-center ax:gap-1 ax:px-xs ax:py-2xs ax:rounded-badge",
|
|
160
|
+
"ax:t-label-sm ax:max-w-50",
|
|
161
|
+
chip.valid
|
|
162
|
+
? VARIANT_VALID_CLASS[variant]
|
|
163
|
+
: "ax:bg-destructive-weakest ax:dark:bg-destructive-strong ax:text-destructive-foreground ax:dark:text-destructive-muted",
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
<span className="ax:truncate">{chip.value}</span>
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
disabled={disabled}
|
|
170
|
+
onClick={(e) => { e.stopPropagation(); removeChip(i) }}
|
|
171
|
+
className={cn(
|
|
172
|
+
"ax:shrink-0 ax:rounded-full ax:hover:bg-foreground/10",
|
|
173
|
+
"ax:focus-visible:outline-none ax:focus-visible:ring-1 ax:focus-visible:ring-ring",
|
|
174
|
+
"ax:disabled:pointer-events-none",
|
|
175
|
+
)}
|
|
176
|
+
aria-label={`Remove ${chip.value}`}
|
|
177
|
+
>
|
|
178
|
+
<X className="ax:size-3" />
|
|
179
|
+
</button>
|
|
180
|
+
</span>
|
|
181
|
+
</TooltipTrigger>
|
|
182
|
+
{!chip.valid && (
|
|
183
|
+
<TooltipContent side="top">Invalid email address</TooltipContent>
|
|
184
|
+
)}
|
|
185
|
+
</Tooltip>
|
|
186
|
+
))}
|
|
187
|
+
|
|
188
|
+
<input
|
|
189
|
+
ref={inputRef}
|
|
190
|
+
id={id}
|
|
191
|
+
value={input}
|
|
192
|
+
onChange={(e) => setInput(e.target.value)}
|
|
193
|
+
onKeyDown={handleKeyDown}
|
|
194
|
+
onPaste={handlePaste}
|
|
195
|
+
onBlur={() => { if (input.trim()) addChip(input) }}
|
|
196
|
+
disabled={disabled}
|
|
197
|
+
aria-invalid={invalid || undefined}
|
|
198
|
+
placeholder={chips.length === 0 ? placeholder : ""}
|
|
199
|
+
className={cn(
|
|
200
|
+
"ax:flex-1 ax:min-w-40 ax:bg-transparent ax:outline-none",
|
|
201
|
+
"ax:t-body-sm ax:text-foreground ax:placeholder:text-muted-foreground",
|
|
202
|
+
"ax:disabled:cursor-not-allowed",
|
|
203
|
+
)}
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
</TooltipProvider>
|
|
207
|
+
)
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
EmailChipsInput.displayName = "EmailChipsInput"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { FolderOpen } from "lucide-react"
|
|
4
|
+
import { Button } from "./button"
|
|
5
|
+
import { EmptyState } from "./empty-state"
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof EmptyState> = {
|
|
8
|
+
title: "Components/EmptyState",
|
|
9
|
+
component: EmptyState,
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
args: {
|
|
12
|
+
icon: <FolderOpen className="ax:size-6" />,
|
|
13
|
+
title: "No projects yet",
|
|
14
|
+
description: "Create your first project to get started.",
|
|
15
|
+
action: <Button>Create project</Button>,
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default meta
|
|
20
|
+
type Story = StoryObj<typeof EmptyState>
|
|
21
|
+
|
|
22
|
+
export const Default: Story = {}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "../lib/cn"
|
|
3
|
+
|
|
4
|
+
export interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
icon?: React.ReactNode
|
|
6
|
+
title: string
|
|
7
|
+
description?: string
|
|
8
|
+
action?: React.ReactNode
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function EmptyState({ className, icon, title, description, action, ...props }: EmptyStateProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
className={cn(
|
|
15
|
+
"ax:flex ax:flex-col ax:items-center ax:justify-center ax:text-center",
|
|
16
|
+
"ax:py-5xl ax:px-xl ax:gap-md",
|
|
17
|
+
className
|
|
18
|
+
)}
|
|
19
|
+
{...props}
|
|
20
|
+
>
|
|
21
|
+
{icon && (
|
|
22
|
+
<div className="ax:size-4xl ax:rounded-full ax:bg-muted ax:flex ax:items-center ax:justify-center ax:text-muted-foreground">
|
|
23
|
+
{icon}
|
|
24
|
+
</div>
|
|
25
|
+
)}
|
|
26
|
+
<div className="ax:space-y-1.5 ax:max-w-md">
|
|
27
|
+
<h3 className="ax:t-h5 ax:text-foreground">{title}</h3>
|
|
28
|
+
{description && (
|
|
29
|
+
<p className="ax:t-body-sm ax:text-muted-foreground">{description}</p>
|
|
30
|
+
)}
|
|
31
|
+
</div>
|
|
32
|
+
{action && <div>{action}</div>}
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { EntityHeader } from "./entity-header"
|
|
4
|
+
import { DataList, DataListItem, DataListLabel, DataListValue } from "./data-list"
|
|
5
|
+
import { Button } from "./button"
|
|
6
|
+
|
|
7
|
+
const DETAILS = [
|
|
8
|
+
{ label: "Trade licence", value: "2120561384412A" },
|
|
9
|
+
{ label: "Brokerage licence", value: "8812612491127" },
|
|
10
|
+
{ label: "Licence expiry", value: "15 May 2028" },
|
|
11
|
+
{ label: "Phone", value: "00 1098231807" },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
const details = (
|
|
15
|
+
<DataList orientation="grid" columns={4} size="sm">
|
|
16
|
+
{DETAILS.map((detail) => (
|
|
17
|
+
<DataListItem key={detail.label}>
|
|
18
|
+
<DataListLabel>{detail.label}</DataListLabel>
|
|
19
|
+
<DataListValue>{detail.value}</DataListValue>
|
|
20
|
+
</DataListItem>
|
|
21
|
+
))}
|
|
22
|
+
</DataList>
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const meta: Meta<typeof EntityHeader> = {
|
|
26
|
+
title: "Components/EntityHeader",
|
|
27
|
+
component: EntityHeader,
|
|
28
|
+
tags: ["autodocs"],
|
|
29
|
+
args: { name: "Remax RealEstate", subtitle: "Remax Real Estate Brokerage L.L.C." },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default meta
|
|
33
|
+
type Story = StoryObj<typeof EntityHeader>
|
|
34
|
+
|
|
35
|
+
export const Default: Story = {}
|
|
36
|
+
|
|
37
|
+
export const WithDetails: Story = { args: { details } }
|
|
38
|
+
|
|
39
|
+
export const WithActions: Story = {
|
|
40
|
+
args: { details, actions: <><Button variant="outline" size="sm">Edit details</Button><Button size="sm">Contact</Button></> },
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const Medium: Story = { args: { size: "md", name: "Omar Khalifa Real Estate", subtitle: "CN-1907642" } }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
4
|
+
import { EntityHeader, entityInitials } from "./entity-header"
|
|
5
|
+
|
|
6
|
+
describe("entityInitials", () => {
|
|
7
|
+
it("takes the first letter of the first two words", () => {
|
|
8
|
+
expect(entityInitials("Remax RealEstate")).toBe("RR")
|
|
9
|
+
expect(entityInitials(" omar khalifa real estate ")).toBe("OK")
|
|
10
|
+
expect(entityInitials("Aldar")).toBe("A")
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe("EntityHeader", () => {
|
|
15
|
+
it("renders the name as a heading at the chosen level", () => {
|
|
16
|
+
expect(renderToStaticMarkup(<EntityHeader name="Remax RealEstate" />)).toMatch(/<h2[^>]*>Remax RealEstate<\/h2>/)
|
|
17
|
+
expect(renderToStaticMarkup(<EntityHeader name="Remax RealEstate" as="h1" />)).toMatch(/<h1[^>]*>Remax RealEstate<\/h1>/)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("shows decorative initials without a logo, and the logo with its alt text when given", () => {
|
|
21
|
+
const initials = renderToStaticMarkup(<EntityHeader name="Remax RealEstate" />)
|
|
22
|
+
expect(initials).toMatch(/aria-hidden="true"[^>]*>RR</)
|
|
23
|
+
const logo = renderToStaticMarkup(<EntityHeader name="Remax RealEstate" logo={{ src: "/remax.webp", alt: "Remax logo" }} />)
|
|
24
|
+
expect(logo).toContain('alt="Remax logo"')
|
|
25
|
+
expect(logo).not.toContain(">RR<")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("renders the details and actions slots only when provided", () => {
|
|
29
|
+
const aside = "ax:flex ax:min-w-0 ax:flex-col ax:gap-md ax:lg:items-end"
|
|
30
|
+
expect(renderToStaticMarkup(<EntityHeader name="A" />)).not.toContain(aside)
|
|
31
|
+
const withDetails = renderToStaticMarkup(<EntityHeader name="A" details={<span>Licence</span>} />)
|
|
32
|
+
expect(withDetails).toContain(aside)
|
|
33
|
+
expect(withDetails).toContain("Licence")
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { tv, type VariantProps } from "tailwind-variants"
|
|
3
|
+
import { Heading, type HeadingAs, type HeadingSize } from "./heading"
|
|
4
|
+
import { cn } from "../lib/cn"
|
|
5
|
+
|
|
6
|
+
export type EntityHeaderSize = "md" | "lg"
|
|
7
|
+
|
|
8
|
+
/** Up to two initials from the name's first words, e.g. "Remax RealEstate" -> "RR". */
|
|
9
|
+
export function entityInitials(name: string) {
|
|
10
|
+
return name
|
|
11
|
+
.trim()
|
|
12
|
+
.split(/\s+/)
|
|
13
|
+
.slice(0, 2)
|
|
14
|
+
.map((word) => word.charAt(0).toUpperCase())
|
|
15
|
+
.join("")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const entityHeaderVariants = tv({
|
|
19
|
+
slots: {
|
|
20
|
+
root: "ax:flex ax:flex-col ax:gap-lg ax:lg:flex-row ax:lg:items-end ax:lg:justify-between",
|
|
21
|
+
identity: "ax:flex ax:min-w-0 ax:items-center ax:gap-md",
|
|
22
|
+
mark: "ax:flex ax:shrink-0 ax:items-center ax:justify-center ax:overflow-hidden ax:rounded-md ax:bg-primary-weaker ax:font-bold ax:text-primary-foreground ax:dark:bg-primary-strong ax:dark:text-primary-weaker",
|
|
23
|
+
text: "ax:flex ax:min-w-0 ax:flex-col",
|
|
24
|
+
subtitle: "ax:t-caption ax:text-muted-foreground",
|
|
25
|
+
aside: "ax:flex ax:min-w-0 ax:flex-col ax:gap-md ax:lg:items-end",
|
|
26
|
+
},
|
|
27
|
+
variants: {
|
|
28
|
+
size: {
|
|
29
|
+
md: { mark: "ax:size-12 ax:t-body-lg" },
|
|
30
|
+
lg: { mark: "ax:size-14 ax:t-body-xl" },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
defaultVariants: { size: "lg" },
|
|
34
|
+
}, { twMerge: false })
|
|
35
|
+
|
|
36
|
+
const NAME_SIZE: Record<EntityHeaderSize, HeadingSize> = { md: "h3", lg: "h2" }
|
|
37
|
+
|
|
38
|
+
export interface EntityHeaderProps
|
|
39
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
|
|
40
|
+
VariantProps<typeof entityHeaderVariants> {
|
|
41
|
+
/** Organisation, company or property name. */
|
|
42
|
+
name: string
|
|
43
|
+
/** Secondary line, e.g. the legal name or a registration number. */
|
|
44
|
+
subtitle?: React.ReactNode
|
|
45
|
+
/** Logo image. Without it the mark shows initials from `name`. */
|
|
46
|
+
logo?: { src: string; alt: string }
|
|
47
|
+
/** Heading level for the name. Defaults to `h2`. */
|
|
48
|
+
as?: HeadingAs
|
|
49
|
+
/** Key facts beside the identity, typically `<DataList orientation="grid">`. */
|
|
50
|
+
details?: React.ReactNode
|
|
51
|
+
/** Actions such as Edit or Contact, aligned with the details. */
|
|
52
|
+
actions?: React.ReactNode
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* EntityHeader - the identity block for an organisation, brokerage or
|
|
57
|
+
* property: a logo (or initials) mark, the name as a heading, a secondary
|
|
58
|
+
* line, and slots for key details and actions. Stacks on small screens and
|
|
59
|
+
* sits in a row, bottom-aligned, from `lg` up.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* <EntityHeader
|
|
63
|
+
* name="Remax RealEstate"
|
|
64
|
+
* subtitle="Remax Real Estate Brokerage L.L.C."
|
|
65
|
+
* details={<DataList orientation="grid" columns={4} size="sm">…</DataList>}
|
|
66
|
+
* />
|
|
67
|
+
*/
|
|
68
|
+
export const EntityHeader = React.forwardRef<HTMLDivElement, EntityHeaderProps>(
|
|
69
|
+
({ className, name, subtitle, logo, as = "h2", size, details, actions, ...props }, ref) => {
|
|
70
|
+
const styles = entityHeaderVariants({ size })
|
|
71
|
+
return (
|
|
72
|
+
<div ref={ref} data-slot="entity-header" className={cn(styles.root(), className)} {...props}>
|
|
73
|
+
<div className={styles.identity()}>
|
|
74
|
+
<span className={styles.mark()} aria-hidden={logo ? undefined : true}>
|
|
75
|
+
{logo ? <img src={logo.src} alt={logo.alt} className="ax:size-full ax:object-contain" /> : entityInitials(name)}
|
|
76
|
+
</span>
|
|
77
|
+
<div className={styles.text()}>
|
|
78
|
+
<Heading as={as} size={NAME_SIZE[size ?? "lg"]} truncate>{name}</Heading>
|
|
79
|
+
{subtitle && <p className={styles.subtitle()}>{subtitle}</p>}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
{(details || actions) && (
|
|
83
|
+
<div className={styles.aside()}>
|
|
84
|
+
{details}
|
|
85
|
+
{actions && <div className="ax:flex ax:flex-wrap ax:gap-xs">{actions}</div>}
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
EntityHeader.displayName = "EntityHeader"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { Sparkles, Shield, Zap } from "lucide-react"
|
|
4
|
+
import { FeatureGrid } from "./feature-grid"
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof FeatureGrid> = {
|
|
7
|
+
title: "Components/FeatureGrid",
|
|
8
|
+
component: FeatureGrid,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
args: {
|
|
11
|
+
eyebrow: "Features",
|
|
12
|
+
title: "Everything you need to ship faster",
|
|
13
|
+
description: "Composable primitives with brand-aware styling.",
|
|
14
|
+
features: [
|
|
15
|
+
{ icon: <Zap className="ax:size-5" />, title: "Fast", description: "Optimized for performance." },
|
|
16
|
+
{ icon: <Shield className="ax:size-5" />, title: "Accessible", description: "Built on Radix primitives." },
|
|
17
|
+
{ icon: <Sparkles className="ax:size-5" />, title: "Beautiful", description: "Polished defaults out of the box." },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default meta
|
|
23
|
+
type Story = StoryObj<typeof FeatureGrid>
|
|
24
|
+
|
|
25
|
+
export const Default: Story = {}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { motion } from "motion/react"
|
|
5
|
+
import { cn } from "../lib/cn"
|
|
6
|
+
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
|
7
|
+
import { stagger, variants, viewport as defaultViewport } from "../lib/motion"
|
|
8
|
+
|
|
9
|
+
export interface Feature {
|
|
10
|
+
icon?: React.ReactNode
|
|
11
|
+
title: React.ReactNode
|
|
12
|
+
description: React.ReactNode
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FeatureGridProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
|
|
16
|
+
eyebrow?: React.ReactNode
|
|
17
|
+
title?: React.ReactNode
|
|
18
|
+
description?: React.ReactNode
|
|
19
|
+
features: Feature[]
|
|
20
|
+
/** Number of columns at the largest breakpoint. Default 3. */
|
|
21
|
+
columns?: 2 | 3 | 4
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* FeatureGrid - marketing feature showcase.
|
|
26
|
+
* Mobile-first: 1 column. Tablet: 2. Desktop: 3 (or 2/4 via prop).
|
|
27
|
+
*/
|
|
28
|
+
export const FeatureGrid = React.forwardRef<HTMLElement, FeatureGridProps>(
|
|
29
|
+
({ className, eyebrow, title, description, features, columns = 3, ...props }, ref) => {
|
|
30
|
+
const reduce = useReducedMotion()
|
|
31
|
+
|
|
32
|
+
const colClass =
|
|
33
|
+
columns === 2 ? "ax:md:grid-cols-2" : columns === 4 ? "ax:md:grid-cols-2 ax:lg:grid-cols-4" : "ax:md:grid-cols-2 ax:lg:grid-cols-3"
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<section ref={ref} className={cn("ax:w-full ax:px-xl ax:md:px-3xl ax:py-5xl ax:md:py-6xl", className)} {...props}>
|
|
37
|
+
<div className="ax:mx-auto ax:max-w-420">
|
|
38
|
+
{(eyebrow || title || description) && (
|
|
39
|
+
<div className="ax:max-w-3xl ax:mb-4xl ax:md:mb-5xl">
|
|
40
|
+
{eyebrow && (
|
|
41
|
+
<span className="ax:inline-block ax:t-overline ax:text-primary ax:mb-sm">
|
|
42
|
+
{eyebrow}
|
|
43
|
+
</span>
|
|
44
|
+
)}
|
|
45
|
+
{title && (
|
|
46
|
+
<h2 className="ax:t-display-md ax:md:t-display-lg ax:text-foreground ax:[text-wrap:balance]">
|
|
47
|
+
{title}
|
|
48
|
+
</h2>
|
|
49
|
+
)}
|
|
50
|
+
{description && (
|
|
51
|
+
<p className="ax:mt-md ax:t-body-md ax:md:t-body-lg ax:text-muted-foreground ax:[text-wrap:pretty]">
|
|
52
|
+
{description}
|
|
53
|
+
</p>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
)}
|
|
57
|
+
<motion.ul
|
|
58
|
+
className={cn("ax:grid ax:grid-cols-1 ax:gap-x-2xl ax:gap-y-3xl", colClass)}
|
|
59
|
+
{...(reduce ? {} : {
|
|
60
|
+
variants: stagger(),
|
|
61
|
+
initial: "hidden",
|
|
62
|
+
whileInView: "visible",
|
|
63
|
+
viewport: defaultViewport,
|
|
64
|
+
})}
|
|
65
|
+
>
|
|
66
|
+
{features.map((feature, i) => (
|
|
67
|
+
<motion.li
|
|
68
|
+
key={i}
|
|
69
|
+
{...(reduce ? {} : { variants: variants.fadeUp })}
|
|
70
|
+
className="ax:flex ax:flex-col ax:gap-sm"
|
|
71
|
+
>
|
|
72
|
+
{feature.icon && (
|
|
73
|
+
<div className="ax:size-3xl ax:rounded-md ax:bg-primary/10 ax:text-primary ax:flex ax:items-center ax:justify-center ax:[&>svg]:size-5">
|
|
74
|
+
{feature.icon}
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
<h3 className="ax:t-h3 ax:text-foreground">{feature.title}</h3>
|
|
78
|
+
<p className="ax:t-body-sm ax:text-muted-foreground">{feature.description}</p>
|
|
79
|
+
</motion.li>
|
|
80
|
+
))}
|
|
81
|
+
</motion.ul>
|
|
82
|
+
</div>
|
|
83
|
+
</section>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
FeatureGrid.displayName = "FeatureGrid"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { Sparkles } from "lucide-react"
|
|
4
|
+
import { FeedbackBar } from "./feedback-bar"
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof FeedbackBar> = {
|
|
7
|
+
title: "Components/FeedbackBar",
|
|
8
|
+
component: FeedbackBar,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
args: {
|
|
11
|
+
title: "Was this helpful?",
|
|
12
|
+
onHelpful: () => undefined,
|
|
13
|
+
onNotHelpful: () => undefined,
|
|
14
|
+
onClose: () => undefined,
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default meta
|
|
19
|
+
type Story = StoryObj<typeof FeedbackBar>
|
|
20
|
+
|
|
21
|
+
export const Default: Story = {}
|
|
22
|
+
|
|
23
|
+
export const WithIcon: Story = { args: { icon: <Sparkles /> } }
|
|
24
|
+
|
|
25
|
+
export const WithoutClose: Story = { args: { onClose: undefined } }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
4
|
+
import { FeedbackBar } from "./feedback-bar"
|
|
5
|
+
|
|
6
|
+
describe("FeedbackBar", () => {
|
|
7
|
+
it("names the group by its title and labels both ratings", () => {
|
|
8
|
+
const html = renderToStaticMarkup(<FeedbackBar title="Was this helpful?" />)
|
|
9
|
+
expect(html).toContain('role="group"')
|
|
10
|
+
expect(html).toContain('aria-label="Was this helpful?"')
|
|
11
|
+
expect(html).toContain('aria-label="Helpful"')
|
|
12
|
+
expect(html).toContain('aria-label="Not helpful"')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("shows the close button only when onClose is passed", () => {
|
|
16
|
+
expect(renderToStaticMarkup(<FeedbackBar title="Was this helpful?" />)).not.toContain('aria-label="Close"')
|
|
17
|
+
expect(renderToStaticMarkup(<FeedbackBar title="Was this helpful?" onClose={() => undefined} />)).toContain('aria-label="Close"')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("hides the decorative icon from assistive tech", () => {
|
|
21
|
+
const html = renderToStaticMarkup(<FeedbackBar title="Was this helpful?" icon={<svg data-testid="logo" />} />)
|
|
22
|
+
expect(html).toMatch(/<span[^>]*aria-hidden="true"[^>]*><svg data-testid="logo"/)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { ThumbsDown, ThumbsUp, X } from "lucide-react"
|
|
5
|
+
import { cn } from "../lib/cn"
|
|
6
|
+
import { Button } from "./button"
|
|
7
|
+
|
|
8
|
+
export interface FeedbackBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
9
|
+
/** The question, e.g. "Was this helpful?". Also names the group. */
|
|
10
|
+
title: string
|
|
11
|
+
/** Optional mark before the title, such as the assistant's logo. */
|
|
12
|
+
icon?: React.ReactNode
|
|
13
|
+
onHelpful?: () => void
|
|
14
|
+
onNotHelpful?: () => void
|
|
15
|
+
/** Shows the close button. Hide the bar yourself when it fires. */
|
|
16
|
+
onClose?: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* FeedbackBar - a compact "Was this helpful?" prompt: a title, thumbs up and
|
|
21
|
+
* down, and an optional close. Use it once at the end of a conversation or
|
|
22
|
+
* page; for the actions under each answer use AnswerActions.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* <FeedbackBar title="Was this helpful?" onHelpful={up} onNotHelpful={down} onClose={hide} />
|
|
26
|
+
*/
|
|
27
|
+
export const FeedbackBar = React.forwardRef<HTMLDivElement, FeedbackBarProps>(
|
|
28
|
+
({ title, icon, onHelpful, onNotHelpful, onClose, className, ...props }, ref) => (
|
|
29
|
+
<div
|
|
30
|
+
ref={ref}
|
|
31
|
+
role="group"
|
|
32
|
+
aria-label={title}
|
|
33
|
+
data-slot="feedback-bar"
|
|
34
|
+
className={cn(
|
|
35
|
+
"ax:inline-flex ax:items-stretch ax:rounded-lg ax:border ax:border-border ax:bg-background ax:t-body-sm",
|
|
36
|
+
className,
|
|
37
|
+
)}
|
|
38
|
+
{...props}
|
|
39
|
+
>
|
|
40
|
+
<div className="ax:flex ax:flex-1 ax:items-center ax:gap-sm ax:py-sm ax:ps-md">
|
|
41
|
+
{icon && <span className="ax:inline-flex ax:shrink-0 ax:[&_svg]:size-4" aria-hidden>{icon}</span>}
|
|
42
|
+
<span className="ax:font-medium ax:text-foreground">{title}</span>
|
|
43
|
+
</div>
|
|
44
|
+
<div className="ax:flex ax:items-center ax:gap-2xs ax:px-sm">
|
|
45
|
+
<Button variant="ghost" size="icon-sm" className="ax:text-muted-foreground ax:hover:text-foreground" aria-label="Helpful" onClick={onHelpful}>
|
|
46
|
+
<ThumbsUp />
|
|
47
|
+
</Button>
|
|
48
|
+
<Button variant="ghost" size="icon-sm" className="ax:text-muted-foreground ax:hover:text-foreground" aria-label="Not helpful" onClick={onNotHelpful}>
|
|
49
|
+
<ThumbsDown />
|
|
50
|
+
</Button>
|
|
51
|
+
</div>
|
|
52
|
+
{onClose && (
|
|
53
|
+
<div className="ax:flex ax:items-center ax:border-s ax:border-border ax:px-2xs">
|
|
54
|
+
<Button variant="ghost" size="icon-sm" className="ax:text-muted-foreground ax:hover:text-foreground" aria-label="Close" onClick={onClose}>
|
|
55
|
+
<X />
|
|
56
|
+
</Button>
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
FeedbackBar.displayName = "FeedbackBar"
|