@adres/mgda-ui 0.0.0-stage → 0.0.1
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,96 @@
|
|
|
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
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export type SkeletonVariant = "pulse" | "shimmer" | "none"
|
|
10
|
+
|
|
11
|
+
// ─── Variants ─────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const skeletonVariants = cva(
|
|
14
|
+
"ax:rounded-md ax:bg-muted",
|
|
15
|
+
{
|
|
16
|
+
variants: {
|
|
17
|
+
variant: {
|
|
18
|
+
// Tailwind's built-in pulse: opacity oscillation. Quiet, ubiquitous.
|
|
19
|
+
pulse: "ax:animate-pulse ax:motion-reduce:animate-none",
|
|
20
|
+
// Sliding gradient overlay. Feels smoother on larger placeholder blocks
|
|
21
|
+
// (hero images, card previews). Uses an `::after` pseudo-element so the
|
|
22
|
+
// base bg-muted stays visible underneath.
|
|
23
|
+
shimmer: cn(
|
|
24
|
+
"ax:relative ax:overflow-hidden",
|
|
25
|
+
"ax:after:absolute ax:after:inset-0",
|
|
26
|
+
"ax:after:bg-gradient-to-r ax:after:from-transparent ax:after:via-foreground/[0.08] ax:after:to-transparent",
|
|
27
|
+
"ax:after:animate-skeleton-shimmer",
|
|
28
|
+
"ax:motion-reduce:after:animate-none",
|
|
29
|
+
),
|
|
30
|
+
// Static placeholder with no animation. Use when motion is distracting
|
|
31
|
+
// (e.g. inside an already-animated parent) or for visual snapshots.
|
|
32
|
+
none: "",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
defaultVariants: { variant: "pulse" },
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
// ─── Skeleton ─────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
export interface SkeletonProps
|
|
42
|
+
extends React.HTMLAttributes<HTMLDivElement>,
|
|
43
|
+
VariantProps<typeof skeletonVariants> {}
|
|
44
|
+
|
|
45
|
+
export const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
|
|
46
|
+
({ className, variant, ...props }, ref) => (
|
|
47
|
+
<div
|
|
48
|
+
ref={ref}
|
|
49
|
+
aria-hidden="true"
|
|
50
|
+
className={cn(skeletonVariants({ variant }), className)}
|
|
51
|
+
{...props}
|
|
52
|
+
/>
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
Skeleton.displayName = "Skeleton"
|
|
56
|
+
|
|
57
|
+
// ─── SkeletonText ─────────────────────────────────────────────────────────────
|
|
58
|
+
//
|
|
59
|
+
// Stacked text-line placeholders with naturally varied widths: the recurring
|
|
60
|
+
// "3 paragraph lines" pattern in one component. The last line is always
|
|
61
|
+
// noticeably shorter to mimic a paragraph ending mid-line.
|
|
62
|
+
|
|
63
|
+
const TEXT_WIDTHS = ["ax:w-full", "ax:w-11/12", "ax:w-10/12", "ax:w-9/12"]
|
|
64
|
+
|
|
65
|
+
export interface SkeletonTextProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
66
|
+
/** Number of text lines to render. Default 3. */
|
|
67
|
+
lines?: number
|
|
68
|
+
/** Animation variant - applied to every line. */
|
|
69
|
+
variant?: SkeletonVariant
|
|
70
|
+
/** Line height - applied to every line. Default `h-3`. */
|
|
71
|
+
lineClassName?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const SkeletonText = React.forwardRef<HTMLDivElement, SkeletonTextProps>(
|
|
75
|
+
({ className, lines = 3, variant, lineClassName, ...props }, ref) => (
|
|
76
|
+
<div
|
|
77
|
+
ref={ref}
|
|
78
|
+
aria-hidden="true"
|
|
79
|
+
className={cn("ax:space-y-2", className)}
|
|
80
|
+
{...props}
|
|
81
|
+
>
|
|
82
|
+
{Array.from({ length: lines }).map((_, i) => {
|
|
83
|
+
const isLast = i === lines - 1
|
|
84
|
+
const width = isLast ? "ax:w-2/3" : TEXT_WIDTHS[i % TEXT_WIDTHS.length]
|
|
85
|
+
return (
|
|
86
|
+
<Skeleton
|
|
87
|
+
key={i}
|
|
88
|
+
variant={variant}
|
|
89
|
+
className={cn("ax:h-3", width, lineClassName)}
|
|
90
|
+
/>
|
|
91
|
+
)
|
|
92
|
+
})}
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
SkeletonText.displayName = "SkeletonText"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { Slider } from "./slider"
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Slider> = {
|
|
6
|
+
title: "Components/Slider",
|
|
7
|
+
component: Slider,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
args: {
|
|
10
|
+
defaultValue: [50],
|
|
11
|
+
max: 100,
|
|
12
|
+
step: 1,
|
|
13
|
+
className: "ax:w-64",
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default meta
|
|
18
|
+
type Story = StoryObj<typeof Slider>
|
|
19
|
+
|
|
20
|
+
export const Default: Story = {}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import * as SliderPrimitive from "@radix-ui/react-slider"
|
|
5
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
6
|
+
import { cn } from "../lib/cn"
|
|
7
|
+
|
|
8
|
+
export type SliderSize = "sm" | "md" | "lg"
|
|
9
|
+
export type SliderValueLabel = "always" | "active" | false
|
|
10
|
+
|
|
11
|
+
const trackVariants = cva(
|
|
12
|
+
"ax:relative ax:flex ax:w-full ax:touch-none ax:select-none ax:items-center",
|
|
13
|
+
{
|
|
14
|
+
variants: {
|
|
15
|
+
size: {
|
|
16
|
+
sm: "ax:h-4",
|
|
17
|
+
md: "ax:h-5",
|
|
18
|
+
lg: "ax:h-6",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
defaultVariants: { size: "md" },
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const TRACK_HEIGHT: Record<SliderSize, string> = {
|
|
26
|
+
sm: "ax:h-1",
|
|
27
|
+
md: "ax:h-1.5",
|
|
28
|
+
lg: "ax:h-2",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const THUMB_SIZE: Record<SliderSize, string> = {
|
|
32
|
+
sm: "ax:size-3",
|
|
33
|
+
md: "ax:size-4",
|
|
34
|
+
lg: "ax:size-5",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// How far above the thumb the value-label pill sits. Increases with thumb size
|
|
38
|
+
// so the gap between thumb and label stays visually consistent.
|
|
39
|
+
const LABEL_OFFSET: Record<SliderSize, string> = {
|
|
40
|
+
sm: "ax:-top-6",
|
|
41
|
+
md: "ax:-top-7",
|
|
42
|
+
lg: "ax:-top-8",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SliderProps
|
|
46
|
+
extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>,
|
|
47
|
+
VariantProps<typeof trackVariants> {
|
|
48
|
+
/** Show the current value in a small pill above each thumb.
|
|
49
|
+
* - `"always"` - pill is always visible
|
|
50
|
+
* - `"active"` - pill appears on hover / focus / drag, hidden otherwise
|
|
51
|
+
* - `false` (default) - no pill */
|
|
52
|
+
valueLabel?: SliderValueLabel
|
|
53
|
+
/** Format the value rendered in the label (e.g. add a "%" suffix, currency).
|
|
54
|
+
* Default: the raw number. */
|
|
55
|
+
formatValue?: (value: number) => React.ReactNode
|
|
56
|
+
/** Render tick marks along the track.
|
|
57
|
+
* - `true` - auto-generate at every `step` between `min` and `max`
|
|
58
|
+
* (auto-generation is skipped when it would produce more than 21 marks
|
|
59
|
+
* - for fine sliders, provide an explicit array instead)
|
|
60
|
+
* - `number[]` - render marks at exactly the provided positions */
|
|
61
|
+
marks?: boolean | number[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const Slider = React.forwardRef<
|
|
65
|
+
React.ElementRef<typeof SliderPrimitive.Root>,
|
|
66
|
+
SliderProps
|
|
67
|
+
>(
|
|
68
|
+
(
|
|
69
|
+
{
|
|
70
|
+
className,
|
|
71
|
+
size,
|
|
72
|
+
valueLabel = false,
|
|
73
|
+
formatValue,
|
|
74
|
+
marks = false,
|
|
75
|
+
value,
|
|
76
|
+
defaultValue,
|
|
77
|
+
onValueChange,
|
|
78
|
+
min = 0,
|
|
79
|
+
max = 100,
|
|
80
|
+
step = 1,
|
|
81
|
+
...props
|
|
82
|
+
},
|
|
83
|
+
ref
|
|
84
|
+
) => {
|
|
85
|
+
const resolvedSize = size ?? "md"
|
|
86
|
+
|
|
87
|
+
// Track the live value so the per-thumb label has something to render in
|
|
88
|
+
// uncontrolled mode. In controlled mode the parent's `value` wins; in
|
|
89
|
+
// uncontrolled mode the internal mirror is updated via onValueChange.
|
|
90
|
+
const [internalValue, setInternalValue] = React.useState<number[]>(
|
|
91
|
+
() => value ?? defaultValue ?? [min]
|
|
92
|
+
)
|
|
93
|
+
const currentValue = value ?? internalValue
|
|
94
|
+
const handleValueChange = (v: number[]) => {
|
|
95
|
+
setInternalValue(v)
|
|
96
|
+
onValueChange?.(v)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const thumbCount = currentValue.length
|
|
100
|
+
|
|
101
|
+
// Compute mark positions. When `marks === true`, generate one per step;
|
|
102
|
+
// bail out when the resulting count would be unreasonable (fine sliders).
|
|
103
|
+
const markPositions: number[] = React.useMemo(() => {
|
|
104
|
+
if (!marks) return []
|
|
105
|
+
if (Array.isArray(marks)) return marks
|
|
106
|
+
const out: number[] = []
|
|
107
|
+
for (let v = min; v <= max; v += step) out.push(v)
|
|
108
|
+
return out.length <= 21 ? out : []
|
|
109
|
+
}, [marks, min, max, step])
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<SliderPrimitive.Root
|
|
113
|
+
ref={ref}
|
|
114
|
+
value={value}
|
|
115
|
+
defaultValue={defaultValue}
|
|
116
|
+
onValueChange={handleValueChange}
|
|
117
|
+
min={min}
|
|
118
|
+
max={max}
|
|
119
|
+
step={step}
|
|
120
|
+
className={cn(trackVariants({ size }), className)}
|
|
121
|
+
{...props}
|
|
122
|
+
>
|
|
123
|
+
<SliderPrimitive.Track
|
|
124
|
+
className={cn(
|
|
125
|
+
"ax:relative ax:w-full ax:grow ax:overflow-hidden ax:rounded-full ax:bg-muted",
|
|
126
|
+
TRACK_HEIGHT[resolvedSize]
|
|
127
|
+
)}
|
|
128
|
+
>
|
|
129
|
+
<SliderPrimitive.Range className="ax:absolute ax:h-full ax:bg-primary" />
|
|
130
|
+
{markPositions.map((m) => (
|
|
131
|
+
<span
|
|
132
|
+
key={m}
|
|
133
|
+
aria-hidden="true"
|
|
134
|
+
style={{ left: `${((m - min) / (max - min)) * 100}%` }}
|
|
135
|
+
className="ax:absolute ax:top-1/2 ax:-translate-x-1/2 ax:-translate-y-1/2 ax:size-1 ax:rounded-full ax:bg-foreground/40 ax:pointer-events-none"
|
|
136
|
+
/>
|
|
137
|
+
))}
|
|
138
|
+
</SliderPrimitive.Track>
|
|
139
|
+
|
|
140
|
+
{Array.from({ length: thumbCount }).map((_, i) => {
|
|
141
|
+
const v = currentValue[i] ?? min
|
|
142
|
+
return (
|
|
143
|
+
<SliderPrimitive.Thumb
|
|
144
|
+
key={i}
|
|
145
|
+
className={cn(
|
|
146
|
+
"ax:group ax:relative ax:block ax:rounded-full ax:border-2 ax:border-primary ax:bg-background ax:cursor-grab ax:active:cursor-grabbing",
|
|
147
|
+
"ax:transition-colors ax:transition-snappy",
|
|
148
|
+
"ax:focus-visible:outline-none ax:focus-visible:ring-2 ax:focus-visible:ring-ring ax:focus-visible:ring-offset-2 ax:focus-visible:ring-offset-background",
|
|
149
|
+
"ax:disabled:pointer-events-none ax:disabled:opacity-50",
|
|
150
|
+
THUMB_SIZE[resolvedSize]
|
|
151
|
+
)}
|
|
152
|
+
>
|
|
153
|
+
{valueLabel && (
|
|
154
|
+
<span
|
|
155
|
+
aria-hidden="true"
|
|
156
|
+
className={cn(
|
|
157
|
+
"ax:absolute ax:left-1/2 ax:-translate-x-1/2 ax:px-1.5 ax:py-0.5 ax:rounded ax:text-xs ax:font-medium ax:tabular-nums ax:whitespace-nowrap ax:pointer-events-none",
|
|
158
|
+
"ax:bg-foreground ax:text-background",
|
|
159
|
+
LABEL_OFFSET[resolvedSize],
|
|
160
|
+
valueLabel === "active" && [
|
|
161
|
+
"ax:opacity-0 ax:transition-opacity",
|
|
162
|
+
"ax:group-hover:opacity-100 ax:group-focus-visible:opacity-100 ax:group-active:opacity-100",
|
|
163
|
+
]
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
{formatValue ? formatValue(v) : v}
|
|
167
|
+
</span>
|
|
168
|
+
)}
|
|
169
|
+
</SliderPrimitive.Thumb>
|
|
170
|
+
)
|
|
171
|
+
})}
|
|
172
|
+
</SliderPrimitive.Root>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
Slider.displayName = SliderPrimitive.Root.displayName
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { Slideshow, SlideshowSlide } from "./slideshow"
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Slideshow> = {
|
|
6
|
+
title: "Components/Slideshow",
|
|
7
|
+
component: Slideshow,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof Slideshow>
|
|
13
|
+
|
|
14
|
+
const slides = [
|
|
15
|
+
{ src: "https://picsum.photos/800/400?random=10", alt: "Slide 1" },
|
|
16
|
+
{ src: "https://picsum.photos/800/400?random=11", alt: "Slide 2" },
|
|
17
|
+
{ src: "https://picsum.photos/800/400?random=12", alt: "Slide 3" },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
export const Default: Story = {
|
|
21
|
+
render: () => (
|
|
22
|
+
<Slideshow className="ax:max-w-xl">
|
|
23
|
+
{slides.map((slide) => (
|
|
24
|
+
<SlideshowSlide key={slide.src}>
|
|
25
|
+
<img src={slide.src} alt={slide.alt} className="ax:w-full ax:rounded-md ax:object-cover ax:aspect-[2/1]" />
|
|
26
|
+
</SlideshowSlide>
|
|
27
|
+
))}
|
|
28
|
+
</Slideshow>
|
|
29
|
+
),
|
|
30
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { motion, AnimatePresence } from "motion/react"
|
|
5
|
+
import { ChevronLeft, ChevronRight, Pause, Play } from "lucide-react"
|
|
6
|
+
import { cn } from "../lib/cn"
|
|
7
|
+
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
|
8
|
+
import { transitions } from "../lib/motion"
|
|
9
|
+
|
|
10
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export type SlideshowVariant = "slide" | "fade"
|
|
13
|
+
|
|
14
|
+
// ─── Slide ────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface SlideshowSlideProps extends React.HTMLAttributes<HTMLDivElement> {}
|
|
17
|
+
|
|
18
|
+
export const SlideshowSlide = React.forwardRef<HTMLDivElement, SlideshowSlideProps>(
|
|
19
|
+
({ className, children, ...props }, ref) => (
|
|
20
|
+
<div ref={ref} className={cn("ax:w-full ax:select-none", className)} {...props}>
|
|
21
|
+
{children}
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
SlideshowSlide.displayName = "SlideshowSlide"
|
|
26
|
+
|
|
27
|
+
// ─── Root ─────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export interface SlideshowProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
30
|
+
/** Transition style - "slide" (default) or "fade". */
|
|
31
|
+
variant?: SlideshowVariant
|
|
32
|
+
/** Auto-advance interval in ms. Pauses on hover / focus / explicit toggle.
|
|
33
|
+
* Pass `false` to disable (default). When enabled, a visible pause button
|
|
34
|
+
* appears in the top-right corner. */
|
|
35
|
+
autoplay?: number | false
|
|
36
|
+
/** Wrap around at the edges (default true). */
|
|
37
|
+
loop?: boolean
|
|
38
|
+
/** Show prev/next arrow buttons (default true). */
|
|
39
|
+
showArrows?: boolean
|
|
40
|
+
/** Show dot indicators (default true). */
|
|
41
|
+
showDots?: boolean
|
|
42
|
+
/** Controlled active index. */
|
|
43
|
+
index?: number
|
|
44
|
+
/** Callback fired when the active slide changes. */
|
|
45
|
+
onIndexChange?: (index: number) => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const Slideshow = React.forwardRef<HTMLDivElement, SlideshowProps>(
|
|
49
|
+
(
|
|
50
|
+
{
|
|
51
|
+
className,
|
|
52
|
+
children,
|
|
53
|
+
variant = "slide",
|
|
54
|
+
autoplay = false,
|
|
55
|
+
loop = true,
|
|
56
|
+
showArrows = true,
|
|
57
|
+
showDots = true,
|
|
58
|
+
index: controlledIndex,
|
|
59
|
+
onIndexChange,
|
|
60
|
+
...props
|
|
61
|
+
},
|
|
62
|
+
ref
|
|
63
|
+
) => {
|
|
64
|
+
const reduce = useReducedMotion()
|
|
65
|
+
const slides = React.Children.toArray(children)
|
|
66
|
+
const count = slides.length
|
|
67
|
+
|
|
68
|
+
// [activeIndex, direction] - direction: 1 = forward, -1 = backward.
|
|
69
|
+
const [[activeIndex, direction], setSlide] = React.useState<[number, number]>([
|
|
70
|
+
controlledIndex ?? 0,
|
|
71
|
+
1,
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
// Pause state. Two sources combine:
|
|
75
|
+
// - `transientPause` - set while hover/focus is inside the carousel
|
|
76
|
+
// (per WAI-ARIA carousel guidance: must pause on pointer + keyboard)
|
|
77
|
+
// - `userPaused` - toggled by the explicit pause/play button
|
|
78
|
+
// (per WCAG 2.2.2: pause control for auto-moving content)
|
|
79
|
+
// Effective pause = either source set.
|
|
80
|
+
const [transientPause, setTransientPause] = React.useState(false)
|
|
81
|
+
const [userPaused, setUserPaused] = React.useState(false)
|
|
82
|
+
const isPaused = transientPause || userPaused
|
|
83
|
+
|
|
84
|
+
// Sync with controlled index when it changes externally.
|
|
85
|
+
React.useEffect(() => {
|
|
86
|
+
if (controlledIndex === undefined) return
|
|
87
|
+
setSlide(([prev]) => [controlledIndex, controlledIndex >= prev ? 1 : -1])
|
|
88
|
+
}, [controlledIndex])
|
|
89
|
+
|
|
90
|
+
const go = React.useCallback(
|
|
91
|
+
(next: number) => {
|
|
92
|
+
setSlide(([prev]) => {
|
|
93
|
+
const clamped = loop
|
|
94
|
+
? ((next % count) + count) % count
|
|
95
|
+
: Math.min(Math.max(next, 0), count - 1)
|
|
96
|
+
onIndexChange?.(clamped)
|
|
97
|
+
return [clamped, next >= prev ? 1 : -1]
|
|
98
|
+
})
|
|
99
|
+
},
|
|
100
|
+
[count, loop, onIndexChange]
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
// Autoplay - interval cleared/restarted whenever pause state changes.
|
|
104
|
+
React.useEffect(() => {
|
|
105
|
+
if (!autoplay || count < 2 || isPaused) return
|
|
106
|
+
const id = setInterval(() => go(activeIndex + 1), autoplay as number)
|
|
107
|
+
return () => clearInterval(id)
|
|
108
|
+
}, [autoplay, activeIndex, count, go, isPaused])
|
|
109
|
+
|
|
110
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
111
|
+
if (e.key === "ArrowLeft") go(activeIndex - 1)
|
|
112
|
+
if (e.key === "ArrowRight") go(activeIndex + 1)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Slide variants - direction-aware horizontal translate.
|
|
116
|
+
const slideVariants = {
|
|
117
|
+
enter: (dir: number) => ({ x: reduce ? 0 : `${dir * 100}%`, opacity: reduce ? 0 : 1 }),
|
|
118
|
+
center: { x: "0%", opacity: 1 },
|
|
119
|
+
exit: (dir: number) => ({ x: reduce ? 0 : `${dir * -100}%`, opacity: reduce ? 0 : 1 }),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const fadeVariants = {
|
|
123
|
+
enter: { opacity: 0 },
|
|
124
|
+
center: { opacity: 1 },
|
|
125
|
+
exit: { opacity: 0 },
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const canGoPrev = loop || activeIndex > 0
|
|
129
|
+
const canGoNext = loop || activeIndex < count - 1
|
|
130
|
+
|
|
131
|
+
// Autoplay progress duration in seconds, used by the active-dot fill.
|
|
132
|
+
const autoplaySec = autoplay ? (autoplay as number) / 1000 : 0
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div
|
|
136
|
+
ref={ref}
|
|
137
|
+
role="region"
|
|
138
|
+
aria-roledescription="carousel"
|
|
139
|
+
tabIndex={0}
|
|
140
|
+
onKeyDown={handleKeyDown}
|
|
141
|
+
onMouseEnter={() => setTransientPause(true)}
|
|
142
|
+
onMouseLeave={() => setTransientPause(false)}
|
|
143
|
+
onFocus={() => setTransientPause(true)}
|
|
144
|
+
onBlur={() => setTransientPause(false)}
|
|
145
|
+
className={cn(
|
|
146
|
+
"ax:relative ax:overflow-hidden ax:rounded-md",
|
|
147
|
+
"ax:focus-visible:outline-none ax:focus-visible:ring-2 ax:focus-visible:ring-ring ax:focus-visible:ring-offset-2 ax:focus-visible:ring-offset-background",
|
|
148
|
+
className
|
|
149
|
+
)}
|
|
150
|
+
{...props}
|
|
151
|
+
>
|
|
152
|
+
{/* Viewport - only the active slide is rendered */}
|
|
153
|
+
<div aria-live="polite" aria-atomic="true">
|
|
154
|
+
<AnimatePresence initial={false} custom={direction} mode="popLayout">
|
|
155
|
+
<motion.div
|
|
156
|
+
key={activeIndex}
|
|
157
|
+
custom={direction}
|
|
158
|
+
role="group"
|
|
159
|
+
aria-roledescription="slide"
|
|
160
|
+
aria-label={`${activeIndex + 1} of ${count}`}
|
|
161
|
+
variants={variant === "fade" ? fadeVariants : slideVariants}
|
|
162
|
+
initial="enter"
|
|
163
|
+
animate="center"
|
|
164
|
+
exit="exit"
|
|
165
|
+
transition={
|
|
166
|
+
variant === "fade"
|
|
167
|
+
? (reduce ? { duration: 0 } : transitions.smooth)
|
|
168
|
+
: transitions.spring
|
|
169
|
+
}
|
|
170
|
+
drag={count > 1 ? "x" : false}
|
|
171
|
+
dragConstraints={{ left: 0, right: 0 }}
|
|
172
|
+
dragElastic={0.12}
|
|
173
|
+
onDragEnd={(_, { offset }) => {
|
|
174
|
+
if (offset.x < -50) go(activeIndex + 1)
|
|
175
|
+
if (offset.x > 50) go(activeIndex - 1)
|
|
176
|
+
}}
|
|
177
|
+
className="ax:w-full"
|
|
178
|
+
>
|
|
179
|
+
{slides[activeIndex]}
|
|
180
|
+
</motion.div>
|
|
181
|
+
</AnimatePresence>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{/* Arrow controls */}
|
|
185
|
+
{showArrows && count > 1 && (
|
|
186
|
+
<>
|
|
187
|
+
<button
|
|
188
|
+
onClick={() => go(activeIndex - 1)}
|
|
189
|
+
disabled={!canGoPrev}
|
|
190
|
+
aria-label="Previous slide"
|
|
191
|
+
className={cn(
|
|
192
|
+
"ax:absolute ax:left-3 ax:top-1/2 ax:-translate-y-1/2 ax:z-10",
|
|
193
|
+
"ax:size-8 ax:flex ax:items-center ax:justify-center",
|
|
194
|
+
"ax:rounded-full ax:bg-background/80 ax:backdrop-blur-sm ax:border ax:border-border ax:shadow-sm",
|
|
195
|
+
"ax:text-foreground ax:hover:bg-background ax:transition-colors ax:cursor-pointer",
|
|
196
|
+
"ax:disabled:opacity-40 ax:disabled:cursor-not-allowed"
|
|
197
|
+
)}
|
|
198
|
+
>
|
|
199
|
+
<ChevronLeft className="ax:size-4" />
|
|
200
|
+
</button>
|
|
201
|
+
|
|
202
|
+
<button
|
|
203
|
+
onClick={() => go(activeIndex + 1)}
|
|
204
|
+
disabled={!canGoNext}
|
|
205
|
+
aria-label="Next slide"
|
|
206
|
+
className={cn(
|
|
207
|
+
"ax:absolute ax:right-3 ax:top-1/2 ax:-translate-y-1/2 ax:z-10",
|
|
208
|
+
"ax:size-8 ax:flex ax:items-center ax:justify-center",
|
|
209
|
+
"ax:rounded-full ax:bg-background/80 ax:backdrop-blur-sm ax:border ax:border-border ax:shadow-sm",
|
|
210
|
+
"ax:text-foreground ax:hover:bg-background ax:transition-colors ax:cursor-pointer",
|
|
211
|
+
"ax:disabled:opacity-40 ax:disabled:cursor-not-allowed"
|
|
212
|
+
)}
|
|
213
|
+
>
|
|
214
|
+
<ChevronRight className="ax:size-4" />
|
|
215
|
+
</button>
|
|
216
|
+
</>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
{/* Pause/play button - only when autoplay is on. WCAG 2.2.2 requires
|
|
220
|
+
* a control for auto-moving content longer than 5s; we offer one
|
|
221
|
+
* for any autoplay interval. */}
|
|
222
|
+
{autoplay && count > 1 && (
|
|
223
|
+
<button
|
|
224
|
+
onClick={() => setUserPaused((p) => !p)}
|
|
225
|
+
aria-label={userPaused ? "Play slideshow" : "Pause slideshow"}
|
|
226
|
+
aria-pressed={userPaused}
|
|
227
|
+
className={cn(
|
|
228
|
+
"ax:absolute ax:top-3 ax:right-3 ax:z-10",
|
|
229
|
+
"ax:size-8 ax:flex ax:items-center ax:justify-center",
|
|
230
|
+
"ax:rounded-full ax:bg-background/80 ax:backdrop-blur-sm ax:border ax:border-border ax:shadow-sm",
|
|
231
|
+
"ax:text-foreground ax:hover:bg-background ax:transition-colors ax:cursor-pointer"
|
|
232
|
+
)}
|
|
233
|
+
>
|
|
234
|
+
{userPaused ? <Play className="ax:size-3.5" /> : <Pause className="ax:size-3.5" />}
|
|
235
|
+
</button>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{/* Dot indicators */}
|
|
239
|
+
{showDots && count > 1 && (
|
|
240
|
+
<div
|
|
241
|
+
role="tablist"
|
|
242
|
+
aria-label="Slides"
|
|
243
|
+
className="ax:absolute ax:bottom-3 ax:left-0 ax:right-0 ax:flex ax:justify-center ax:items-center ax:gap-1.5 ax:z-10"
|
|
244
|
+
>
|
|
245
|
+
{Array.from({ length: count }).map((_, i) => {
|
|
246
|
+
const isActive = i === activeIndex
|
|
247
|
+
return (
|
|
248
|
+
<button
|
|
249
|
+
key={i}
|
|
250
|
+
role="tab"
|
|
251
|
+
aria-selected={isActive}
|
|
252
|
+
aria-label={`Slide ${i + 1}`}
|
|
253
|
+
onClick={() => go(i)}
|
|
254
|
+
className={cn(
|
|
255
|
+
"ax:relative ax:overflow-hidden ax:rounded-full ax:transition-all ax:transition-snappy ax:cursor-pointer",
|
|
256
|
+
isActive
|
|
257
|
+
? "ax:w-4 ax:h-1.5 ax:bg-foreground/20"
|
|
258
|
+
: "ax:size-1.5 ax:bg-foreground/30 ax:hover:bg-foreground/50"
|
|
259
|
+
)}
|
|
260
|
+
>
|
|
261
|
+
{isActive && (
|
|
262
|
+
autoplay && !isPaused ? (
|
|
263
|
+
// Progress fill - animates 0→100% width over the autoplay
|
|
264
|
+
// interval, re-keys (= restarts) on every slide change.
|
|
265
|
+
<motion.span
|
|
266
|
+
key={`progress-${activeIndex}`}
|
|
267
|
+
aria-hidden="true"
|
|
268
|
+
className="ax:absolute ax:inset-y-0 ax:left-0 ax:bg-primary ax:rounded-full ax:origin-left"
|
|
269
|
+
initial={{ scaleX: 0 }}
|
|
270
|
+
animate={{ scaleX: 1 }}
|
|
271
|
+
transition={{ duration: autoplaySec, ease: "linear" }}
|
|
272
|
+
style={{ width: "100%" }}
|
|
273
|
+
/>
|
|
274
|
+
) : (
|
|
275
|
+
// Static full fill when autoplay is off or paused.
|
|
276
|
+
<span
|
|
277
|
+
aria-hidden="true"
|
|
278
|
+
className="ax:absolute ax:inset-0 ax:bg-primary ax:rounded-full"
|
|
279
|
+
/>
|
|
280
|
+
)
|
|
281
|
+
)}
|
|
282
|
+
</button>
|
|
283
|
+
)
|
|
284
|
+
})}
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
Slideshow.displayName = "Slideshow"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { Source, SourceContent, SourceTrigger } from "./source"
|
|
4
|
+
import { Text } from "./text"
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof Source> = {
|
|
7
|
+
title: "Components/Source",
|
|
8
|
+
component: Source,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
decorators: [(Story) => <div style={{ maxWidth: 560, paddingBottom: 160 }}><Story /></div>],
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default meta
|
|
14
|
+
type Story = StoryObj<typeof Source>
|
|
15
|
+
|
|
16
|
+
export const InText: Story = {
|
|
17
|
+
render: () => (
|
|
18
|
+
<Text as="p" size="2">
|
|
19
|
+
Yas Acres townhouses with a private garden start at AED 4.2M
|
|
20
|
+
<Source href="https://www.propertyfinder.ae/en/search?c=1&l=yas-island">
|
|
21
|
+
<SourceTrigger favicon />
|
|
22
|
+
<SourceContent title="Townhouses for sale in Yas Island" description="42 townhouses under AED 5M, 11 with a private garden." />
|
|
23
|
+
</Source>
|
|
24
|
+
, below the AED 4.6M median sale
|
|
25
|
+
<Source href="https://www.dari.ae/en/market-insights">
|
|
26
|
+
<SourceTrigger label={2} />
|
|
27
|
+
<SourceContent title="Abu Dhabi market insights" description="Registered sales by community over the last 12 months." />
|
|
28
|
+
</Source>
|
|
29
|
+
.
|
|
30
|
+
</Text>
|
|
31
|
+
),
|
|
32
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
4
|
+
import { Source, SourceTrigger, sourceDomain } from "./source"
|
|
5
|
+
|
|
6
|
+
const HREF = "https://www.propertyfinder.ae/en/yas-island"
|
|
7
|
+
|
|
8
|
+
describe("sourceDomain", () => {
|
|
9
|
+
it("strips the protocol, path and www", () => {
|
|
10
|
+
expect(sourceDomain(HREF)).toBe("propertyfinder.ae")
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("falls back to the input when it is not a URL", () => {
|
|
14
|
+
expect(sourceDomain("internal report")).toBe("internal report")
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe("SourceTrigger", () => {
|
|
19
|
+
it("is a link to the source, labelled with its domain", () => {
|
|
20
|
+
const html = renderToStaticMarkup(<Source href={HREF}><SourceTrigger /></Source>)
|
|
21
|
+
expect(html).toContain(`href="${HREF}"`)
|
|
22
|
+
expect(html).toContain('rel="noopener noreferrer"')
|
|
23
|
+
expect(html).toContain(">propertyfinder.ae<")
|
|
24
|
+
expect(html).not.toContain("aria-label")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("names a custom label with the domain so a bare number still makes sense", () => {
|
|
28
|
+
const html = renderToStaticMarkup(<Source href={HREF}><SourceTrigger label={2} /></Source>)
|
|
29
|
+
expect(html).toContain('aria-label="Source 2, propertyfinder.ae"')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("uses a given favicon and falls back to a link icon", () => {
|
|
33
|
+
expect(renderToStaticMarkup(<Source href={HREF}><SourceTrigger favicon="/pf.png" /></Source>)).toContain('src="/pf.png"')
|
|
34
|
+
expect(renderToStaticMarkup(<Source href={HREF}><SourceTrigger favicon /></Source>)).toContain("lucide-link2")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("throws outside Source", () => {
|
|
38
|
+
expect(() => renderToStaticMarkup(<SourceTrigger />)).toThrow(/Source/)
|
|
39
|
+
})
|
|
40
|
+
})
|