@bearmenu/ui 0.8.4 → 0.8.7
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/dist/chunk-637L7XQ3.js +85 -0
- package/dist/chunk-6XMYEZVR.js +26 -0
- package/dist/chunk-DNSXMHYX.js +8 -0
- package/dist/chunk-FDITZ2VM.js +16 -0
- package/dist/chunk-GPH6L4DX.js +55 -0
- package/dist/{chunk-N2KNBPAQ.js → chunk-QXHMPJ35.js} +1 -0
- package/dist/chunk-TT3WFS6S.js +379 -0
- package/dist/chunk-UXWAOISG.js +170 -0
- package/dist/{chunk-UZ3PCIE5.js → chunk-XWMRABZW.js} +2 -2
- package/dist/{chunk-VBA45GLP.js → chunk-ZDAHKI3A.js} +12 -2
- package/dist/components/card.d.ts +2 -2
- package/dist/components/card.js +4 -127
- package/dist/components/category-deck.d.ts +50 -0
- package/dist/components/category-deck.js +128 -0
- package/dist/components/concept-card.d.ts +43 -0
- package/dist/components/concept-card.js +81 -0
- package/dist/components/coverflow.d.ts +123 -0
- package/dist/components/coverflow.js +300 -0
- package/dist/components/day-rail-chip.d.ts +82 -0
- package/dist/components/day-rail-chip.js +105 -0
- package/dist/components/drift-rail.d.ts +28 -0
- package/dist/components/drift-rail.js +3 -0
- package/dist/components/event-card.d.ts +97 -0
- package/dist/components/event-card.js +164 -0
- package/dist/components/event-compact-row.d.ts +73 -0
- package/dist/components/event-compact-row.js +134 -0
- package/dist/components/event-day-header.d.ts +65 -0
- package/dist/components/event-day-header.js +95 -0
- package/dist/components/event-day-separator.d.ts +46 -0
- package/dist/components/event-day-separator.js +50 -0
- package/dist/components/event-detail-panel.d.ts +80 -0
- package/dist/components/event-detail-panel.js +251 -0
- package/dist/components/event-hub-hero.d.ts +34 -0
- package/dist/components/event-hub-hero.js +57 -0
- package/dist/components/event-index-row.d.ts +68 -0
- package/dist/components/event-index-row.js +84 -0
- package/dist/components/event-list-row-wide.d.ts +101 -0
- package/dist/components/event-list-row-wide.js +218 -0
- package/dist/components/event-list-row.d.ts +82 -0
- package/dist/components/event-list-row.js +173 -0
- package/dist/components/event-poster-fallback.d.ts +20 -0
- package/dist/components/event-poster-fallback.js +3 -0
- package/dist/components/event-types.d.ts +122 -0
- package/dist/components/event-types.js +2 -0
- package/dist/components/events-empty-state.d.ts +44 -0
- package/dist/components/events-empty-state.js +73 -0
- package/dist/components/facet-group.d.ts +93 -0
- package/dist/components/facet-group.js +222 -0
- package/dist/components/featured-rail-item.d.ts +42 -0
- package/dist/components/featured-rail-item.js +88 -0
- package/dist/components/hour-band.d.ts +68 -0
- package/dist/components/hour-band.js +53 -0
- package/dist/components/hour-group.d.ts +67 -0
- package/dist/components/hour-group.js +59 -0
- package/dist/components/motion-icon-controls.js +3 -379
- package/dist/components/multi-select-combobox.js +1 -1
- package/dist/components/place-about-tab.js +3 -3
- package/dist/components/place-details-mobile.js +3 -3
- package/dist/components/poster-stack.d.ts +30 -0
- package/dist/components/poster-stack.js +104 -0
- package/dist/components/poster-tape.d.ts +36 -0
- package/dist/components/poster-tape.js +64 -0
- package/dist/components/poster-wall.d.ts +25 -0
- package/dist/components/poster-wall.js +183 -0
- package/dist/components/quiet-stretch.d.ts +43 -0
- package/dist/components/quiet-stretch.js +54 -0
- package/dist/components/section-header.d.ts +53 -0
- package/dist/components/section-header.js +63 -0
- package/dist/components/segmented-nav.d.ts +62 -0
- package/dist/components/segmented-nav.js +54 -0
- package/dist/components/skeleton-card.js +1 -1
- package/dist/components/skeleton.d.ts +17 -0
- package/dist/components/skeleton.js +1 -1
- package/dist/components/tag-group.js +3 -26
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/tag.js +1 -1
- package/dist/components/timeline-band.d.ts +46 -0
- package/dist/components/timeline-band.js +5 -0
- package/dist/components/venue-events-block.d.ts +143 -0
- package/dist/components/venue-events-block.js +222 -0
- package/dist/components/weekend-panel.d.ts +44 -0
- package/dist/components/weekend-panel.js +231 -0
- package/package.json +1 -1
- package/src/components/card.tsx +44 -1
- package/src/components/category-deck.stories.tsx +96 -0
- package/src/components/category-deck.tsx +227 -0
- package/src/components/concept-card.stories.tsx +80 -0
- package/src/components/concept-card.tsx +143 -0
- package/src/components/coverflow.stories.tsx +240 -0
- package/src/components/coverflow.test.tsx +193 -0
- package/src/components/coverflow.tsx +550 -0
- package/src/components/day-rail-chip.stories.tsx +145 -0
- package/src/components/day-rail-chip.tsx +201 -0
- package/src/components/drift-rail.stories.tsx +92 -0
- package/src/components/drift-rail.tsx +86 -0
- package/src/components/event-card.stories.tsx +248 -0
- package/src/components/event-card.test.tsx +188 -0
- package/src/components/event-card.tsx +329 -0
- package/src/components/event-compact-row.stories.tsx +221 -0
- package/src/components/event-compact-row.test.tsx +51 -0
- package/src/components/event-compact-row.tsx +270 -0
- package/src/components/event-day-header.stories.tsx +124 -0
- package/src/components/event-day-header.tsx +180 -0
- package/src/components/event-day-separator.stories.tsx +70 -0
- package/src/components/event-day-separator.tsx +95 -0
- package/src/components/event-detail-panel.stories.tsx +191 -0
- package/src/components/event-detail-panel.tsx +360 -0
- package/src/components/event-hub-hero.stories.tsx +66 -0
- package/src/components/event-hub-hero.tsx +110 -0
- package/src/components/event-index-row.stories.tsx +116 -0
- package/src/components/event-index-row.tsx +181 -0
- package/src/components/event-list-row-wide.stories.tsx +204 -0
- package/src/components/event-list-row-wide.tsx +386 -0
- package/src/components/event-list-row.stories.tsx +159 -0
- package/src/components/event-list-row.tsx +335 -0
- package/src/components/event-poster-fallback.stories.tsx +52 -0
- package/src/components/event-poster-fallback.tsx +31 -0
- package/src/components/event-types.ts +137 -0
- package/src/components/events-empty-state.stories.tsx +59 -0
- package/src/components/events-empty-state.tsx +131 -0
- package/src/components/facet-group.stories.tsx +247 -0
- package/src/components/facet-group.test.tsx +225 -0
- package/src/components/facet-group.tsx +352 -0
- package/src/components/featured-rail-item.stories.tsx +86 -0
- package/src/components/featured-rail-item.tsx +153 -0
- package/src/components/hour-band.stories.tsx +159 -0
- package/src/components/hour-band.tsx +126 -0
- package/src/components/hour-group.stories.tsx +100 -0
- package/src/components/hour-group.tsx +124 -0
- package/src/components/poster-stack.stories.tsx +68 -0
- package/src/components/poster-stack.tsx +155 -0
- package/src/components/poster-tape.stories.tsx +70 -0
- package/src/components/poster-tape.tsx +122 -0
- package/src/components/poster-wall.stories.tsx +95 -0
- package/src/components/poster-wall.tsx +220 -0
- package/src/components/quiet-stretch.stories.tsx +62 -0
- package/src/components/quiet-stretch.tsx +115 -0
- package/src/components/section-header.stories.tsx +70 -0
- package/src/components/section-header.tsx +134 -0
- package/src/components/segmented-nav.stories.tsx +118 -0
- package/src/components/segmented-nav.tsx +113 -0
- package/src/components/skeleton.tsx +18 -0
- package/src/components/tag.tsx +11 -1
- package/src/components/timeline-band.stories.tsx +206 -0
- package/src/components/timeline-band.tsx +207 -0
- package/src/components/venue-events-block.stories.tsx +216 -0
- package/src/components/venue-events-block.tsx +408 -0
- package/src/components/weekend-panel.stories.tsx +92 -0
- package/src/components/weekend-panel.tsx +322 -0
- 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}
|
package/src/components/tag.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
},
|