@bearmenu/ui 0.8.4 → 0.8.6

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.
Files changed (157) hide show
  1. package/dist/chunk-637L7XQ3.js +85 -0
  2. package/dist/chunk-6XMYEZVR.js +26 -0
  3. package/dist/chunk-DNSXMHYX.js +8 -0
  4. package/dist/chunk-FDITZ2VM.js +16 -0
  5. package/dist/chunk-GPH6L4DX.js +55 -0
  6. package/dist/{chunk-N2KNBPAQ.js → chunk-QXHMPJ35.js} +1 -0
  7. package/dist/chunk-TT3WFS6S.js +379 -0
  8. package/dist/chunk-UXWAOISG.js +170 -0
  9. package/dist/{chunk-UZ3PCIE5.js → chunk-XWMRABZW.js} +2 -2
  10. package/dist/{chunk-VBA45GLP.js → chunk-ZDAHKI3A.js} +12 -2
  11. package/dist/components/alert.d.ts +2 -2
  12. package/dist/components/badge.d.ts +1 -1
  13. package/dist/components/button.d.ts +1 -1
  14. package/dist/components/card.d.ts +2 -2
  15. package/dist/components/card.js +4 -127
  16. package/dist/components/category-deck.d.ts +50 -0
  17. package/dist/components/category-deck.js +128 -0
  18. package/dist/components/command.d.ts +15 -15
  19. package/dist/components/concept-card.d.ts +43 -0
  20. package/dist/components/concept-card.js +81 -0
  21. package/dist/components/coverflow.d.ts +123 -0
  22. package/dist/components/coverflow.js +300 -0
  23. package/dist/components/date-input.d.ts +1 -1
  24. package/dist/components/day-rail-chip.d.ts +82 -0
  25. package/dist/components/day-rail-chip.js +105 -0
  26. package/dist/components/drift-rail.d.ts +28 -0
  27. package/dist/components/drift-rail.js +3 -0
  28. package/dist/components/event-card.d.ts +97 -0
  29. package/dist/components/event-card.js +161 -0
  30. package/dist/components/event-compact-row.d.ts +72 -0
  31. package/dist/components/event-compact-row.js +133 -0
  32. package/dist/components/event-day-header.d.ts +65 -0
  33. package/dist/components/event-day-header.js +95 -0
  34. package/dist/components/event-day-separator.d.ts +46 -0
  35. package/dist/components/event-day-separator.js +50 -0
  36. package/dist/components/event-detail-panel.d.ts +80 -0
  37. package/dist/components/event-detail-panel.js +251 -0
  38. package/dist/components/event-hub-hero.d.ts +34 -0
  39. package/dist/components/event-hub-hero.js +57 -0
  40. package/dist/components/event-index-row.d.ts +68 -0
  41. package/dist/components/event-index-row.js +84 -0
  42. package/dist/components/event-list-row-wide.d.ts +101 -0
  43. package/dist/components/event-list-row-wide.js +218 -0
  44. package/dist/components/event-list-row.d.ts +82 -0
  45. package/dist/components/event-list-row.js +173 -0
  46. package/dist/components/event-poster-fallback.d.ts +20 -0
  47. package/dist/components/event-poster-fallback.js +3 -0
  48. package/dist/components/event-types.d.ts +118 -0
  49. package/dist/components/event-types.js +2 -0
  50. package/dist/components/events-empty-state.d.ts +44 -0
  51. package/dist/components/events-empty-state.js +73 -0
  52. package/dist/components/facet-group.d.ts +93 -0
  53. package/dist/components/facet-group.js +222 -0
  54. package/dist/components/featured-rail-item.d.ts +42 -0
  55. package/dist/components/featured-rail-item.js +88 -0
  56. package/dist/components/hour-band.d.ts +68 -0
  57. package/dist/components/hour-band.js +53 -0
  58. package/dist/components/hour-group.d.ts +67 -0
  59. package/dist/components/hour-group.js +59 -0
  60. package/dist/components/item.d.ts +2 -2
  61. package/dist/components/motion-icon-controls.js +3 -379
  62. package/dist/components/multi-select-combobox.js +1 -1
  63. package/dist/components/place-about-tab.js +3 -3
  64. package/dist/components/place-details-mobile.js +3 -3
  65. package/dist/components/poster-stack.d.ts +30 -0
  66. package/dist/components/poster-stack.js +104 -0
  67. package/dist/components/poster-tape.d.ts +36 -0
  68. package/dist/components/poster-tape.js +64 -0
  69. package/dist/components/poster-wall.d.ts +25 -0
  70. package/dist/components/poster-wall.js +183 -0
  71. package/dist/components/quiet-stretch.d.ts +43 -0
  72. package/dist/components/quiet-stretch.js +54 -0
  73. package/dist/components/section-header.d.ts +53 -0
  74. package/dist/components/section-header.js +63 -0
  75. package/dist/components/segmented-nav.d.ts +62 -0
  76. package/dist/components/segmented-nav.js +54 -0
  77. package/dist/components/skeleton-card.js +1 -1
  78. package/dist/components/skeleton.d.ts +17 -0
  79. package/dist/components/skeleton.js +1 -1
  80. package/dist/components/spinner.d.ts +2 -2
  81. package/dist/components/tag-group.js +3 -26
  82. package/dist/components/tag.d.ts +2 -2
  83. package/dist/components/tag.js +1 -1
  84. package/dist/components/text.d.ts +3 -3
  85. package/dist/components/timeline-band.d.ts +46 -0
  86. package/dist/components/timeline-band.js +5 -0
  87. package/dist/components/venue-events-block.d.ts +143 -0
  88. package/dist/components/venue-events-block.js +222 -0
  89. package/dist/components/weekend-panel.d.ts +44 -0
  90. package/dist/components/weekend-panel.js +231 -0
  91. package/package.json +1 -1
  92. package/src/components/card.tsx +44 -1
  93. package/src/components/category-deck.stories.tsx +96 -0
  94. package/src/components/category-deck.tsx +227 -0
  95. package/src/components/concept-card.stories.tsx +80 -0
  96. package/src/components/concept-card.tsx +143 -0
  97. package/src/components/coverflow.stories.tsx +240 -0
  98. package/src/components/coverflow.test.tsx +193 -0
  99. package/src/components/coverflow.tsx +550 -0
  100. package/src/components/day-rail-chip.stories.tsx +145 -0
  101. package/src/components/day-rail-chip.tsx +201 -0
  102. package/src/components/drift-rail.stories.tsx +92 -0
  103. package/src/components/drift-rail.tsx +86 -0
  104. package/src/components/event-card.stories.tsx +248 -0
  105. package/src/components/event-card.test.tsx +177 -0
  106. package/src/components/event-card.tsx +321 -0
  107. package/src/components/event-compact-row.stories.tsx +221 -0
  108. package/src/components/event-compact-row.tsx +268 -0
  109. package/src/components/event-day-header.stories.tsx +124 -0
  110. package/src/components/event-day-header.tsx +180 -0
  111. package/src/components/event-day-separator.stories.tsx +70 -0
  112. package/src/components/event-day-separator.tsx +95 -0
  113. package/src/components/event-detail-panel.stories.tsx +191 -0
  114. package/src/components/event-detail-panel.tsx +360 -0
  115. package/src/components/event-hub-hero.stories.tsx +66 -0
  116. package/src/components/event-hub-hero.tsx +110 -0
  117. package/src/components/event-index-row.stories.tsx +116 -0
  118. package/src/components/event-index-row.tsx +181 -0
  119. package/src/components/event-list-row-wide.stories.tsx +204 -0
  120. package/src/components/event-list-row-wide.tsx +386 -0
  121. package/src/components/event-list-row.stories.tsx +159 -0
  122. package/src/components/event-list-row.tsx +335 -0
  123. package/src/components/event-poster-fallback.stories.tsx +52 -0
  124. package/src/components/event-poster-fallback.tsx +31 -0
  125. package/src/components/event-types.ts +133 -0
  126. package/src/components/events-empty-state.stories.tsx +59 -0
  127. package/src/components/events-empty-state.tsx +131 -0
  128. package/src/components/facet-group.stories.tsx +247 -0
  129. package/src/components/facet-group.test.tsx +225 -0
  130. package/src/components/facet-group.tsx +352 -0
  131. package/src/components/featured-rail-item.stories.tsx +86 -0
  132. package/src/components/featured-rail-item.tsx +153 -0
  133. package/src/components/hour-band.stories.tsx +159 -0
  134. package/src/components/hour-band.tsx +126 -0
  135. package/src/components/hour-group.stories.tsx +100 -0
  136. package/src/components/hour-group.tsx +124 -0
  137. package/src/components/poster-stack.stories.tsx +68 -0
  138. package/src/components/poster-stack.tsx +155 -0
  139. package/src/components/poster-tape.stories.tsx +70 -0
  140. package/src/components/poster-tape.tsx +122 -0
  141. package/src/components/poster-wall.stories.tsx +95 -0
  142. package/src/components/poster-wall.tsx +220 -0
  143. package/src/components/quiet-stretch.stories.tsx +62 -0
  144. package/src/components/quiet-stretch.tsx +115 -0
  145. package/src/components/section-header.stories.tsx +70 -0
  146. package/src/components/section-header.tsx +134 -0
  147. package/src/components/segmented-nav.stories.tsx +118 -0
  148. package/src/components/segmented-nav.tsx +113 -0
  149. package/src/components/skeleton.tsx +18 -0
  150. package/src/components/tag.tsx +11 -1
  151. package/src/components/timeline-band.stories.tsx +206 -0
  152. package/src/components/timeline-band.tsx +207 -0
  153. package/src/components/venue-events-block.stories.tsx +216 -0
  154. package/src/components/venue-events-block.tsx +408 -0
  155. package/src/components/weekend-panel.stories.tsx +92 -0
  156. package/src/components/weekend-panel.tsx +322 -0
  157. package/src/globals.css +19 -0
