@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,462 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { ChevronDown, ChevronLeft } from "lucide-react"
|
|
5
|
+
import { Sidebar, SidebarSection } from "./sidebar"
|
|
6
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
|
|
7
|
+
import { cn } from "../lib/cn"
|
|
8
|
+
|
|
9
|
+
// ─── Nav config types ────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export interface NavSubItem {
|
|
12
|
+
href: string
|
|
13
|
+
label: string
|
|
14
|
+
/** Optional trailing count rendered on the inline-end of the row, e.g. a
|
|
15
|
+
* per-category item tally. Muted + `tabular-nums` so columns of numbers
|
|
16
|
+
* line up. Omit (or pass `undefined`) to render no trailing element. */
|
|
17
|
+
count?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface NavItem {
|
|
21
|
+
href: string
|
|
22
|
+
label: string
|
|
23
|
+
icon: React.ReactNode
|
|
24
|
+
/** When set, the item is active only on an exact pathname match. */
|
|
25
|
+
exact?: boolean
|
|
26
|
+
/** Show a small accent dot at the top-end corner of the icon. Accepts
|
|
27
|
+
* `true` for "just-on" or a number that is treated as truthy (>0 → on).
|
|
28
|
+
* Items with children never render a dot - the chevron toggle owns the
|
|
29
|
+
* trailing space and the dot would compete with it. */
|
|
30
|
+
dot?: boolean | number
|
|
31
|
+
/** Optional trailing count, e.g. saved items or drafts. Rendered like
|
|
32
|
+
* `NavSubItem.count` (muted, `tabular-nums`, hidden at 0). Collapsed rows
|
|
33
|
+
* show it in the tooltip instead. Ignored on items with children, where the
|
|
34
|
+
* chevron owns the trailing space. */
|
|
35
|
+
count?: number
|
|
36
|
+
/** Nested children expand under this item with a tree-indent guide and a
|
|
37
|
+
* chevron toggle. */
|
|
38
|
+
children?: NavSubItem[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface NavSection {
|
|
42
|
+
/** Overline section label (omitted when undefined). */
|
|
43
|
+
label?: string
|
|
44
|
+
items: NavItem[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Component props ─────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export interface NavSidebarProps {
|
|
50
|
+
sections: NavSection[]
|
|
51
|
+
/** The application's current pathname - used to compute active rows and to
|
|
52
|
+
* auto-open the active branch on first render. Pass `usePathname()` from
|
|
53
|
+
* your router. */
|
|
54
|
+
currentPath: string
|
|
55
|
+
/** Optional brand slot rendered in the sidebar header. Typically a logo
|
|
56
|
+
* with an optional sub-label. Hidden in collapsed mode (the collapse
|
|
57
|
+
* toggle takes the slot instead) - the consumer is responsible for
|
|
58
|
+
* rendering a compact mark separately if needed. */
|
|
59
|
+
brand?: React.ReactNode
|
|
60
|
+
/** Optional footer slot, typically a `SidebarUserCard`. */
|
|
61
|
+
footer?: React.ReactNode
|
|
62
|
+
|
|
63
|
+
// ─── Controlled / uncontrolled collapse ────────────────────────────────
|
|
64
|
+
collapsed?: boolean
|
|
65
|
+
defaultCollapsed?: boolean
|
|
66
|
+
onCollapsedChange?: (collapsed: boolean) => void
|
|
67
|
+
/** Hide the built-in collapse toggle. Useful when the consumer controls
|
|
68
|
+
* collapse from elsewhere (e.g. a media-query hook on mobile). */
|
|
69
|
+
hideCollapseToggle?: boolean
|
|
70
|
+
|
|
71
|
+
// ─── Controlled / uncontrolled per-item open overrides ─────────────────
|
|
72
|
+
/** Map of item href → explicit open state. Items not present in the map
|
|
73
|
+
* fall back to auto-open-when-active. */
|
|
74
|
+
expandedItems?: Record<string, boolean>
|
|
75
|
+
defaultExpandedItems?: Record<string, boolean>
|
|
76
|
+
onExpandedItemsChange?: (next: Record<string, boolean>) => void
|
|
77
|
+
|
|
78
|
+
// ─── Link integration ──────────────────────────────────────────────────
|
|
79
|
+
/** Component used to render navigable rows. Defaults to a plain anchor;
|
|
80
|
+
* pass your router's link component (e.g. Next.js `<Link>`) to get
|
|
81
|
+
* client-side navigation. Receives standard anchor props. */
|
|
82
|
+
LinkComponent?: React.ElementType
|
|
83
|
+
|
|
84
|
+
// ─── i18n / a11y labels ────────────────────────────────────────────────
|
|
85
|
+
expandSidebarLabel?: string
|
|
86
|
+
collapseSidebarLabel?: string
|
|
87
|
+
/** Called with `(label)` to format aria-labels for chevron toggle buttons.
|
|
88
|
+
* Defaults to `"Expand {label}"` / `"Collapse {label}"`. */
|
|
89
|
+
expandItemLabel?: (label: string) => string
|
|
90
|
+
collapseItemLabel?: (label: string) => string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function isItemActive(href: string, pathname: string, exact?: boolean): boolean {
|
|
96
|
+
if (exact) return pathname === href
|
|
97
|
+
return pathname === href || pathname.startsWith(`${href}/`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isItemOpenAuto(item: NavItem, pathname: string): boolean {
|
|
101
|
+
if (!item.children?.length) return false
|
|
102
|
+
if (isItemActive(item.href, pathname, item.exact)) return true
|
|
103
|
+
return item.children.some((c) => isItemActive(c.href, pathname))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function countLabel(count: number) {
|
|
107
|
+
return `${count} item${count === 1 ? "" : "s"}`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function NavCount({ count }: { count: number }) {
|
|
111
|
+
return (
|
|
112
|
+
<span className="ax:t-caption ax:text-muted-foreground ax:tabular-nums ax:shrink-0" aria-label={countLabel(count)}>
|
|
113
|
+
{count}
|
|
114
|
+
</span>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Internal: NavRow ────────────────────────────────────────────────────────
|
|
119
|
+
//
|
|
120
|
+
// Renders a single top-level nav row. When the item has children the chevron
|
|
121
|
+
// is split out as a sibling button so clicking the chevron toggles the
|
|
122
|
+
// submenu without navigating. The "raised white card" active treatment lives
|
|
123
|
+
// on the row when there are no children; when children exist the parent
|
|
124
|
+
// wrapper renders the card so the submenu sits on the same surface.
|
|
125
|
+
|
|
126
|
+
interface NavRowProps {
|
|
127
|
+
item: NavItem
|
|
128
|
+
active: boolean
|
|
129
|
+
collapsed: boolean
|
|
130
|
+
open?: boolean
|
|
131
|
+
onToggle?: () => void
|
|
132
|
+
LinkComponent: React.ElementType
|
|
133
|
+
expandItemLabel: (label: string) => string
|
|
134
|
+
collapseItemLabel: (label: string) => string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function NavRow({
|
|
138
|
+
item,
|
|
139
|
+
active,
|
|
140
|
+
collapsed,
|
|
141
|
+
open,
|
|
142
|
+
onToggle,
|
|
143
|
+
LinkComponent,
|
|
144
|
+
expandItemLabel,
|
|
145
|
+
collapseItemLabel,
|
|
146
|
+
}: NavRowProps) {
|
|
147
|
+
const hasChildren = Boolean(item.children?.length)
|
|
148
|
+
const dotValue = typeof item.dot === "number" ? item.dot : undefined
|
|
149
|
+
const showDot = item.dot === true || (typeof item.dot === "number" && item.dot > 0)
|
|
150
|
+
const count = !hasChildren && item.count !== undefined && item.count > 0 ? item.count : undefined
|
|
151
|
+
|
|
152
|
+
const linkBase =
|
|
153
|
+
"ax:group ax:flex ax:items-center ax:gap-xs ax:t-label-sm " +
|
|
154
|
+
"ax:rounded-md ax:py-2xs " +
|
|
155
|
+
"ax:transition-colors ax:transition-snappy " +
|
|
156
|
+
"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 "
|
|
157
|
+
|
|
158
|
+
const inactiveText = "ax:text-foreground-muted ax:hover:text-foreground-strong"
|
|
159
|
+
const activeText = "ax:text-primary-foreground"
|
|
160
|
+
const standaloneActive = !hasChildren && active
|
|
161
|
+
|
|
162
|
+
const row = (
|
|
163
|
+
<LinkComponent
|
|
164
|
+
href={item.href}
|
|
165
|
+
aria-current={active ? "page" : undefined}
|
|
166
|
+
// Collapsed rows render only the icon, so the label has to come from here.
|
|
167
|
+
aria-label={collapsed ? (count !== undefined ? `${item.label}, ${countLabel(count)}` : item.label) : undefined}
|
|
168
|
+
className={cn(
|
|
169
|
+
linkBase,
|
|
170
|
+
hasChildren ? "ax:ps-xs ax:pe-2xs ax:flex-1" : "ax:ps-xs ax:pe-xs",
|
|
171
|
+
standaloneActive
|
|
172
|
+
? "ax:bg-card ax:shadow-xs " + activeText
|
|
173
|
+
: active
|
|
174
|
+
? activeText
|
|
175
|
+
: inactiveText + " ax:hover:bg-card/60",
|
|
176
|
+
)}
|
|
177
|
+
>
|
|
178
|
+
<span className="ax:relative ax:size-4 ax:shrink-0 ax:flex ax:items-center ax:justify-center ax:[&>svg]:size-4 ax:[&>svg]:shrink-0">
|
|
179
|
+
{item.icon}
|
|
180
|
+
{showDot && !hasChildren && (
|
|
181
|
+
<span
|
|
182
|
+
className="ax:absolute ax:-top-0.5 ax:-inset-e-0.5 ax:size-2 ax:rounded-full ax:bg-accent ax:shrink-0"
|
|
183
|
+
aria-label={dotValue ? `${dotValue} unread` : "Unread"}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
</span>
|
|
187
|
+
{!collapsed && <span className="ax:flex-1 ax:truncate ax:leading-normal">{item.label}</span>}
|
|
188
|
+
{!collapsed && count !== undefined && <NavCount count={count} />}
|
|
189
|
+
</LinkComponent>
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
// ─── Collapsed: icon-only row with a tooltip ─────────────────────────
|
|
193
|
+
if (collapsed) {
|
|
194
|
+
return (
|
|
195
|
+
<Tooltip>
|
|
196
|
+
<TooltipTrigger asChild>{row}</TooltipTrigger>
|
|
197
|
+
<TooltipContent side="right">
|
|
198
|
+
{item.label}
|
|
199
|
+
{count !== undefined && <span className="ax:tabular-nums">{` · ${count}`}</span>}
|
|
200
|
+
</TooltipContent>
|
|
201
|
+
</Tooltip>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!hasChildren) return row
|
|
206
|
+
|
|
207
|
+
// ─── With children: row + chevron toggle as siblings ─────────────────
|
|
208
|
+
return (
|
|
209
|
+
<div
|
|
210
|
+
className={cn(
|
|
211
|
+
"ax:flex ax:items-center ax:rounded-md ax:transition-colors ax:transition-snappy",
|
|
212
|
+
active ? "" : "ax:hover:bg-card/60",
|
|
213
|
+
)}
|
|
214
|
+
>
|
|
215
|
+
{row}
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
onClick={onToggle}
|
|
219
|
+
aria-label={open ? collapseItemLabel(item.label) : expandItemLabel(item.label)}
|
|
220
|
+
aria-expanded={open}
|
|
221
|
+
className={cn(
|
|
222
|
+
"ax:shrink-0 ax:size-2xl ax:me-2xs ax:rounded-md ax:flex ax:items-center ax:justify-center",
|
|
223
|
+
"ax:text-muted-foreground ax:hover:text-foreground ax:transition-colors",
|
|
224
|
+
"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",
|
|
225
|
+
)}
|
|
226
|
+
>
|
|
227
|
+
<ChevronDown
|
|
228
|
+
className={cn(
|
|
229
|
+
"ax:size-3.5 ax:transition-transform",
|
|
230
|
+
open ? "ax:rotate-0" : "ax:-rotate-90 ax:rtl:rotate-90",
|
|
231
|
+
)}
|
|
232
|
+
aria-hidden="true"
|
|
233
|
+
/>
|
|
234
|
+
</button>
|
|
235
|
+
</div>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ─── Internal: NavSubRow ─────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
interface NavSubRowProps {
|
|
242
|
+
href: string
|
|
243
|
+
label: string
|
|
244
|
+
active: boolean
|
|
245
|
+
count?: number
|
|
246
|
+
LinkComponent: React.ElementType
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function NavSubRow({ href, label, count, active, LinkComponent }: NavSubRowProps) {
|
|
250
|
+
return (
|
|
251
|
+
<LinkComponent
|
|
252
|
+
href={href}
|
|
253
|
+
aria-current={active ? "page" : undefined}
|
|
254
|
+
className={cn(
|
|
255
|
+
"ax:flex ax:items-center ax:gap-sm ax:ps-md ax:pe-sm ax:py-2xs ax:t-label-sm ax:rounded-md",
|
|
256
|
+
"ax:transition-colors ax:transition-snappy",
|
|
257
|
+
"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",
|
|
258
|
+
active ? "ax:text-foreground" : "ax:text-muted-foreground ax:hover:text-foreground",
|
|
259
|
+
)}
|
|
260
|
+
>
|
|
261
|
+
<span className="ax:flex-1 ax:truncate ax:leading-normal">{label}</span>
|
|
262
|
+
{count !== undefined && count > 0 && <NavCount count={count} />}
|
|
263
|
+
</LinkComponent>
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ─── NavSidebar ──────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* NavSidebar - opinionated, product-agnostic sidebar navigation.
|
|
271
|
+
*
|
|
272
|
+
* Combines the structural primitives (`Sidebar`, `SidebarSection`) with a
|
|
273
|
+
* custom row visual: an active "raised white card" state that extends to
|
|
274
|
+
* wrap nested sub-items, a chevron toggle that opens/closes the submenu
|
|
275
|
+
* without navigating, and a status-dot badge that sits on the top-end
|
|
276
|
+
* corner of the icon (visible in both expanded and collapsed modes).
|
|
277
|
+
*
|
|
278
|
+
* Pass your router's link component via `LinkComponent` for client-side
|
|
279
|
+
* navigation; defaults to a plain `<a>`. State (collapse, per-item
|
|
280
|
+
* open overrides) can be controlled or uncontrolled.
|
|
281
|
+
*
|
|
282
|
+
* @example
|
|
283
|
+
* <NavSidebar
|
|
284
|
+
* LinkComponent={Link}
|
|
285
|
+
* currentPath={usePathname()}
|
|
286
|
+
* brand={<MyLogo />}
|
|
287
|
+
* footer={<SidebarUserCard avatar={…} name="Sara" />}
|
|
288
|
+
* sections={[
|
|
289
|
+
* {
|
|
290
|
+
* label: "Main menu",
|
|
291
|
+
* items: [
|
|
292
|
+
* { href: "/dashboard", label: "Dashboard", icon: <Home /> },
|
|
293
|
+
* {
|
|
294
|
+
* href: "/courses",
|
|
295
|
+
* label: "Courses",
|
|
296
|
+
* icon: <Book />,
|
|
297
|
+
* children: [
|
|
298
|
+
* { href: "/courses", label: "All courses" },
|
|
299
|
+
* { href: "/courses/categories", label: "Categories" },
|
|
300
|
+
* ],
|
|
301
|
+
* },
|
|
302
|
+
* ],
|
|
303
|
+
* },
|
|
304
|
+
* ]}
|
|
305
|
+
* />
|
|
306
|
+
*/
|
|
307
|
+
export function NavSidebar({
|
|
308
|
+
sections,
|
|
309
|
+
currentPath,
|
|
310
|
+
brand,
|
|
311
|
+
footer,
|
|
312
|
+
collapsed: collapsedProp,
|
|
313
|
+
defaultCollapsed = false,
|
|
314
|
+
onCollapsedChange,
|
|
315
|
+
hideCollapseToggle,
|
|
316
|
+
expandedItems: expandedProp,
|
|
317
|
+
defaultExpandedItems,
|
|
318
|
+
onExpandedItemsChange,
|
|
319
|
+
LinkComponent = "a",
|
|
320
|
+
expandSidebarLabel = "Expand sidebar",
|
|
321
|
+
collapseSidebarLabel = "Collapse sidebar",
|
|
322
|
+
expandItemLabel = (label) => `Expand ${label}`,
|
|
323
|
+
collapseItemLabel = (label) => `Collapse ${label}`,
|
|
324
|
+
}: NavSidebarProps) {
|
|
325
|
+
// ─── Uncontrolled collapse state ──────────────────────────────────────
|
|
326
|
+
const [collapsedInternal, setCollapsedInternal] = React.useState(defaultCollapsed)
|
|
327
|
+
const collapsed = collapsedProp ?? collapsedInternal
|
|
328
|
+
const setCollapsed = (next: boolean) => {
|
|
329
|
+
if (collapsedProp === undefined) setCollapsedInternal(next)
|
|
330
|
+
onCollapsedChange?.(next)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ─── Uncontrolled per-item open overrides ─────────────────────────────
|
|
334
|
+
const [overridesInternal, setOverridesInternal] = React.useState<Record<string, boolean>>(
|
|
335
|
+
defaultExpandedItems ?? {},
|
|
336
|
+
)
|
|
337
|
+
const overrides = expandedProp ?? overridesInternal
|
|
338
|
+
const setOverrides = (next: Record<string, boolean>) => {
|
|
339
|
+
if (expandedProp === undefined) setOverridesInternal(next)
|
|
340
|
+
onExpandedItemsChange?.(next)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const isItemOpenResolved = (item: NavItem): boolean => {
|
|
344
|
+
if (item.href in overrides) return overrides[item.href]!
|
|
345
|
+
return isItemOpenAuto(item, currentPath)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const toggleItem = (item: NavItem) => {
|
|
349
|
+
const current = isItemOpenResolved(item)
|
|
350
|
+
setOverrides({ ...overrides, [item.href]: !current })
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ─── Brand area ────────────────────────────────────────────────────────
|
|
354
|
+
// The collapse toggle used to live here as an icon button; it's now the
|
|
355
|
+
// floating circular button straddling the sidebar's edge (below), so the
|
|
356
|
+
// brand row just hides the logo when collapsed.
|
|
357
|
+
const brandSlot = collapsed ? null : (
|
|
358
|
+
<div className="ax:flex ax:items-center ax:w-full ax:overflow-hidden ax:gap-xs">{brand}</div>
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
// The wrapper needs its own explicit width matching `Sidebar`'s (rather than
|
|
362
|
+
// shrink-wrapping it) so the floating toggle's `end-0` anchor lands exactly
|
|
363
|
+
// on the sidebar's real edge instead of drifting with any ambiguity in the
|
|
364
|
+
// surrounding flex layout. Mirrors `Sidebar`'s own default width/collapsed
|
|
365
|
+
// width (16rem / 4rem) since `width` isn't exposed as a prop here.
|
|
366
|
+
const wrapperWidth = collapsed ? "4rem" : "16rem"
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
<div className="ax:relative ax:h-full ax:shrink-0 ax:transition-[width] ax:duration-300" style={{ width: wrapperWidth }}>
|
|
370
|
+
<Sidebar
|
|
371
|
+
collapsed={collapsed}
|
|
372
|
+
brand={brandSlot}
|
|
373
|
+
footer={footer}
|
|
374
|
+
className="ax:bg-transparent ax:border-e-0"
|
|
375
|
+
>
|
|
376
|
+
{sections.map((section, sectionIdx) => (
|
|
377
|
+
<SidebarSection
|
|
378
|
+
key={section.label ?? sectionIdx}
|
|
379
|
+
label={section.label}
|
|
380
|
+
collapsed={collapsed}
|
|
381
|
+
className={sectionIdx === 0 ? undefined : "ax:border-t ax:border-border ax:mt-sm"}
|
|
382
|
+
>
|
|
383
|
+
{section.items.map((item) => {
|
|
384
|
+
const hasChildren = Boolean(item.children?.length)
|
|
385
|
+
const active = isItemActive(item.href, currentPath, item.exact)
|
|
386
|
+
const open = hasChildren ? isItemOpenResolved(item) : false
|
|
387
|
+
// When an item has children AND is active, the elevated white
|
|
388
|
+
// card wraps both the parent row and the submenu so the whole
|
|
389
|
+
// branch reads as one surface. When inactive, the wrapper is a
|
|
390
|
+
// transparent layout container.
|
|
391
|
+
const groupActiveCard = hasChildren && active
|
|
392
|
+
|
|
393
|
+
return (
|
|
394
|
+
<div
|
|
395
|
+
key={item.href}
|
|
396
|
+
className={cn(
|
|
397
|
+
"ax:flex ax:flex-col ax:gap-2xs ax:rounded-md ax:transition-colors",
|
|
398
|
+
groupActiveCard && "ax:bg-transparent",
|
|
399
|
+
)}
|
|
400
|
+
>
|
|
401
|
+
<NavRow
|
|
402
|
+
item={item}
|
|
403
|
+
active={active}
|
|
404
|
+
collapsed={collapsed}
|
|
405
|
+
open={open}
|
|
406
|
+
onToggle={() => toggleItem(item)}
|
|
407
|
+
LinkComponent={LinkComponent}
|
|
408
|
+
expandItemLabel={expandItemLabel}
|
|
409
|
+
collapseItemLabel={collapseItemLabel}
|
|
410
|
+
/>
|
|
411
|
+
{!collapsed && hasChildren && open && (
|
|
412
|
+
<div className="ax:relative ax:ms-md ax:ps-sm ax:pb-sm ax:flex ax:flex-col ax:gap-2xs">
|
|
413
|
+
<span
|
|
414
|
+
aria-hidden="true"
|
|
415
|
+
className="ax:absolute ax:inset-y-0 ax:inset-s-0 ax:w-px ax:bg-border"
|
|
416
|
+
/>
|
|
417
|
+
{item.children!.map((child) => (
|
|
418
|
+
<NavSubRow
|
|
419
|
+
key={child.href}
|
|
420
|
+
href={child.href}
|
|
421
|
+
label={child.label}
|
|
422
|
+
count={child.count}
|
|
423
|
+
active={isItemActive(child.href, currentPath)}
|
|
424
|
+
LinkComponent={LinkComponent}
|
|
425
|
+
/>
|
|
426
|
+
))}
|
|
427
|
+
</div>
|
|
428
|
+
)}
|
|
429
|
+
</div>
|
|
430
|
+
)
|
|
431
|
+
})}
|
|
432
|
+
</SidebarSection>
|
|
433
|
+
))}
|
|
434
|
+
</Sidebar>
|
|
435
|
+
|
|
436
|
+
{/* Floating collapse/expand toggle - vertically centred, straddling
|
|
437
|
+
the border between the sidebar and the main container. */}
|
|
438
|
+
{!hideCollapseToggle && (
|
|
439
|
+
<button
|
|
440
|
+
type="button"
|
|
441
|
+
onClick={() => setCollapsed(!collapsed)}
|
|
442
|
+
aria-label={collapsed ? expandSidebarLabel : collapseSidebarLabel}
|
|
443
|
+
className={cn(
|
|
444
|
+
"ax:absolute ax:top-1/2 ax:inset-e-0 ax:z-20 ax:-translate-y-1/2 ax:translate-x-1/2 ax:rtl:-translate-x-1/2 ax:-mr-3.5",
|
|
445
|
+
"ax:flex ax:h-8 ax:w-6 ax:items-center ax:justify-center ax:rounded-sm",
|
|
446
|
+
"ax:border ax:border-border ax:bg-card ax:text-muted-foreground ax:shadow-xs",
|
|
447
|
+
"ax:transition-colors ax:hover:bg-muted ax:hover:text-foreground",
|
|
448
|
+
"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",
|
|
449
|
+
)}
|
|
450
|
+
>
|
|
451
|
+
<ChevronLeft
|
|
452
|
+
aria-hidden="true"
|
|
453
|
+
className={cn(
|
|
454
|
+
"ax:size-4 ax:transition-transform",
|
|
455
|
+
collapsed ? "ax:rotate-180 ax:rtl:rotate-0" : "ax:rotate-0 ax:rtl:rotate-180",
|
|
456
|
+
)}
|
|
457
|
+
/>
|
|
458
|
+
</button>
|
|
459
|
+
)}
|
|
460
|
+
</div>
|
|
461
|
+
)
|
|
462
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { NumberStepper } from "./number-stepper"
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof NumberStepper> = {
|
|
6
|
+
title: "Components/NumberStepper",
|
|
7
|
+
component: NumberStepper,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
args: {
|
|
10
|
+
defaultValue: 1,
|
|
11
|
+
min: 0,
|
|
12
|
+
max: 10,
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default meta
|
|
17
|
+
type Story = StoryObj<typeof NumberStepper>
|
|
18
|
+
|
|
19
|
+
export const Default: Story = {}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Minus, Plus } from "lucide-react"
|
|
5
|
+
import { Button } from "./button"
|
|
6
|
+
import { Input } from "./input"
|
|
7
|
+
import { cn } from "../lib/cn"
|
|
8
|
+
|
|
9
|
+
export type NumberStepperSize = "sm" | "md" | "lg"
|
|
10
|
+
|
|
11
|
+
export interface NumberStepperProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
12
|
+
value: number
|
|
13
|
+
onChange: (next: number) => void
|
|
14
|
+
/** Smallest value the stepper allows. Defaults to 1. */
|
|
15
|
+
min?: number
|
|
16
|
+
/** Largest value the stepper allows. Defaults to 999. */
|
|
17
|
+
max?: number
|
|
18
|
+
/** Increment / decrement amount. Defaults to 1. */
|
|
19
|
+
step?: number
|
|
20
|
+
/** Disables the whole control (both buttons + input). Per-bound disabling
|
|
21
|
+
* of the +/- buttons still happens automatically at min/max. */
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
/** Visual density. Matches Button / Input size scale. */
|
|
24
|
+
size?: NumberStepperSize
|
|
25
|
+
/** Pass-through id for the central <input>, so a sibling <label htmlFor>
|
|
26
|
+
* binds correctly. */
|
|
27
|
+
id?: string
|
|
28
|
+
/** Accessible label for the wrapper. Set this when context isn't obvious
|
|
29
|
+
* from the surrounding label element. */
|
|
30
|
+
ariaLabel?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Height token per size. Button's `size="icon"` is fixed at h-3xl (40px), so
|
|
34
|
+
// the button's height is overridden here to match the requested stepper size.
|
|
35
|
+
const HEIGHT: Record<NumberStepperSize, string> = {
|
|
36
|
+
sm: "ax:h-2xl", // 32px
|
|
37
|
+
md: "ax:h-3xl", // 40px (matches Button md / Input md)
|
|
38
|
+
lg: "ax:h-4xl", // 48px
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ICON_SIZE: Record<NumberStepperSize, string> = {
|
|
42
|
+
sm: "ax:size-3",
|
|
43
|
+
md: "ax:size-3.5",
|
|
44
|
+
lg: "ax:size-4",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Joined +/-/input control. The three segments split the parent width
|
|
48
|
+
* equally via `flex-1` so the control reads as a single unit at any
|
|
49
|
+
* container size. Negative 1px margins prevent the doubled border between
|
|
50
|
+
* segments; the focus ring on the input is elevated above its neighbours
|
|
51
|
+
* via z-index so it isn't clipped.
|
|
52
|
+
*
|
|
53
|
+
* The wrapper is `w-full`: constrain the parent (e.g. `max-w-sm`) for a
|
|
54
|
+
* more traditional, compact stepper; leave unconstrained to fill the row.
|
|
55
|
+
*
|
|
56
|
+
* Common uses: seat counts in request forms, quantity selectors, any
|
|
57
|
+
* bounded integer input where direct typing should remain possible. */
|
|
58
|
+
export const NumberStepper = React.forwardRef<HTMLDivElement, NumberStepperProps>(
|
|
59
|
+
(
|
|
60
|
+
{
|
|
61
|
+
value,
|
|
62
|
+
onChange,
|
|
63
|
+
min = 1,
|
|
64
|
+
max = 999,
|
|
65
|
+
step = 1,
|
|
66
|
+
disabled = false,
|
|
67
|
+
size = "md",
|
|
68
|
+
id,
|
|
69
|
+
ariaLabel = "Number",
|
|
70
|
+
className,
|
|
71
|
+
...props
|
|
72
|
+
},
|
|
73
|
+
ref,
|
|
74
|
+
) => {
|
|
75
|
+
// Local string buffer so the user can clear the field mid-edit, type
|
|
76
|
+
// multi-digit values that transit through the bounds, etc. We commit
|
|
77
|
+
// live ONLY when the typed string parses to a value already within
|
|
78
|
+
// bounds; out-of-range or empty strings stay as a draft until blur.
|
|
79
|
+
const [draft, setDraft] = React.useState(String(value))
|
|
80
|
+
|
|
81
|
+
// Sync the buffer when `value` changes from the outside (e.g. parent
|
|
82
|
+
// form state update). Avoids tearing between controlled prop and draft.
|
|
83
|
+
React.useEffect(() => {
|
|
84
|
+
setDraft(String(value))
|
|
85
|
+
}, [value])
|
|
86
|
+
|
|
87
|
+
const clamp = (n: number) => Math.min(max, Math.max(min, n))
|
|
88
|
+
|
|
89
|
+
const decrement = () => onChange(clamp(value - step))
|
|
90
|
+
const increment = () => onChange(clamp(value + step))
|
|
91
|
+
|
|
92
|
+
const handleType = (raw: string) => {
|
|
93
|
+
setDraft(raw)
|
|
94
|
+
const parsed = parseInt(raw, 10)
|
|
95
|
+
// Commit live only for clean, in-bounds integers, keeps disabled-state
|
|
96
|
+
// buttons accurate as the user types, without snapping mid-edit.
|
|
97
|
+
if (!Number.isNaN(parsed) && parsed >= min && parsed <= max && parsed !== value) {
|
|
98
|
+
onChange(parsed)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const handleBlur = () => {
|
|
103
|
+
const parsed = parseInt(draft, 10)
|
|
104
|
+
const next = Number.isNaN(parsed) ? min : clamp(parsed)
|
|
105
|
+
setDraft(String(next))
|
|
106
|
+
if (next !== value) onChange(next)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
ref={ref}
|
|
112
|
+
className={cn("ax:flex ax:items-stretch ax:w-full", className)}
|
|
113
|
+
role="group"
|
|
114
|
+
aria-label={ariaLabel}
|
|
115
|
+
{...props}
|
|
116
|
+
>
|
|
117
|
+
<Button
|
|
118
|
+
variant="outline"
|
|
119
|
+
size="icon"
|
|
120
|
+
onClick={decrement}
|
|
121
|
+
disabled={disabled || value <= min}
|
|
122
|
+
aria-label="Decrease"
|
|
123
|
+
className={cn("ax:flex-1 ax:w-0 ax:rounded-e-none ax:-me-px ax:bg-neutral-weakest", HEIGHT[size])}
|
|
124
|
+
>
|
|
125
|
+
<Minus className={ICON_SIZE[size]} />
|
|
126
|
+
</Button>
|
|
127
|
+
<Input
|
|
128
|
+
id={id}
|
|
129
|
+
type="number"
|
|
130
|
+
inputMode="numeric"
|
|
131
|
+
min={min}
|
|
132
|
+
max={max}
|
|
133
|
+
step={step}
|
|
134
|
+
size={size}
|
|
135
|
+
value={draft}
|
|
136
|
+
onChange={(e) => handleType(e.target.value)}
|
|
137
|
+
onBlur={handleBlur}
|
|
138
|
+
disabled={disabled}
|
|
139
|
+
className={cn(
|
|
140
|
+
"ax:flex-1 ax:w-0 ax:text-center ax:rounded-none ax:focus-visible:relative ax:focus-visible:z-10",
|
|
141
|
+
"ax:[appearance:textfield] ax:[&::-webkit-outer-spin-button]:appearance-none ax:[&::-webkit-inner-spin-button]:appearance-none",
|
|
142
|
+
)}
|
|
143
|
+
/>
|
|
144
|
+
<Button
|
|
145
|
+
variant="outline"
|
|
146
|
+
size="icon"
|
|
147
|
+
onClick={increment}
|
|
148
|
+
disabled={disabled || value >= max}
|
|
149
|
+
aria-label="Increase"
|
|
150
|
+
className={cn("ax:flex-1 ax:w-0 ax:rounded-s-none ax:-ms-px ax:bg-neutral-weakest", HEIGHT[size])}
|
|
151
|
+
>
|
|
152
|
+
<Plus className={ICON_SIZE[size]} />
|
|
153
|
+
</Button>
|
|
154
|
+
</div>
|
|
155
|
+
)
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
NumberStepper.displayName = "NumberStepper"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { Button } from "./button"
|
|
4
|
+
import { PageHeader } from "./page-header"
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof PageHeader> = {
|
|
7
|
+
title: "Components/PageHeader",
|
|
8
|
+
component: PageHeader,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
args: {
|
|
11
|
+
title: "Students",
|
|
12
|
+
subtitle: "38 active learners",
|
|
13
|
+
children: (
|
|
14
|
+
<>
|
|
15
|
+
<Button variant="outline">Import CSV</Button>
|
|
16
|
+
<Button>Invite students</Button>
|
|
17
|
+
</>
|
|
18
|
+
),
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default meta
|
|
23
|
+
type Story = StoryObj<typeof PageHeader>
|
|
24
|
+
|
|
25
|
+
export const Default: Story = {}
|