@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,161 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Link2 } from "lucide-react"
|
|
5
|
+
import { cn } from "../lib/cn"
|
|
6
|
+
import { Popover, PopoverAnchor, PopoverContent } from "./popover"
|
|
7
|
+
import { Text } from "./text"
|
|
8
|
+
|
|
9
|
+
const OPEN_DELAY_MS = 150
|
|
10
|
+
/** Long enough to move the pointer from the pill into the preview. */
|
|
11
|
+
const CLOSE_DELAY_MS = 120
|
|
12
|
+
|
|
13
|
+
/** "zillow.com" from a URL; the input itself when it is not a valid URL. */
|
|
14
|
+
export function sourceDomain(href: string) {
|
|
15
|
+
try {
|
|
16
|
+
return new URL(href).hostname.replace(/^www\./, "")
|
|
17
|
+
} catch {
|
|
18
|
+
return href
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SourceContextValue {
|
|
23
|
+
href: string
|
|
24
|
+
domain: string
|
|
25
|
+
open: boolean
|
|
26
|
+
show: () => void
|
|
27
|
+
hide: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SourceContext = React.createContext<SourceContextValue | null>(null)
|
|
31
|
+
|
|
32
|
+
function useSource(part: string) {
|
|
33
|
+
const context = React.useContext(SourceContext)
|
|
34
|
+
if (!context) throw new Error(`${part} must be used inside Source`)
|
|
35
|
+
return context
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SourceProps {
|
|
39
|
+
href: string
|
|
40
|
+
children: React.ReactNode
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Source - a link to where an answer came from: a small pill labelled with
|
|
45
|
+
* the domain, previewing the page on hover and keyboard focus. Works from a
|
|
46
|
+
* URL alone, so it suits web search results.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* <Source href="https://www.propertyfinder.ae/en/yas-island">
|
|
50
|
+
* <SourceTrigger />
|
|
51
|
+
* <SourceContent title="Yas Island townhouses" description="42 listings under AED 5M" />
|
|
52
|
+
* </Source>
|
|
53
|
+
*/
|
|
54
|
+
export function Source({ href, children }: SourceProps) {
|
|
55
|
+
const [open, setOpen] = React.useState(false)
|
|
56
|
+
const timer = React.useRef<number | undefined>(undefined)
|
|
57
|
+
|
|
58
|
+
const schedule = React.useCallback((next: boolean, delay: number) => {
|
|
59
|
+
window.clearTimeout(timer.current)
|
|
60
|
+
timer.current = window.setTimeout(() => setOpen(next), delay)
|
|
61
|
+
}, [])
|
|
62
|
+
const show = React.useCallback(() => schedule(true, OPEN_DELAY_MS), [schedule])
|
|
63
|
+
const hide = React.useCallback(() => schedule(false, CLOSE_DELAY_MS), [schedule])
|
|
64
|
+
React.useEffect(() => () => window.clearTimeout(timer.current), [])
|
|
65
|
+
|
|
66
|
+
const value = React.useMemo(() => ({ href, domain: sourceDomain(href), open, show, hide }), [href, open, show, hide])
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<SourceContext.Provider value={value}>
|
|
70
|
+
<Popover open={open} onOpenChange={setOpen}>{children}</Popover>
|
|
71
|
+
</SourceContext.Provider>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function Favicon({ src, className }: { src?: string; className?: string }) {
|
|
76
|
+
const [failed, setFailed] = React.useState(false)
|
|
77
|
+
if (!src || failed) return <Link2 className={className} aria-hidden />
|
|
78
|
+
// eslint-disable-next-line @next/next/no-img-element -- framework-agnostic package
|
|
79
|
+
return <img src={src} alt="" className={cn("ax:rounded-full", className)} onError={() => setFailed(true)} />
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SourceTriggerProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
|
|
83
|
+
/** Replaces the domain, e.g. a citation number. */
|
|
84
|
+
label?: React.ReactNode
|
|
85
|
+
/** Shows a favicon before the label. Pass an image URL to use it; otherwise a link icon. */
|
|
86
|
+
favicon?: string | boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The pill in running text. It is a real link to the source. */
|
|
90
|
+
export const SourceTrigger = React.forwardRef<HTMLAnchorElement, SourceTriggerProps>(
|
|
91
|
+
({ label, favicon, className, onMouseEnter, onMouseLeave, onFocus, onBlur, ...props }, ref) => {
|
|
92
|
+
const { href, domain, show, hide } = useSource("SourceTrigger")
|
|
93
|
+
const custom = label !== undefined
|
|
94
|
+
return (
|
|
95
|
+
<PopoverAnchor asChild>
|
|
96
|
+
<a
|
|
97
|
+
ref={ref}
|
|
98
|
+
href={href}
|
|
99
|
+
target="_blank"
|
|
100
|
+
rel="noopener noreferrer"
|
|
101
|
+
data-slot="source-trigger"
|
|
102
|
+
aria-label={custom ? `Source ${typeof label === "string" || typeof label === "number" ? `${label}, ` : ""}${domain}` : undefined}
|
|
103
|
+
onMouseEnter={(event) => { onMouseEnter?.(event); show() }}
|
|
104
|
+
onMouseLeave={(event) => { onMouseLeave?.(event); hide() }}
|
|
105
|
+
onFocus={(event) => { onFocus?.(event); show() }}
|
|
106
|
+
onBlur={(event) => { onBlur?.(event); hide() }}
|
|
107
|
+
className={cn(
|
|
108
|
+
"ax:mx-0.5 ax:inline-flex ax:h-5 ax:max-w-32 ax:items-center ax:gap-1 ax:overflow-hidden ax:rounded-full ax:bg-muted ax:align-text-top",
|
|
109
|
+
"ax:text-xs ax:tabular-nums ax:text-muted-foreground ax:no-underline ax:transition-colors",
|
|
110
|
+
"ax:hover:bg-muted-foreground/20 ax:hover:text-foreground ax:focus-visible:outline-none ax:focus-visible:ring-2 ax:focus-visible:ring-ring",
|
|
111
|
+
favicon ? "ax:ps-1 ax:pe-2" : "ax:px-1.5",
|
|
112
|
+
className,
|
|
113
|
+
)}
|
|
114
|
+
{...props}
|
|
115
|
+
>
|
|
116
|
+
{favicon && <Favicon src={typeof favicon === "string" ? favicon : undefined} className="ax:size-3.5 ax:shrink-0" />}
|
|
117
|
+
<span className="ax:truncate">{custom ? label : domain}</span>
|
|
118
|
+
</a>
|
|
119
|
+
</PopoverAnchor>
|
|
120
|
+
)
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
SourceTrigger.displayName = "SourceTrigger"
|
|
124
|
+
|
|
125
|
+
export interface SourceContentProps {
|
|
126
|
+
title: string
|
|
127
|
+
description?: string
|
|
128
|
+
/** Image URL for the site's icon; a link icon otherwise. */
|
|
129
|
+
favicon?: string
|
|
130
|
+
className?: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The hover preview: domain, title and description, all one link to the source. */
|
|
134
|
+
export function SourceContent({ title, description, favicon, className }: SourceContentProps) {
|
|
135
|
+
const { href, domain, show, hide } = useSource("SourceContent")
|
|
136
|
+
return (
|
|
137
|
+
<PopoverContent
|
|
138
|
+
align="start"
|
|
139
|
+
className={cn("ax:w-80 ax:p-0 ax:shadow-xs", className)}
|
|
140
|
+
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
141
|
+
onCloseAutoFocus={(event) => event.preventDefault()}
|
|
142
|
+
onMouseEnter={show}
|
|
143
|
+
onMouseLeave={hide}
|
|
144
|
+
>
|
|
145
|
+
<a
|
|
146
|
+
href={href}
|
|
147
|
+
target="_blank"
|
|
148
|
+
rel="noopener noreferrer"
|
|
149
|
+
tabIndex={-1}
|
|
150
|
+
className="ax:flex ax:flex-col ax:gap-xs ax:rounded-[inherit] ax:p-sm ax:no-underline ax:transition-colors ax:hover:bg-muted/50"
|
|
151
|
+
>
|
|
152
|
+
<span className="ax:flex ax:items-center ax:gap-1.5 ax:text-muted-foreground">
|
|
153
|
+
<Favicon src={favicon} className="ax:size-4 ax:shrink-0" />
|
|
154
|
+
<Text size="1" color="muted" truncate>{domain}</Text>
|
|
155
|
+
</span>
|
|
156
|
+
<Text as="span" size="2" weight="medium" className="ax:line-clamp-2">{title}</Text>
|
|
157
|
+
{description && <Text as="span" size="2" color="muted" className="ax:line-clamp-2">{description}</Text>}
|
|
158
|
+
</a>
|
|
159
|
+
</PopoverContent>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { Spinner } from "./spinner"
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Spinner> = {
|
|
6
|
+
title: "Components/Spinner",
|
|
7
|
+
component: Spinner,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
args: { size: "md" },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof Spinner>
|
|
14
|
+
|
|
15
|
+
export const Default: Story = {}
|
|
16
|
+
|
|
17
|
+
export const Large: Story = {
|
|
18
|
+
args: { size: "lg" },
|
|
19
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
5
|
+
import { cn } from "../lib/cn"
|
|
6
|
+
|
|
7
|
+
export type SpinnerSize = "sm" | "md" | "lg" | "xl"
|
|
8
|
+
|
|
9
|
+
const spinnerVariants = cva(
|
|
10
|
+
cn(
|
|
11
|
+
"ax:animate-spin ax:text-primary",
|
|
12
|
+
// Slow the spin to a 3-second cycle when the OS requests reduced motion.
|
|
13
|
+
// We don't stop it: a stationary spinner is just a static circle and no
|
|
14
|
+
// longer reads as "loading". A slow rotation preserves the semantic.
|
|
15
|
+
"ax:motion-reduce:[animation-duration:3s]"
|
|
16
|
+
),
|
|
17
|
+
{
|
|
18
|
+
variants: {
|
|
19
|
+
size: {
|
|
20
|
+
sm: "ax:size-3.5",
|
|
21
|
+
md: "ax:size-4",
|
|
22
|
+
lg: "ax:size-5",
|
|
23
|
+
xl: "ax:size-6",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
defaultVariants: { size: "md" },
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
export interface SpinnerProps
|
|
31
|
+
extends React.SVGAttributes<SVGSVGElement>,
|
|
32
|
+
VariantProps<typeof spinnerVariants> {
|
|
33
|
+
/** Accessible label announced by screen readers via the live region.
|
|
34
|
+
* Defaults to `"Loading"`. Use this for i18n or to be more specific
|
|
35
|
+
* ("Saving", "Searching", "Fetching listings"). Ignored when
|
|
36
|
+
* `decorative` is true. */
|
|
37
|
+
label?: string
|
|
38
|
+
/** Hide from assistive tech (`aria-hidden`, drops `role` and `aria-label`).
|
|
39
|
+
* Use when a sibling element already announces the loading state - e.g.
|
|
40
|
+
* inside a button with visible "Saving…" text - to avoid duplicate
|
|
41
|
+
* screen-reader announcements. */
|
|
42
|
+
decorative?: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const Spinner = React.forwardRef<SVGSVGElement, SpinnerProps>(
|
|
46
|
+
({ className, size, label, decorative = false, ...props }, ref) => {
|
|
47
|
+
const ariaProps = decorative
|
|
48
|
+
? { "aria-hidden": true as const }
|
|
49
|
+
: { role: "status" as const, "aria-label": label ?? "Loading" }
|
|
50
|
+
return (
|
|
51
|
+
<svg
|
|
52
|
+
ref={ref}
|
|
53
|
+
viewBox="0 0 24 24"
|
|
54
|
+
fill="none"
|
|
55
|
+
{...ariaProps}
|
|
56
|
+
className={cn(spinnerVariants({ size }), className)}
|
|
57
|
+
{...props}
|
|
58
|
+
>
|
|
59
|
+
<circle
|
|
60
|
+
cx="12"
|
|
61
|
+
cy="12"
|
|
62
|
+
r="10"
|
|
63
|
+
strokeWidth="2.5"
|
|
64
|
+
className="ax:stroke-current ax:opacity-20"
|
|
65
|
+
/>
|
|
66
|
+
<path
|
|
67
|
+
d="M22 12a10 10 0 0 0-10-10"
|
|
68
|
+
strokeWidth="2.5"
|
|
69
|
+
strokeLinecap="round"
|
|
70
|
+
className="ax:stroke-current"
|
|
71
|
+
/>
|
|
72
|
+
</svg>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
Spinner.displayName = "Spinner"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { Handshake, ShoppingBag, TrendingUp, Users } from "lucide-react"
|
|
4
|
+
import { StatStrip } from "./stat-strip"
|
|
5
|
+
|
|
6
|
+
const ITEMS = [
|
|
7
|
+
{ label: "Sold", value: "1,086", icon: <ShoppingBag aria-hidden /> },
|
|
8
|
+
{ label: "On-Market", value: "1,854", icon: <TrendingUp aria-hidden /> },
|
|
9
|
+
{ label: "Deals", value: "2,451", icon: <Handshake aria-hidden /> },
|
|
10
|
+
{ label: "Active brokers", value: "275", icon: <Users aria-hidden /> },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const meta: Meta<typeof StatStrip> = {
|
|
14
|
+
title: "Components/StatStrip",
|
|
15
|
+
component: StatStrip,
|
|
16
|
+
tags: ["autodocs"],
|
|
17
|
+
argTypes: { tone: { control: "select", options: ["neutral", "primary", "secondary"] } },
|
|
18
|
+
args: { label: "Madhmoun MLS global stats", items: ITEMS, tone: "secondary" },
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default meta
|
|
22
|
+
type Story = StoryObj<typeof StatStrip>
|
|
23
|
+
|
|
24
|
+
export const Default: Story = {}
|
|
25
|
+
|
|
26
|
+
export const Tones: Story = {
|
|
27
|
+
render: (args) => (
|
|
28
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
29
|
+
<StatStrip {...args} tone="neutral" />
|
|
30
|
+
<StatStrip {...args} tone="primary" />
|
|
31
|
+
<StatStrip {...args} tone="secondary" />
|
|
32
|
+
</div>
|
|
33
|
+
),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const WithoutLabel: Story = { args: { label: undefined, tone: "neutral" } }
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
4
|
+
import { StatStrip } from "./stat-strip"
|
|
5
|
+
|
|
6
|
+
const ITEMS = [{ label: "Sold", value: "1,086" }, { label: "Deals", value: "2,451" }]
|
|
7
|
+
|
|
8
|
+
describe("StatStrip", () => {
|
|
9
|
+
it("pairs each label with its value in a definition list", () => {
|
|
10
|
+
const html = renderToStaticMarkup(<StatStrip items={ITEMS} />)
|
|
11
|
+
expect(html).toMatch(/<dl[^>]*>.*<dt[^>]*>Sold<\/dt><dd[^>]*>1,086<\/dd>.*<\/dl>/)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("names the list with its label when one is given", () => {
|
|
15
|
+
const html = renderToStaticMarkup(<StatStrip label="Global stats" items={ITEMS} />)
|
|
16
|
+
const id = html.match(/<p id="([^"]+)"/)?.[1]
|
|
17
|
+
expect(id).toBeTruthy()
|
|
18
|
+
expect(html).toContain(`aria-labelledby="${id}"`)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("applies the tone", () => {
|
|
22
|
+
expect(renderToStaticMarkup(<StatStrip tone="secondary" items={ITEMS} />)).toContain("ax:bg-secondary-weakest")
|
|
23
|
+
expect(renderToStaticMarkup(<StatStrip items={ITEMS} />)).toContain("ax:bg-muted")
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { tv, type VariantProps } from "tailwind-variants"
|
|
3
|
+
import { cn } from "../lib/cn"
|
|
4
|
+
|
|
5
|
+
export type StatStripTone = "neutral" | "primary" | "secondary"
|
|
6
|
+
|
|
7
|
+
export interface StatStripItem {
|
|
8
|
+
label: string
|
|
9
|
+
value: React.ReactNode
|
|
10
|
+
/** Small leading icon. Mark it decorative; the label names the figure. */
|
|
11
|
+
icon?: React.ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Tones use the `weakest` fills with `-foreground` text, which the emphasis
|
|
16
|
+
* aliases keep legible in light and dark without `dark:` overrides.
|
|
17
|
+
*/
|
|
18
|
+
export const statStripVariants = tv({
|
|
19
|
+
slots: {
|
|
20
|
+
root: "ax:flex ax:flex-col ax:gap-xs ax:rounded-md ax:border ax:px-md ax:py-xs ax:sm:flex-row ax:sm:items-center ax:sm:gap-md",
|
|
21
|
+
label: "ax:t-label-xs ax:shrink-0",
|
|
22
|
+
list: "ax:grid ax:grid-cols-2 ax:gap-x-md ax:gap-y-xs ax:sm:flex ax:sm:flex-wrap ax:sm:gap-x-md",
|
|
23
|
+
// Labels and values never break; in a narrow two-column cell the value wraps under its label instead.
|
|
24
|
+
item: "ax:flex ax:min-w-0 ax:flex-wrap ax:items-center ax:gap-x-xs ax:sm:border-s ax:sm:ps-md ax:[&_svg]:size-3.5 ax:[&_svg]:shrink-0",
|
|
25
|
+
},
|
|
26
|
+
variants: {
|
|
27
|
+
tone: {
|
|
28
|
+
neutral: {
|
|
29
|
+
root: "ax:border-border ax:bg-muted ax:text-foreground",
|
|
30
|
+
item: "ax:sm:border-border",
|
|
31
|
+
},
|
|
32
|
+
primary: {
|
|
33
|
+
root: "ax:border-primary-weaker ax:bg-primary-weakest ax:text-primary-foreground",
|
|
34
|
+
item: "ax:sm:border-primary-weaker",
|
|
35
|
+
},
|
|
36
|
+
secondary: {
|
|
37
|
+
root: "ax:border-secondary-weaker ax:bg-secondary-weakest ax:text-secondary-foreground",
|
|
38
|
+
item: "ax:sm:border-secondary-weaker",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
defaultVariants: { tone: "neutral" },
|
|
43
|
+
}, { twMerge: false })
|
|
44
|
+
|
|
45
|
+
export interface StatStripProps
|
|
46
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, "children">,
|
|
47
|
+
VariantProps<typeof statStripVariants> {
|
|
48
|
+
/** Leading caption naming the set, e.g. "Madhmoun MLS global stats". */
|
|
49
|
+
label?: React.ReactNode
|
|
50
|
+
items: StatStripItem[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* StatStrip - a compact, single-line bar of headline figures for the top of a
|
|
55
|
+
* dashboard. Renders a definition list so each label is tied to its value;
|
|
56
|
+
* collapses to two columns below `sm`.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* <StatStrip
|
|
60
|
+
* tone="secondary"
|
|
61
|
+
* label="Madhmoun MLS global stats"
|
|
62
|
+
* items={[{ label: "Sold", value: "1,086" }, { label: "Deals", value: "2,451" }]}
|
|
63
|
+
* />
|
|
64
|
+
*/
|
|
65
|
+
export const StatStrip = React.forwardRef<HTMLDivElement, StatStripProps>(
|
|
66
|
+
({ className, tone, label, items, ...props }, ref) => {
|
|
67
|
+
const styles = statStripVariants({ tone })
|
|
68
|
+
const labelId = React.useId()
|
|
69
|
+
return (
|
|
70
|
+
<div ref={ref} data-slot="stat-strip" className={cn(styles.root(), className)} {...props}>
|
|
71
|
+
{label && <p id={labelId} className={styles.label()}>{label}</p>}
|
|
72
|
+
<dl className={styles.list()} aria-labelledby={label ? labelId : undefined}>
|
|
73
|
+
{items.map((item) => (
|
|
74
|
+
<div key={item.label} className={styles.item()}>
|
|
75
|
+
{item.icon}
|
|
76
|
+
<dt className="ax:t-label-xs ax:font-normal ax:whitespace-nowrap">{item.label}</dt>
|
|
77
|
+
<dd className="ax:t-label-xs ax:font-bold ax:whitespace-nowrap ax:tabular-nums">{item.value}</dd>
|
|
78
|
+
</div>
|
|
79
|
+
))}
|
|
80
|
+
</dl>
|
|
81
|
+
</div>
|
|
82
|
+
)
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
StatStrip.displayName = "StatStrip"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { Stats } from "./stats"
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Stats> = {
|
|
6
|
+
title: "Components/Stats",
|
|
7
|
+
component: Stats,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
args: {
|
|
10
|
+
eyebrow: "By the numbers",
|
|
11
|
+
title: "Trusted worldwide",
|
|
12
|
+
stats: [
|
|
13
|
+
{ value: "10k+", label: "Teams" },
|
|
14
|
+
{ value: "99.9%", label: "Uptime" },
|
|
15
|
+
{ value: "50+", label: "Countries" },
|
|
16
|
+
{ value: "4.9", label: "Rating" },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default meta
|
|
22
|
+
type Story = StoryObj<typeof Stats>
|
|
23
|
+
|
|
24
|
+
export const Default: Story = {}
|
|
@@ -0,0 +1,83 @@
|
|
|
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 Stat {
|
|
10
|
+
value: React.ReactNode
|
|
11
|
+
label: string
|
|
12
|
+
description?: string
|
|
13
|
+
valueClassName?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StatsProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
|
|
17
|
+
eyebrow?: React.ReactNode
|
|
18
|
+
title?: React.ReactNode
|
|
19
|
+
description?: React.ReactNode
|
|
20
|
+
stats: Stat[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Stats({ className, eyebrow, title, description, stats, ...props }: StatsProps) {
|
|
24
|
+
const reduce = useReducedMotion()
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<section className={cn("ax:w-full ax:px-xl ax:md:px-3xl ax:py-5xl ax:md:py-6xl", className)} {...props}>
|
|
28
|
+
<div className="ax:mx-auto ax:max-w-420">
|
|
29
|
+
{(eyebrow || title || description) && (
|
|
30
|
+
<div className="ax:max-w-2xl ax:mb-4xl ax:space-y-sm">
|
|
31
|
+
{eyebrow && (
|
|
32
|
+
<span className="ax:inline-block ax:t-overline ax:text-primary">
|
|
33
|
+
{eyebrow}
|
|
34
|
+
</span>
|
|
35
|
+
)}
|
|
36
|
+
{title && (
|
|
37
|
+
<h2 className="ax:t-display-md ax:md:t-display-lg ax:text-foreground ax:[text-wrap:balance]">
|
|
38
|
+
{title}
|
|
39
|
+
</h2>
|
|
40
|
+
)}
|
|
41
|
+
{description && (
|
|
42
|
+
<p className="ax:t-body-md ax:text-muted-foreground ax:[text-wrap:pretty]">{description}</p>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
|
|
47
|
+
<motion.dl
|
|
48
|
+
className={cn(
|
|
49
|
+
"ax:grid ax:gap-px ax:bg-border ax:rounded-xl ax:overflow-hidden ax:border ax:border-border",
|
|
50
|
+
stats.length === 2 && "ax:grid-cols-1 ax:sm:grid-cols-2",
|
|
51
|
+
stats.length === 3 && "ax:grid-cols-1 ax:sm:grid-cols-3",
|
|
52
|
+
stats.length === 4 && "ax:grid-cols-2 ax:lg:grid-cols-4",
|
|
53
|
+
stats.length > 4 && "ax:grid-cols-2 ax:md:grid-cols-3 ax:lg:grid-cols-4",
|
|
54
|
+
)}
|
|
55
|
+
{...(reduce ? {} : {
|
|
56
|
+
variants: stagger(),
|
|
57
|
+
initial: "hidden",
|
|
58
|
+
whileInView: "visible",
|
|
59
|
+
viewport: defaultViewport,
|
|
60
|
+
})}
|
|
61
|
+
>
|
|
62
|
+
{stats.map((stat, i) => (
|
|
63
|
+
<motion.div
|
|
64
|
+
key={i}
|
|
65
|
+
{...(reduce ? {} : { variants: variants.fadeUp })}
|
|
66
|
+
className="ax:flex ax:flex-col ax:gap-2xs ax:bg-card ax:px-xl ax:py-2xl"
|
|
67
|
+
>
|
|
68
|
+
<dt className="ax:t-overline ax:text-muted-foreground">
|
|
69
|
+
{stat.label}
|
|
70
|
+
</dt>
|
|
71
|
+
<dd className={cn("ax:t-display-lg ax:text-foreground ax:tabular-nums", stat.valueClassName)}>
|
|
72
|
+
{stat.value}
|
|
73
|
+
</dd>
|
|
74
|
+
{stat.description && (
|
|
75
|
+
<p className="ax:mt-2xs ax:t-caption ax:text-muted-foreground">{stat.description}</p>
|
|
76
|
+
)}
|
|
77
|
+
</motion.div>
|
|
78
|
+
))}
|
|
79
|
+
</motion.dl>
|
|
80
|
+
</div>
|
|
81
|
+
</section>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { StepIndicator } from "./step-indicator"
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof StepIndicator> = {
|
|
6
|
+
title: "Components/StepIndicator",
|
|
7
|
+
component: StepIndicator,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
args: {
|
|
10
|
+
steps: [{ label: "Account" }, { label: "Profile" }, { label: "Review" }],
|
|
11
|
+
currentStep: 1,
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default meta
|
|
16
|
+
type Story = StoryObj<typeof StepIndicator>
|
|
17
|
+
|
|
18
|
+
export const Default: Story = {}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
4
|
+
import { StepIndicator } from "./step-indicator"
|
|
5
|
+
|
|
6
|
+
describe("StepIndicator horizontal labels", () => {
|
|
7
|
+
it("anchors the first, middle, and last labels to the circle", () => {
|
|
8
|
+
const html = renderToStaticMarkup(
|
|
9
|
+
<StepIndicator
|
|
10
|
+
currentStep={1}
|
|
11
|
+
steps={[{ label: "Account" }, { label: "Profile" }, { label: "Review" }]}
|
|
12
|
+
/>,
|
|
13
|
+
)
|
|
14
|
+
expect(html).toContain("ax:start-0")
|
|
15
|
+
expect(html).toContain("ax:start-1/2 ax:-translate-x-1/2 ax:rtl:translate-x-1/2")
|
|
16
|
+
expect(html).toContain("ax:end-0")
|
|
17
|
+
})
|
|
18
|
+
})
|