@@ -0,0 +1,62 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { QuietStretch, type QuietStretchDay } from "./quiet-stretch";
3
+
4
+ const days: QuietStretchDay[] = [
5
+ { id: "mon", dayLabel: "Mon 7", count: 0 },
6
+ { id: "tue", dayLabel: "Tue 8", count: 0 },
7
+ { id: "wed", dayLabel: "Wed 9", count: 2 },
8
+ { id: "thu", dayLabel: "Thu 10", count: 0 },
9
+ ];
10
+
11
+ const meta: Meta<typeof QuietStretch> = {
12
+ title: "Events/QuietStretch",
13
+ component: QuietStretch,
14
+ tags: ["autodocs"],
15
+ args: {
16
+ label: "QUIET STRETCH",
17
+ days,
18
+ emptyLabel: "nothing yet",
19
+ actionLabel: "Expand",
20
+ },
21
+ decorators: [
22
+ (Story) => (
23
+ <div className="w-[640px]">
24
+ <Story />
25
+ </div>
26
+ ),
27
+ ],
28
+ parameters: {
29
+ docs: {
30
+ description: {
31
+ component:
32
+ "The collapsed run of quiet days on the timeline, summarising a run of near-empty days into one row. Responsive rather than a separate mobile component: past `sm` the label/days/action sit on one row, below it they stack.",
33
+ },
34
+ },
35
+ },
36
+ };
37
+
38
+ export default meta;
39
+ type Story = StoryObj<typeof QuietStretch>;
40
+
41
+ export const Default: Story = {
42
+ args: { onAction: () => {} },
43
+ };
44
+
45
+ /** No link or handler — the action renders as static text, not a control. */
46
+ export const NonInteractive: Story = {};
47
+
48
+ export const AsLink: Story = {
49
+ args: { href: "#quiet-stretch" },
50
+ };
51
+
52
+ /** Narrow viewport — label, days and action stack, days wrap at a tighter gap. */
53
+ export const Narrow: Story = {
54
+ args: { onAction: () => {} },
55
+ decorators: [
56
+ (Story) => (
57
+ <div className="w-[360px]">
58
+ <Story />
59
+ </div>
60
+ ),
61
+ ],
62
+ };
@@ -0,0 +1,115 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { ChevronDown } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+
7
+ /**
8
+ * QuietStretch — the collapsed run of quiet days on the timeline, summarising
9
+ * a run of near-empty days into one row so the reader doesn't scroll past
10
+ * dozens of "nothing on" headers to reach the next thing worth seeing.
11
+ *
12
+ * Responsive rather than a separate mobile component: past `sm` the label,
13
+ * days and action sit on one row; below it they stack, and the days list
14
+ * wraps at a tighter gap (14px vs. the desktop 22px).
15
+ *
16
+ * The action renders as a link whenever `href` is given, a button when only
17
+ * `onAction` is — same anchor rule as the rest of the events surface.
18
+ */
19
+
20
+ export type QuietStretchLinkProps = {
21
+ href: string;
22
+ className?: string;
23
+ children: React.ReactNode;
24
+ onClick?: (mouseEvent: React.MouseEvent) => void;
25
+ };
26
+
27
+ function DefaultLink({ href, ...props }: QuietStretchLinkProps) {
28
+ return <a href={href} {...props} />;
29
+ }
30
+
31
+ export type QuietStretchDay = {
32
+ id: string;
33
+ /** Already formatted and localised, e.g. "Mon 7". */
34
+ dayLabel: string;
35
+ count: number;
36
+ };
37
+
38
+ export type QuietStretchProps = {
39
+ /** e.g. "QUIET STRETCH". */
40
+ label: string;
41
+ days: QuietStretchDay[];
42
+ /** Shown in place of a zero count, e.g. "nothing yet". */
43
+ emptyLabel: string;
44
+ /** e.g. "Expand". */
45
+ actionLabel: string;
46
+ href?: string;
47
+ linkComponent?: React.ComponentType<QuietStretchLinkProps>;
48
+ onAction?: (mouseEvent: React.MouseEvent) => void;
49
+ className?: string;
50
+ };
51
+
52
+ export function QuietStretch({
53
+ label,
54
+ days,
55
+ emptyLabel,
56
+ actionLabel,
57
+ href,
58
+ linkComponent: LinkComponent = DefaultLink,
59
+ onAction,
60
+ className,
61
+ }: QuietStretchProps) {
62
+ const isInteractive = !!href || !!onAction;
63
+
64
+ const actionClasses = cn(
65
+ "inline-flex items-center gap-1 whitespace-nowrap rounded-sm text-[13px] font-bold text-primary sm:ml-auto",
66
+ isInteractive &&
67
+ cn(
68
+ "-m-1 cursor-pointer p-1",
69
+ "transition-colors duration-150 hover:text-primary/80 motion-reduce:transition-none",
70
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
71
+ )
72
+ );
73
+
74
+ const action = (
75
+ <>
76
+ {actionLabel}
77
+ <ChevronDown className="h-3.5 w-3.5" aria-hidden />
78
+ </>
79
+ );
80
+
81
+ return (
82
+ <div
83
+ className={cn(
84
+ "my-2 flex flex-col gap-3 rounded-xl bg-muted px-[22px] py-5",
85
+ "sm:flex-row sm:items-center sm:gap-4",
86
+ className
87
+ )}
88
+ >
89
+ <span className="whitespace-nowrap text-xs font-extrabold tracking-widest text-muted-foreground">
90
+ {label}
91
+ </span>
92
+
93
+ <div className="flex flex-wrap gap-3.5 text-[13px] sm:gap-[22px]">
94
+ {days.map((day) => (
95
+ <span key={day.id} className="whitespace-nowrap">
96
+ <span className="font-bold">{day.dayLabel}</span>{" "}
97
+ <span className="text-muted-foreground">{day.count > 0 ? day.count : emptyLabel}</span>
98
+ </span>
99
+ ))}
100
+ </div>
101
+
102
+ {href ? (
103
+ <LinkComponent href={href} className={actionClasses} onClick={onAction}>
104
+ {action}
105
+ </LinkComponent>
106
+ ) : onAction ? (
107
+ <button type="button" className={actionClasses} onClick={onAction}>
108
+ {action}
109
+ </button>
110
+ ) : (
111
+ <span className={actionClasses}>{action}</span>
112
+ )}
113
+ </div>
114
+ );
115
+ }
@@ -0,0 +1,70 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { SectionHeader, SectionHeaderSkeleton } from "./section-header";
3
+
4
+ // Explicitly annotated rather than `satisfies`: the meta-level decorator makes
5
+ // the inferred type reference storybook's internal CSF module, which tsc cannot
6
+ // name portably (TS2742).
7
+ const meta: Meta<typeof SectionHeader> = {
8
+ title: "Events/SectionHeader",
9
+ component: SectionHeader,
10
+ tags: ["autodocs"],
11
+ args: { title: "Tonight" },
12
+ decorators: [
13
+ (Story) => (
14
+ <div className="w-[360px]">
15
+ <Story />
16
+ </div>
17
+ ),
18
+ ],
19
+ };
20
+
21
+ export default meta;
22
+ type Story = StoryObj<typeof SectionHeader>;
23
+
24
+ export const Default: Story = {};
25
+
26
+ export const WithCount: Story = {
27
+ args: { count: 12 },
28
+ };
29
+
30
+ export const WithAction: Story = {
31
+ args: { count: 12, action: "See all", actionHref: "/events" },
32
+ };
33
+
34
+ export const WithSubtitle: Story = {
35
+ args: {
36
+ title: "This weekend",
37
+ subtitle: "Curated by the team, updated every Friday.",
38
+ action: "See all",
39
+ actionHref: "/events/weekend",
40
+ },
41
+ };
42
+
43
+ /** `onAction` renders a button instead of a link — used inside a sheet/drawer. */
44
+ export const ButtonAction: Story = {
45
+ args: { action: "Reset", onAction: () => {} },
46
+ };
47
+
48
+ export const AsH1: Story = {
49
+ args: { as: "h1", title: "All events" },
50
+ };
51
+
52
+ /** Loading state — identical geometry to `Default`, so nothing shifts on data arrival. */
53
+ export const Loading: Story = {
54
+ render: () => <SectionHeaderSkeleton />,
55
+ };
56
+
57
+ /** Reserves space for count, action and subtitle — the richest header shape. */
58
+ export const LoadingFull: Story = {
59
+ render: () => <SectionHeaderSkeleton showCount showAction showSubtitle />,
60
+ };
61
+
62
+ /** Loading above loaded, so any geometry drift is visible at a glance. */
63
+ export const LoadingVsLoaded: Story = {
64
+ render: () => (
65
+ <div>
66
+ <SectionHeaderSkeleton showCount showAction />
67
+ <SectionHeader title="Tonight" count={12} action="See all" actionHref="/events" />
68
+ </div>
69
+ ),
70
+ };
@@ -0,0 +1,134 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { ChevronRight } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { Skeleton } from "./skeleton";
7
+
8
+ /**
9
+ * SectionHeader — the title row every rail, list and hub section opens with:
10
+ * title (+ optional count), an optional trailing action, and an optional
11
+ * subtitle line beneath.
12
+ *
13
+ * Renders a link when `actionHref` is given, a button when only `onAction`
14
+ * is, and no action at all otherwise — same href/onClick contract as
15
+ * `EventCard`. No app schema, no i18n, no app hooks.
16
+ */
17
+
18
+ export type SectionHeaderLinkProps = {
19
+ href: string;
20
+ className?: string;
21
+ children: React.ReactNode;
22
+ onClick?: (mouseEvent: React.MouseEvent) => void;
23
+ };
24
+
25
+ function DefaultLink({ href, ...props }: SectionHeaderLinkProps) {
26
+ return <a href={href} {...props} />;
27
+ }
28
+
29
+ export type SectionHeaderProps = {
30
+ title: string;
31
+ /** Renders inline after the title in muted text, e.g. "12". */
32
+ count?: number;
33
+ /** Sub-text below the title row. */
34
+ subtitle?: string;
35
+ /** Trailing action label, e.g. "See all". */
36
+ action?: string;
37
+ actionHref?: string;
38
+ /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
39
+ linkComponent?: React.ComponentType<SectionHeaderLinkProps>;
40
+ onAction?: (mouseEvent: React.MouseEvent) => void;
41
+ /**
42
+ * Heading level for `title`, or `"p"` / `"div"` for a section opener that
43
+ * should NOT enter the document outline — e.g. an index whose own group
44
+ * headings are the page's semantic `h2`s, where a generic opener above them
45
+ * would dilute the headings that matter. Defaults to `"h2"`.
46
+ */
47
+ as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "div";
48
+ className?: string;
49
+ };
50
+
51
+ const ACTION_CLASSES = cn(
52
+ "inline-flex shrink-0 items-center gap-0.5 rounded-sm text-[14px] text-muted-foreground",
53
+ "transition-colors duration-150 hover:text-foreground motion-reduce:transition-none",
54
+ "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
55
+ );
56
+
57
+ export function SectionHeader({
58
+ title,
59
+ count,
60
+ subtitle,
61
+ action,
62
+ actionHref,
63
+ linkComponent: LinkComponent = DefaultLink,
64
+ onAction,
65
+ as: Heading = "h2",
66
+ className,
67
+ }: SectionHeaderProps) {
68
+ const actionIcon = <ChevronRight className="h-3.5 w-3.5" aria-hidden />;
69
+
70
+ return (
71
+ <div className={cn("flex flex-col mb-3.5", className)}>
72
+ <div className="flex items-center justify-between gap-2">
73
+ <div className="flex min-w-0 items-baseline">
74
+ <Heading className="truncate text-[17px] font-bold tracking-[-0.01em] text-foreground md:text-[18px]">
75
+ {title}
76
+ </Heading>
77
+ {count !== undefined && (
78
+ <span className="ml-0.5 shrink-0 text-[12px] text-muted-foreground">{count}</span>
79
+ )}
80
+ </div>
81
+
82
+ {action &&
83
+ (actionHref ? (
84
+ <LinkComponent href={actionHref} className={ACTION_CLASSES} onClick={onAction}>
85
+ {action}
86
+ {actionIcon}
87
+ </LinkComponent>
88
+ ) : onAction ? (
89
+ <button type="button" className={ACTION_CLASSES} onClick={onAction}>
90
+ {action}
91
+ {actionIcon}
92
+ </button>
93
+ ) : null)}
94
+ </div>
95
+
96
+ {subtitle && (
97
+ <p className="mt-0.5 text-[13px] text-muted-foreground md:text-[14px]">{subtitle}</p>
98
+ )}
99
+ </div>
100
+ );
101
+ }
102
+
103
+ export type SectionHeaderSkeletonProps = {
104
+ /** Shows the inline count placeholder, matching `SectionHeaderProps["count"]`. */
105
+ showCount?: boolean;
106
+ /** Shows the trailing action placeholder, matching `SectionHeaderProps["action"]`. */
107
+ showAction?: boolean;
108
+ /** Shows the subtitle placeholder, matching `SectionHeaderProps["subtitle"]`. */
109
+ showSubtitle?: boolean;
110
+ className?: string;
111
+ };
112
+
113
+ /** Mirrors `<SectionHeader>`'s geometry exactly so nothing shifts when data lands. */
114
+ export function SectionHeaderSkeleton({
115
+ showCount,
116
+ showAction,
117
+ showSubtitle,
118
+ className,
119
+ }: SectionHeaderSkeletonProps) {
120
+ return (
121
+ <div aria-hidden="true" className={cn("flex flex-col mb-3.5", className)}>
122
+ <div className="flex items-center justify-between gap-2">
123
+ <div className="flex min-w-0 items-baseline gap-1">
124
+ <Skeleton className="h-[17px] w-32 md:h-[18px]" />
125
+ {showCount && <Skeleton className="h-3 w-4" />}
126
+ </div>
127
+
128
+ {showAction && <Skeleton className="h-3.5 w-16" />}
129
+ </div>
130
+
131
+ {showSubtitle && <Skeleton className="mt-0.5 h-[13px] w-48 md:h-3.5" />}
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,118 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { CalendarClock, LayoutGrid, List, SlidersHorizontal } from "lucide-react";
3
+ import { SegmentedNav } from "./segmented-nav";
4
+
5
+ const meta = {
6
+ title: "Components/SegmentedNav",
7
+ component: SegmentedNav,
8
+ tags: ["autodocs"],
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "A segmented control whose segments are links to separate routes. Use `<Tabs>` for in-page panels and `<ToggleGroup>` for button semantics — this one renders a nav landmark and marks the active segment with `aria-current=\"page\"`.",
14
+ },
15
+ },
16
+ },
17
+ } satisfies Meta<typeof SegmentedNav>;
18
+
19
+ export default meta;
20
+ type Story = StoryObj<typeof meta>;
21
+
22
+ const ICON = "h-4 w-4";
23
+
24
+ const eventsItems = [
25
+ { id: "hub", href: "/events", label: "Hub", icon: <LayoutGrid className={ICON} aria-hidden /> },
26
+ {
27
+ id: "schedule",
28
+ href: "/events/schedule",
29
+ label: "Timeline",
30
+ icon: <CalendarClock className={ICON} aria-hidden />,
31
+ },
32
+ { id: "list", href: "/events/list", label: "List", icon: <List className={ICON} aria-hidden /> },
33
+ ];
34
+
35
+ /** The events surface's three routes — the control this was built for. */
36
+ export const Events: Story = {
37
+ args: {
38
+ items: eventsItems,
39
+ activeId: "schedule",
40
+ label: "Events views",
41
+ },
42
+ };
43
+
44
+ /** Segments stretch to fill the row below `lg`, matching the source design's mobile toggle. */
45
+ export const Mobile: Story = {
46
+ args: {
47
+ items: eventsItems,
48
+ activeId: "hub",
49
+ label: "Events views",
50
+ },
51
+ parameters: { viewport: { defaultViewport: "mobile1" } },
52
+ render: (args) => (
53
+ <div className="w-[358px]">
54
+ <SegmentedNav {...args} />
55
+ </div>
56
+ ),
57
+ };
58
+
59
+ /**
60
+ * BESIDE OTHER CONTROLS — the case that used to fail silently.
61
+ *
62
+ * As a flex child the nav sized to its content, so the segments' `flex-1`
63
+ * distributed only the width of three labels and the rest of the row went to
64
+ * whatever sat next to it. The `w-full` below `lg` is what makes the segments
65
+ * actually own the row here.
66
+ */
67
+ export const InAControlRow: Story = {
68
+ args: {
69
+ items: eventsItems,
70
+ activeId: "schedule",
71
+ label: "Events views",
72
+ },
73
+ render: (args) => (
74
+ <div className="w-[390px] rounded-xl border border-border p-2">
75
+ <div className="flex items-center gap-2">
76
+ <SegmentedNav {...args} />
77
+ <button
78
+ type="button"
79
+ aria-label="Filters"
80
+ className="ml-auto flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-border"
81
+ >
82
+ <SlidersHorizontal className="h-4 w-4" aria-hidden />
83
+ </button>
84
+ </div>
85
+ </div>
86
+ ),
87
+ };
88
+
89
+ /** Labels alone, no icons. */
90
+ export const WithoutIcons: Story = {
91
+ args: {
92
+ items: [
93
+ { id: "all", href: "/all", label: "All" },
94
+ { id: "free", href: "/free", label: "Free" },
95
+ { id: "paid", href: "/paid", label: "Paid" },
96
+ ],
97
+ activeId: "free",
98
+ label: "Entry filter",
99
+ },
100
+ };
101
+
102
+ /**
103
+ * In a Next app, pass `next/link` as `linkComponent` for client-side navigation
104
+ * and prefetch. The default is a plain `<a>` so the component stays usable in
105
+ * a non-Next app.
106
+ */
107
+ export const WithCustomLinkComponent: Story = {
108
+ args: {
109
+ items: eventsItems,
110
+ activeId: "list",
111
+ label: "Events views",
112
+ linkComponent: ({ href, children, ...props }) => (
113
+ <a href={href} data-router="custom" {...props}>
114
+ {children}
115
+ </a>
116
+ ),
117
+ },
118
+ };
@@ -0,0 +1,113 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+
6
+ /**
7
+ * SegmentedNav — a segmented control whose segments are LINKS, not buttons.
8
+ *
9
+ * Use this when the segments are separate routes. For segments that swap
10
+ * content inside one page, use `<Tabs>` (Radix tab-panel semantics) or
11
+ * `<ToggleGroup>` (button semantics) instead — a nav landmark with
12
+ * `aria-current="page"` is only correct when each segment is a destination.
13
+ *
14
+ * FRAMEWORK-AGNOSTIC BY DESIGN. This renders a plain `<a>` unless the caller
15
+ * passes `linkComponent`. It deliberately does NOT import `next/link` the way
16
+ * `link-button.tsx` does — that import is what quietly makes a component
17
+ * Next-only, and this one has to work in admin (Vite) too.
18
+ *
19
+ * Segments stretch to fill the row on mobile and shrink to their content from
20
+ * `lg` up, matching the `.mtog` / `.tog` split in the source design.
21
+ *
22
+ * The `w-full` below `lg` is what makes that sentence TRUE, and it is not
23
+ * redundant with the segments' own `flex-1`. `flex-1` only distributes the
24
+ * width the nav already has; the nav itself sizes to content whenever its
25
+ * parent is a flex row, so a caller placing this beside other controls got
26
+ * three segments hugging their labels with dead space alongside — the
27
+ * component quietly not doing the thing its own docblock claimed. Callers that
28
+ * genuinely want it to hug on mobile can pass `w-auto` through `className`,
29
+ * which wins via `cn`'s merge.
30
+ */
31
+
32
+ export type SegmentedNavItem = {
33
+ /** Stable identity, compared against `activeId`. */
34
+ id: string;
35
+ href: string;
36
+ label: string;
37
+ /** Rendered before the label. Size it at the call site (the design uses 15–16px). */
38
+ icon?: React.ReactNode;
39
+ };
40
+
41
+ /**
42
+ * The shape `linkComponent` must accept. `next/link` satisfies it structurally,
43
+ * so callers can pass it directly.
44
+ */
45
+ export type SegmentedNavLinkProps = {
46
+ href: string;
47
+ className?: string;
48
+ children: React.ReactNode;
49
+ "aria-current"?: "page";
50
+ onClick?: () => void;
51
+ };
52
+
53
+ function DefaultLink({ href, ...props }: SegmentedNavLinkProps) {
54
+ return <a href={href} {...props} />;
55
+ }
56
+
57
+ export type SegmentedNavProps = {
58
+ items: SegmentedNavItem[];
59
+ /** `id` of the segment for the current route. */
60
+ activeId: string;
61
+ /** Accessible name for the nav landmark. Translated by the caller. */
62
+ label: string;
63
+ /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
64
+ linkComponent?: React.ComponentType<SegmentedNavLinkProps>;
65
+ /** Fired with the segment id on click — analytics, haptics. Never navigation. */
66
+ onSelect?: (id: string) => void;
67
+ className?: string;
68
+ };
69
+
70
+ export function SegmentedNav({
71
+ items,
72
+ activeId,
73
+ label,
74
+ linkComponent: LinkComponent = DefaultLink,
75
+ onSelect,
76
+ className,
77
+ }: SegmentedNavProps) {
78
+ return (
79
+ <nav
80
+ aria-label={label}
81
+ className={cn(
82
+ "flex w-full items-center gap-0.5 rounded-lg border border-border bg-card p-0.5",
83
+ "lg:inline-flex lg:w-fit",
84
+ className
85
+ )}
86
+ >
87
+ {items.map((item) => {
88
+ const isActive = item.id === activeId;
89
+ return (
90
+ <LinkComponent
91
+ key={item.id}
92
+ href={item.href}
93
+ aria-current={isActive ? "page" : undefined}
94
+ onClick={onSelect ? () => onSelect(item.id) : undefined}
95
+ className={cn(
96
+ "inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded-md px-3",
97
+ "text-[13px] font-semibold lg:flex-none lg:text-sm",
98
+ "transition-colors duration-150 motion-reduce:transition-none",
99
+ "cursor-pointer touch-manipulation select-none",
100
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
101
+ isActive
102
+ ? "bg-primary/10 text-primary"
103
+ : "text-muted-foreground hover:text-foreground"
104
+ )}
105
+ >
106
+ {item.icon}
107
+ {item.label}
108
+ </LinkComponent>
109
+ );
110
+ })}
111
+ </nav>
112
+ );
113
+ }
@@ -1,6 +1,23 @@
1
1
  import type React from "react";
2
2
  import { cn } from "../lib/utils";
3
3
 
4
+ /**
5
+ * The shimmer stops under `prefers-reduced-motion: reduce`.
6
+ *
7
+ * It did not, and this is the one animation in the package that is guaranteed
8
+ * to be on screen during every slow load, on every surface, in multiples — a
9
+ * list of twelve skeleton rows is twelve sweeping gradients. A reader who has
10
+ * asked the OS for less motion is exactly the reader least served by that.
11
+ *
12
+ * `motion-reduce:` on the `before:` pseudo-element rather than a media query in
13
+ * a style block, because the animation is declared as a utility class here and
14
+ * a class beats a class predictably; the components that animate via an inline
15
+ * `animation-play-state` need the media-query form instead, and say so.
16
+ *
17
+ * The skeleton still reads as a skeleton without the sweep — it keeps its muted
18
+ * fill and its geometry, which is what actually communicates "content is coming
19
+ * and it will be this shape".
20
+ */
4
21
  function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
5
22
  return (
6
23
  <div
@@ -8,6 +25,7 @@ function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>)
8
25
  className={cn(
9
26
  "relative overflow-hidden rounded-xl bg-muted",
10
27
  "before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-foreground/[0.04] before:to-transparent",
28
+ "motion-reduce:before:animate-none motion-reduce:before:hidden",
11
29
  className
12
30
  )}
13
31
  {...props}
@@ -16,9 +16,19 @@ const tagVariants = cva(
16
16
  warning: "border-warning/30 bg-warning/10 text-warning",
17
17
  destructive: "border-destructive/30 bg-destructive/10 text-destructive",
18
18
  outline: "border-border bg-transparent text-foreground",
19
+ /**
20
+ * Decorative metadata — a tag the reader scans past, not one that
21
+ * labels a state. `outline` at full `text-foreground` is too loud
22
+ * repeated a dozen times down a list, and `default`'s `bg-muted` fill
23
+ * is reserved: in the event row family a FILLED chip means live.
24
+ */
25
+ quiet: "border-border bg-transparent text-muted-foreground",
19
26
  },
20
27
  size: {
21
- sm: "h-5 px-2 text-[10px]",
28
+ // 22px/11px, matching the event row family's chip. 10px was the
29
+ // smallest type in the package and below the floor used everywhere
30
+ // else; there is no surface that wants it.
31
+ sm: "h-[22px] px-2 text-[11px]",
22
32
  default: "h-6 px-2.5",
23
33
  lg: "h-7 px-3 text-sm",
24
34
  },