@bearmenu/ui 0.8.7 → 0.8.9
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 → chunk-3TNPSN4D.js} +28 -9
- package/dist/chunk-62ES6N32.js +27 -0
- package/dist/chunk-AHL45DD2.js +145 -0
- package/dist/chunk-AXV5O2IZ.js +58 -0
- package/dist/chunk-IYDH5IU6.js +66 -0
- package/dist/chunk-OPVKL4VS.js +18 -0
- package/dist/chunk-Q6QH52LJ.js +38 -0
- package/dist/chunk-TH6W2PWG.js +213 -0
- package/dist/chunk-THOVPZNN.js +37 -0
- package/dist/chunk-YS6BHKZO.js +92 -0
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/category-deck.js +10 -1
- package/dist/components/command.d.ts +7 -7
- package/dist/components/concept-compare.d.ts +73 -0
- package/dist/components/concept-compare.js +162 -0
- package/dist/components/coverflow.d.ts +11 -3
- package/dist/components/coverflow.js +34 -51
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/drift-rail.d.ts +24 -10
- package/dist/components/drift-rail.js +1 -1
- package/dist/components/event-card.d.ts +8 -0
- package/dist/components/event-card.js +64 -34
- package/dist/components/event-compact-row.d.ts +33 -6
- package/dist/components/event-compact-row.js +120 -39
- package/dist/components/event-day-header.js +3 -1
- package/dist/components/event-day-separator.d.ts +7 -1
- package/dist/components/event-day-separator.js +3 -2
- package/dist/components/event-detail-panel.d.ts +171 -25
- package/dist/components/event-detail-panel.js +471 -174
- package/dist/components/event-hub-hero.d.ts +25 -4
- package/dist/components/event-hub-hero.js +89 -19
- package/dist/components/event-list-row-wide.js +1 -1
- package/dist/components/event-list-row.d.ts +19 -6
- package/dist/components/event-list-row.js +79 -35
- package/dist/components/hour-band.d.ts +38 -40
- package/dist/components/hour-band.js +55 -27
- package/dist/components/hour-group.js +1 -1
- package/dist/components/hub-surface.d.ts +27 -0
- package/dist/components/hub-surface.js +20 -0
- package/dist/components/menu-family-card.d.ts +59 -0
- package/dist/components/menu-family-card.js +121 -0
- package/dist/components/menu-field.d.ts +57 -0
- package/dist/components/menu-field.js +3 -0
- package/dist/components/menu-hub-hero.d.ts +65 -0
- package/dist/components/menu-hub-hero.js +103 -0
- package/dist/components/menu-intent-tiles.d.ts +31 -0
- package/dist/components/menu-intent-tiles.js +50 -0
- package/dist/components/menu-item.d.ts +127 -0
- package/dist/components/menu-item.js +5 -0
- package/dist/components/menu-mocks.d.ts +55 -0
- package/dist/components/menu-mocks.js +379 -0
- package/dist/components/menu-types.d.ts +245 -0
- package/dist/components/menu-types.js +1 -0
- package/dist/components/motion-icon-set.js +15 -3
- package/dist/components/open-now-list.d.ts +40 -0
- package/dist/components/open-now-list.js +69 -0
- package/dist/components/place-types.d.ts +112 -1
- package/dist/components/poster-tape.js +1 -1
- package/dist/components/poster-wall.d.ts +3 -3
- package/dist/components/poster-wall.js +73 -17
- package/dist/components/ranked-venue-list.d.ts +49 -0
- package/dist/components/ranked-venue-list.js +87 -0
- package/dist/components/section-header.d.ts +16 -1
- package/dist/components/section-header.js +26 -7
- package/dist/components/signature-chip.d.ts +37 -0
- package/dist/components/signature-chip.js +4 -0
- package/dist/components/signature-line-list.d.ts +45 -0
- package/dist/components/signature-line-list.js +99 -0
- package/dist/components/snap-rail.d.ts +54 -0
- package/dist/components/snap-rail.js +109 -0
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/stat-banner.d.ts +47 -0
- package/dist/components/stat-banner.js +61 -0
- package/dist/components/text.d.ts +3 -3
- package/dist/components/timeline-band.d.ts +20 -2
- package/dist/components/timeline-band.js +1 -1
- package/dist/components/timeline-playhead.d.ts +26 -0
- package/dist/components/timeline-playhead.js +25 -0
- package/dist/components/venue-busyness-strip.d.ts +35 -0
- package/dist/components/venue-busyness-strip.js +37 -0
- package/dist/components/venue-grade-dimensions.d.ts +36 -0
- package/dist/components/venue-grade-dimensions.js +3 -0
- package/dist/components/venue-quotes.d.ts +32 -0
- package/dist/components/venue-quotes.js +3 -0
- package/dist/components/venue-signature-block.d.ts +39 -0
- package/dist/components/venue-signature-block.js +6 -0
- package/dist/components/venue-spotlight.d.ts +74 -0
- package/dist/components/venue-spotlight.js +138 -0
- package/dist/components/venue-wall.d.ts +41 -0
- package/dist/components/venue-wall.js +118 -0
- package/dist/components/weekday-theme-banner.d.ts +70 -0
- package/dist/components/weekday-theme-banner.js +127 -0
- package/dist/components/weekend-panel.d.ts +5 -4
- package/dist/components/weekend-panel.js +85 -71
- package/dist/hooks/use-drag-swipe.d.ts +44 -0
- package/dist/hooks/use-drag-swipe.js +2 -0
- package/dist/hooks/use-media-query.d.ts +17 -0
- package/dist/hooks/use-media-query.js +2 -0
- package/package.json +1 -1
- package/src/components/category-deck.tsx +19 -8
- package/src/components/colour-dictionary.stories.tsx +227 -0
- package/src/components/concept-compare.stories.tsx +104 -0
- package/src/components/concept-compare.tsx +276 -0
- package/src/components/coverflow.test.tsx +28 -1
- package/src/components/coverflow.tsx +37 -43
- package/src/components/drift-rail.tsx +167 -42
- package/src/components/event-card.tsx +55 -10
- package/src/components/event-compact-row.stories.tsx +30 -0
- package/src/components/event-compact-row.test.tsx +49 -0
- package/src/components/event-compact-row.tsx +162 -38
- package/src/components/event-day-header.tsx +3 -1
- package/src/components/event-day-separator.tsx +8 -1
- package/src/components/event-detail-panel.stories.tsx +205 -97
- package/src/components/event-detail-panel.test.tsx +181 -0
- package/src/components/event-detail-panel.tsx +703 -185
- package/src/components/event-hub-hero.stories.tsx +30 -0
- package/src/components/event-hub-hero.tsx +102 -30
- package/src/components/event-list-row.stories.tsx +34 -0
- package/src/components/event-list-row.test.tsx +53 -0
- package/src/components/event-list-row.tsx +135 -87
- package/src/components/hour-band.stories.tsx +28 -5
- package/src/components/hour-band.tsx +90 -70
- package/src/components/hub-surface.stories.tsx +68 -0
- package/src/components/hub-surface.tsx +40 -0
- package/src/components/menu-family-card.stories.tsx +112 -0
- package/src/components/menu-family-card.tsx +211 -0
- package/src/components/menu-field.stories.tsx +74 -0
- package/src/components/menu-field.tsx +199 -0
- package/src/components/menu-hub-hero.stories.tsx +92 -0
- package/src/components/menu-hub-hero.tsx +187 -0
- package/src/components/menu-intent-tiles.stories.tsx +54 -0
- package/src/components/menu-intent-tiles.tsx +76 -0
- package/src/components/menu-item.tsx +416 -0
- package/src/components/menu-mocks.ts +417 -0
- package/src/components/menu-types.ts +259 -0
- package/src/components/motion-icon-set.tsx +30 -3
- package/src/components/open-now-list.stories.tsx +76 -0
- package/src/components/open-now-list.tsx +127 -0
- package/src/components/place-types.ts +112 -0
- package/src/components/poster-wall.test.tsx +28 -0
- package/src/components/poster-wall.tsx +96 -23
- package/src/components/ranked-venue-list.stories.tsx +80 -0
- package/src/components/ranked-venue-list.tsx +177 -0
- package/src/components/section-header.stories.tsx +24 -0
- package/src/components/section-header.test.tsx +28 -0
- package/src/components/section-header.tsx +45 -7
- package/src/components/signature-chip.tsx +77 -0
- package/src/components/signature-line-list.stories.tsx +85 -0
- package/src/components/signature-line-list.tsx +136 -0
- package/src/components/snap-rail.stories.tsx +79 -0
- package/src/components/snap-rail.test.tsx +18 -0
- package/src/components/snap-rail.tsx +148 -0
- package/src/components/stat-banner.stories.tsx +100 -0
- package/src/components/stat-banner.tsx +117 -0
- package/src/components/timeline-band.tsx +52 -12
- package/src/components/timeline-playhead.stories.tsx +30 -0
- package/src/components/timeline-playhead.test.tsx +11 -0
- package/src/components/timeline-playhead.tsx +46 -0
- package/src/components/venue-busyness-strip.stories.tsx +66 -0
- package/src/components/venue-busyness-strip.tsx +64 -0
- package/src/components/venue-grade-dimensions.stories.tsx +83 -0
- package/src/components/venue-grade-dimensions.tsx +91 -0
- package/src/components/venue-quotes.stories.tsx +59 -0
- package/src/components/venue-quotes.tsx +64 -0
- package/src/components/venue-signature-block.stories.tsx +68 -0
- package/src/components/venue-signature-block.tsx +71 -0
- package/src/components/venue-spotlight.stories.tsx +141 -0
- package/src/components/venue-spotlight.tsx +236 -0
- package/src/components/venue-wall.stories.tsx +78 -0
- package/src/components/venue-wall.tsx +185 -0
- package/src/components/weekday-theme-banner.stories.tsx +113 -0
- package/src/components/weekday-theme-banner.tsx +231 -0
- package/src/components/weekend-panel.test.tsx +43 -0
- package/src/components/weekend-panel.tsx +142 -108
- package/src/hooks/use-drag-swipe.test.tsx +66 -0
- package/src/hooks/use-drag-swipe.ts +105 -0
- package/src/hooks/use-media-query.ts +31 -0
- package/dist/chunk-GPH6L4DX.js +0 -55
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { ChevronRight, Star } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { Separator } from "./separator";
|
|
4
|
+
import { MenuItem, type MenuItemLabels } from "./menu-item";
|
|
5
|
+
import type { MenuVenuePreview, MenuLinkProps, MenuLinkComponent } from "./menu-types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SignatureLineList — family, venue, dish, price and reputation on one line.
|
|
9
|
+
*
|
|
10
|
+
* The densest surface on the hub, and the one that most nearly justifies the
|
|
11
|
+
* whole catalogue: every row ties a kitchen to the single thing its reviewers
|
|
12
|
+
* keep naming, with the price and the count that back it.
|
|
13
|
+
*
|
|
14
|
+
* ## Two targets per row, deliberately
|
|
15
|
+
*
|
|
16
|
+
* The venue line goes to the venue; the item row goes to the item across every
|
|
17
|
+
* kitchen that makes it. Those are genuinely different destinations, and
|
|
18
|
+
* collapsing them into one tap loses the comparison that makes the item worth
|
|
19
|
+
* showing. They are separate anchors rather than one anchor with a nested
|
|
20
|
+
* button, which would be invalid markup.
|
|
21
|
+
*
|
|
22
|
+
* Below `lg` the row stacks: venue line first, then the full `<MenuItem>`
|
|
23
|
+
* underneath. The desktop layout puts them side by side. Same markup, one
|
|
24
|
+
* breakpoint — no second component.
|
|
25
|
+
*
|
|
26
|
+
* Server-safe.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
|
|
30
|
+
export type SignatureLineListLinkProps = MenuLinkProps;
|
|
31
|
+
|
|
32
|
+
function DefaultLink({ href, ...props }: SignatureLineListLinkProps) {
|
|
33
|
+
return <a href={href} {...props} />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type SignatureLineListProps = {
|
|
37
|
+
venues: MenuVenuePreview[];
|
|
38
|
+
/** Passed through to each `<MenuItem>`. */
|
|
39
|
+
labels: MenuItemLabels;
|
|
40
|
+
formatPrice: (amount: number) => string;
|
|
41
|
+
formatCount?: (value: number) => string;
|
|
42
|
+
/** Composed meta line per venue, keyed by place slug, e.g. "romanian · Centru". */
|
|
43
|
+
metaLabels?: Record<string, string>;
|
|
44
|
+
/** The row's category mark per venue, keyed by place slug. */
|
|
45
|
+
hues?: Record<string, string>;
|
|
46
|
+
linkComponent?: MenuLinkComponent;
|
|
47
|
+
className?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function SignatureLineList({
|
|
51
|
+
venues,
|
|
52
|
+
labels,
|
|
53
|
+
formatPrice,
|
|
54
|
+
formatCount,
|
|
55
|
+
metaLabels,
|
|
56
|
+
hues,
|
|
57
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
58
|
+
className,
|
|
59
|
+
}: SignatureLineListProps) {
|
|
60
|
+
return (
|
|
61
|
+
<ul className={cn("flex flex-col", className)}>
|
|
62
|
+
{venues.map((venue, index) => {
|
|
63
|
+
const { place, signature } = venue;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<li
|
|
67
|
+
key={place.slug}
|
|
68
|
+
className="grid gap-x-4 py-3.5 lg:grid-cols-[minmax(0,260px)_minmax(0,1fr)] lg:items-center"
|
|
69
|
+
>
|
|
70
|
+
{/* The DS rule between rows, spanning both grid columns. */}
|
|
71
|
+
{index > 0 && <Separator className="mb-3.5 lg:col-span-2" />}
|
|
72
|
+
<LinkComponent
|
|
73
|
+
href={venue.href}
|
|
74
|
+
className="flex items-center gap-3 rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
75
|
+
>
|
|
76
|
+
<span className="relative h-12 w-12 shrink-0 overflow-hidden rounded-xl bg-[repeating-linear-gradient(135deg,oklch(0.78_0.018_55)_0_8px,oklch(0.84_0.012_58)_8px_16px)] lg:h-16 lg:w-16">
|
|
77
|
+
{place.thumbnail_url && (
|
|
78
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
79
|
+
<img
|
|
80
|
+
src={place.thumbnail_url}
|
|
81
|
+
alt=""
|
|
82
|
+
className="h-full w-full object-cover"
|
|
83
|
+
loading="lazy"
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
{place.open_now && (
|
|
87
|
+
<i
|
|
88
|
+
aria-hidden
|
|
89
|
+
className="absolute right-1 top-1 h-[7px] w-[7px] rounded-full bg-[var(--free,currentColor)] ring-2 ring-white/80"
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
</span>
|
|
93
|
+
|
|
94
|
+
<span className="min-w-0 flex-1">
|
|
95
|
+
<span className="block truncate text-[15px] font-bold tracking-[-0.01em]">
|
|
96
|
+
{place.name}
|
|
97
|
+
</span>
|
|
98
|
+
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
99
|
+
{place.rating && (
|
|
100
|
+
<>
|
|
101
|
+
<Star className="h-[11px] w-[11px]" aria-hidden />
|
|
102
|
+
<span className="tabular-nums">{place.rating.rating}</span>
|
|
103
|
+
</>
|
|
104
|
+
)}
|
|
105
|
+
{metaLabels?.[place.slug] && (
|
|
106
|
+
<span className="truncate">{metaLabels[place.slug]}</span>
|
|
107
|
+
)}
|
|
108
|
+
</span>
|
|
109
|
+
</span>
|
|
110
|
+
|
|
111
|
+
<ChevronRight
|
|
112
|
+
className="h-4 w-4 shrink-0 text-muted-foreground lg:hidden"
|
|
113
|
+
aria-hidden
|
|
114
|
+
/>
|
|
115
|
+
</LinkComponent>
|
|
116
|
+
|
|
117
|
+
{signature && (
|
|
118
|
+
<MenuItem
|
|
119
|
+
item={signature.item}
|
|
120
|
+
labels={labels}
|
|
121
|
+
formatPrice={formatPrice}
|
|
122
|
+
formatCount={formatCount}
|
|
123
|
+
markGradient={hues?.[place.slug]}
|
|
124
|
+
subtitle={signature.why}
|
|
125
|
+
showSignatureBadge={signature.isSignature}
|
|
126
|
+
href={signature.href}
|
|
127
|
+
linkComponent={LinkComponent}
|
|
128
|
+
className="min-w-0"
|
|
129
|
+
/>
|
|
130
|
+
)}
|
|
131
|
+
</li>
|
|
132
|
+
);
|
|
133
|
+
})}
|
|
134
|
+
</ul>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { SnapRail } from "./snap-rail";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof SnapRail> = {
|
|
5
|
+
title: "Primitives/SnapRail",
|
|
6
|
+
component: SnapRail,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
decorators: [
|
|
9
|
+
(Story) => (
|
|
10
|
+
<div className="w-[390px] bg-surface-2 p-3">
|
|
11
|
+
<div className="rounded-[18px] bg-card px-3.5 py-4">
|
|
12
|
+
<Story />
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
),
|
|
16
|
+
],
|
|
17
|
+
parameters: {
|
|
18
|
+
docs: {
|
|
19
|
+
description: {
|
|
20
|
+
component:
|
|
21
|
+
"A swipe-driven rail with mandatory snap points. Direct children become the snap targets and take the rail's item width; `bleed` lets the rail run under its host's padding while still snapping to the host's content edge.",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default meta;
|
|
28
|
+
type Story = StoryObj<typeof SnapRail>;
|
|
29
|
+
|
|
30
|
+
function Poster({ label }: { label: string }) {
|
|
31
|
+
return (
|
|
32
|
+
<div className="flex flex-col gap-2">
|
|
33
|
+
<div className="aspect-[4/3] rounded-xl bg-muted" />
|
|
34
|
+
<div className="text-[13.5px] font-bold">{label}</div>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const cards = ["Psihotrop Live", "Jazz in the Yard", "Quiz Night", "Techno Basement", "Vinyl Hour"];
|
|
40
|
+
|
|
41
|
+
/** 46% items: two cards in view and a third peeking. The category-deck width. */
|
|
42
|
+
export const Small: Story = {
|
|
43
|
+
args: {
|
|
44
|
+
itemWidth: "sm",
|
|
45
|
+
bleed: "card",
|
|
46
|
+
children: cards.map((c) => <Poster key={c} label={c} />),
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** 60% items: one card and a strong peek. The featured-rail width. */
|
|
51
|
+
export const Medium: Story = {
|
|
52
|
+
args: {
|
|
53
|
+
itemWidth: "md",
|
|
54
|
+
bleed: "card",
|
|
55
|
+
children: cards.map((c) => <Poster key={c} label={c} />),
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** 80% items: one card at a time with a sliver of the next. */
|
|
60
|
+
export const Large: Story = {
|
|
61
|
+
args: {
|
|
62
|
+
itemWidth: "lg",
|
|
63
|
+
bleed: "card",
|
|
64
|
+
children: cards.map((c) => <Poster key={c} label={c} />),
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Without `bleed` the first card snaps flush to the host's padding edge and
|
|
70
|
+
* the rail's clip sits inside the card's rounded corner. Kept to show the
|
|
71
|
+
* difference; every hub card wants `bleed="card"`.
|
|
72
|
+
*/
|
|
73
|
+
export const NoBleed: Story = {
|
|
74
|
+
args: {
|
|
75
|
+
itemWidth: "sm",
|
|
76
|
+
bleed: "none",
|
|
77
|
+
children: cards.map((c) => <Poster key={c} label={c} />),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render } from "@testing-library/react";
|
|
3
|
+
import { SnapRail } from "./snap-rail";
|
|
4
|
+
|
|
5
|
+
describe("SnapRail", () => {
|
|
6
|
+
it("applies the item width and bleed variants to the rail", () => {
|
|
7
|
+
const { container } = render(
|
|
8
|
+
<SnapRail itemWidth="md" bleed="card" data-testid="rail">
|
|
9
|
+
<div>a</div>
|
|
10
|
+
<div>b</div>
|
|
11
|
+
</SnapRail>
|
|
12
|
+
);
|
|
13
|
+
const rail = container.firstElementChild as HTMLElement;
|
|
14
|
+
expect(rail.className).toContain("snap-mandatory");
|
|
15
|
+
expect(rail.className).toContain("[&>*]:w-[60%]");
|
|
16
|
+
expect(rail.className).toContain("scroll-px-3.5");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SnapRail — a horizontal, finger-driven rail with mandatory snap points, the
|
|
9
|
+
* way a native app lays out a row of cards. Not `DriftRail`: that one moves on
|
|
10
|
+
* its own and is for ambience; this one waits to be swiped and is for content
|
|
11
|
+
* the reader is expected to reach.
|
|
12
|
+
*
|
|
13
|
+
* Every direct child becomes a snap target and takes the rail's item width, so
|
|
14
|
+
* callers pass bare cards — no wrapper, no per-item width. Widths are
|
|
15
|
+
* percentages of the rail, so the peek of the next card is what tells the
|
|
16
|
+
* reader there is more; there is no scrollbar and no edge fade.
|
|
17
|
+
*
|
|
18
|
+
* `bleed` cancels the host's horizontal padding and restores it as scroll
|
|
19
|
+
* padding, so the first card lines up with the host's content edge and the
|
|
20
|
+
* rail still snaps to that same edge after a swipe. Without it, the rail
|
|
21
|
+
* snaps flush to the host's rounded corner and loads looking pre-scrolled.
|
|
22
|
+
* `card` is the 14px inset of a hub card; `page` is the 16px page gutter.
|
|
23
|
+
*/
|
|
24
|
+
const snapRailVariants = cva(
|
|
25
|
+
cn(
|
|
26
|
+
"flex snap-x snap-mandatory overflow-x-auto pb-0.5 scrollbar-hide",
|
|
27
|
+
"[&>*]:shrink-0 [&>*]:snap-start"
|
|
28
|
+
),
|
|
29
|
+
{
|
|
30
|
+
variants: {
|
|
31
|
+
itemWidth: {
|
|
32
|
+
sm: "[&>*]:w-[46%]",
|
|
33
|
+
md: "[&>*]:w-[60%]",
|
|
34
|
+
lg: "[&>*]:w-[80%]",
|
|
35
|
+
/** One card per view — pair with `dots` so the reader knows there is more. */
|
|
36
|
+
full: "[&>*]:w-full",
|
|
37
|
+
},
|
|
38
|
+
gap: {
|
|
39
|
+
sm: "gap-2",
|
|
40
|
+
default: "gap-3",
|
|
41
|
+
},
|
|
42
|
+
bleed: {
|
|
43
|
+
none: "",
|
|
44
|
+
card: "-mx-3.5 px-3.5 scroll-px-3.5",
|
|
45
|
+
page: "-mx-4 px-4 scroll-px-4",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
defaultVariants: {
|
|
49
|
+
itemWidth: "sm",
|
|
50
|
+
gap: "default",
|
|
51
|
+
bleed: "none",
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
export type SnapRailProps = React.HTMLAttributes<HTMLDivElement> &
|
|
57
|
+
VariantProps<typeof snapRailVariants> & {
|
|
58
|
+
/**
|
|
59
|
+
* Page dots under the rail, one per child, the current one filled. For
|
|
60
|
+
* `itemWidth="full"`, where no next card peeks in to say there is more.
|
|
61
|
+
* Decorative: the rail itself is the control, and every card is reachable
|
|
62
|
+
* by scrolling, so the dots are `aria-hidden`.
|
|
63
|
+
*/
|
|
64
|
+
dots?: boolean;
|
|
65
|
+
/** Classes for the dots row, e.g. `lg:hidden` where the rail becomes a grid. */
|
|
66
|
+
dotsClassName?: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const SnapRail = React.forwardRef<HTMLDivElement, SnapRailProps>(
|
|
70
|
+
({ className, itemWidth, gap, bleed, dots, dotsClassName, children, ...props }, ref) => {
|
|
71
|
+
const railRef = React.useRef<HTMLDivElement | null>(null);
|
|
72
|
+
const [active, setActive] = React.useState(0);
|
|
73
|
+
const count = React.Children.count(children);
|
|
74
|
+
|
|
75
|
+
const setRefs = React.useCallback(
|
|
76
|
+
(node: HTMLDivElement | null) => {
|
|
77
|
+
railRef.current = node;
|
|
78
|
+
if (typeof ref === "function") ref(node);
|
|
79
|
+
else if (ref) ref.current = node;
|
|
80
|
+
},
|
|
81
|
+
[ref]
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// The child whose left edge is nearest the rail's scroll-padding edge is
|
|
85
|
+
// the current page. Read on scroll, rAF-throttled; passive so it never
|
|
86
|
+
// blocks the swipe it watches.
|
|
87
|
+
React.useEffect(() => {
|
|
88
|
+
if (!dots) return;
|
|
89
|
+
const rail = railRef.current;
|
|
90
|
+
if (!rail) return;
|
|
91
|
+
let frame = 0;
|
|
92
|
+
const sync = () => {
|
|
93
|
+
frame = 0;
|
|
94
|
+
const origin = rail.scrollLeft + parseFloat(getComputedStyle(rail).scrollPaddingLeft || "0");
|
|
95
|
+
let best = 0;
|
|
96
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
97
|
+
Array.from(rail.children).forEach((child, index) => {
|
|
98
|
+
const distance = Math.abs((child as HTMLElement).offsetLeft - origin);
|
|
99
|
+
if (distance < bestDistance) {
|
|
100
|
+
bestDistance = distance;
|
|
101
|
+
best = index;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
setActive(best);
|
|
105
|
+
};
|
|
106
|
+
const onScroll = () => {
|
|
107
|
+
if (!frame) frame = requestAnimationFrame(sync);
|
|
108
|
+
};
|
|
109
|
+
sync();
|
|
110
|
+
rail.addEventListener("scroll", onScroll, { passive: true });
|
|
111
|
+
return () => {
|
|
112
|
+
if (frame) cancelAnimationFrame(frame);
|
|
113
|
+
rail.removeEventListener("scroll", onScroll);
|
|
114
|
+
};
|
|
115
|
+
}, [dots, count]);
|
|
116
|
+
|
|
117
|
+
const rail = (
|
|
118
|
+
<div
|
|
119
|
+
ref={setRefs}
|
|
120
|
+
data-slot="snap-rail"
|
|
121
|
+
className={cn(snapRailVariants({ itemWidth, gap, bleed }), className)}
|
|
122
|
+
{...props}
|
|
123
|
+
>
|
|
124
|
+
{children}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (!dots) return rail;
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className="flex flex-col gap-3">
|
|
132
|
+
{rail}
|
|
133
|
+
<div aria-hidden className={cn("flex justify-center gap-1.5", dotsClassName)}>
|
|
134
|
+
{Array.from({ length: count }, (_, index) => (
|
|
135
|
+
<span
|
|
136
|
+
key={index}
|
|
137
|
+
className={cn(
|
|
138
|
+
"h-1.5 rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none",
|
|
139
|
+
index === active ? "w-4 bg-foreground" : "w-1.5 bg-foreground/25"
|
|
140
|
+
)}
|
|
141
|
+
/>
|
|
142
|
+
))}
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
SnapRail.displayName = "SnapRail";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { ArrowRight } from "lucide-react";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { StatBanner } from "./stat-banner";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof StatBanner> = {
|
|
7
|
+
title: "Menu/StatBanner",
|
|
8
|
+
component: StatBanner,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
decorators: [
|
|
11
|
+
(Story) => (
|
|
12
|
+
<div className="w-[520px] bg-background p-4">
|
|
13
|
+
<Story />
|
|
14
|
+
</div>
|
|
15
|
+
),
|
|
16
|
+
],
|
|
17
|
+
parameters: {
|
|
18
|
+
docs: {
|
|
19
|
+
description: {
|
|
20
|
+
component:
|
|
21
|
+
"One claim about the catalogue, carried by a single number. These sit between the modules that list things and give the page somewhere to STATE what it knows rather than only showing it. Two side by side is the usual arrangement, which is a caller's grid rather than a `StatBannerPair` component — pairing is layout, and a consumer wanting one, three, or a different order should not have to fight a wrapper. Pass `scale` only when all three values come from the same real measurement.",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default meta;
|
|
28
|
+
type Story = StoryObj<typeof StatBanner>;
|
|
29
|
+
|
|
30
|
+
export const Dark: Story = {
|
|
31
|
+
args: {
|
|
32
|
+
tone: "dark",
|
|
33
|
+
kicker: "ONE DISH, A DOZEN NAMES",
|
|
34
|
+
headline: "“Margherita” appears on eighty-seven menus and never twice the same way.",
|
|
35
|
+
body: "We match them anyway, so the price comparison is real.",
|
|
36
|
+
watermark: "12",
|
|
37
|
+
action: (
|
|
38
|
+
<Button size="sm" variant="secondary">
|
|
39
|
+
How concepts work
|
|
40
|
+
</Button>
|
|
41
|
+
),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const Warm: Story = {
|
|
46
|
+
args: {
|
|
47
|
+
tone: "warm",
|
|
48
|
+
kicker: "THE REVIEW CENSUS",
|
|
49
|
+
figure: "100",
|
|
50
|
+
figureUnit: "%",
|
|
51
|
+
body: "Every one of the 45,140 items carries a mention count read from the reviews. The typical dish is named twelve times; the most-named, sixty-eight.",
|
|
52
|
+
scale: ["1", "12", "68"],
|
|
53
|
+
action: (
|
|
54
|
+
<Button size="sm" variant="ghost">
|
|
55
|
+
How the census works
|
|
56
|
+
<ArrowRight className="h-3.5 w-3.5" />
|
|
57
|
+
</Button>
|
|
58
|
+
),
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The pair, as the hub arranges it — a caller's grid, not a component. */
|
|
63
|
+
export const AsAPair: Story = {
|
|
64
|
+
render: () => (
|
|
65
|
+
<div className="grid gap-5 lg:grid-cols-2">
|
|
66
|
+
<StatBanner
|
|
67
|
+
tone="dark"
|
|
68
|
+
kicker="ONE DISH, A DOZEN NAMES"
|
|
69
|
+
headline="Eighty-seven menus, a dozen names."
|
|
70
|
+
watermark="12"
|
|
71
|
+
/>
|
|
72
|
+
<StatBanner
|
|
73
|
+
tone="warm"
|
|
74
|
+
kicker="THE REVIEW CENSUS"
|
|
75
|
+
figure="100"
|
|
76
|
+
figureUnit="%"
|
|
77
|
+
body="A mention count on every item in the catalogue."
|
|
78
|
+
scale={["1", "12", "68"]}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
),
|
|
82
|
+
decorators: [
|
|
83
|
+
(Story) => (
|
|
84
|
+
<div className="w-[1000px] bg-background p-4">
|
|
85
|
+
<Story />
|
|
86
|
+
</div>
|
|
87
|
+
),
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** No scale — because nothing counted the distribution. */
|
|
92
|
+
export const WithoutScale: Story = {
|
|
93
|
+
args: {
|
|
94
|
+
tone: "warm",
|
|
95
|
+
kicker: "THE WIDEST SPREAD WE FOUND",
|
|
96
|
+
figure: "4.99",
|
|
97
|
+
figureUnit: "— 30 lei",
|
|
98
|
+
body: "The same espresso, across the ninety-eight bars and cafés that pour one.",
|
|
99
|
+
},
|
|
100
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* StatBanner — one claim about the catalogue, carried by a single number.
|
|
6
|
+
*
|
|
7
|
+
* These sit between the modules that list things and give the page somewhere to
|
|
8
|
+
* state what it knows rather than only showing it. Two side by side is the
|
|
9
|
+
* usual arrangement, which is a caller's grid rather than a `StatBannerPair`
|
|
10
|
+
* component — pairing is layout, and a consumer wanting one, or three, or a
|
|
11
|
+
* different order should not have to fight a wrapper for it.
|
|
12
|
+
*
|
|
13
|
+
* `scale` draws the distribution the figure sits in — low, the figure, high.
|
|
14
|
+
* Pass it only when all three are real numbers from the same measurement; a
|
|
15
|
+
* scale invented around a figure is worse than no scale, because it implies a
|
|
16
|
+
* spread nobody counted.
|
|
17
|
+
*
|
|
18
|
+
* Server-safe.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const bannerVariants = cva(
|
|
22
|
+
"relative flex flex-col gap-2 overflow-hidden rounded-[18px] p-5",
|
|
23
|
+
{
|
|
24
|
+
variants: {
|
|
25
|
+
tone: {
|
|
26
|
+
/** Dark ground, for the claim that needs the most weight. */
|
|
27
|
+
dark: "bg-[oklch(0.21_0.012_55)] text-white",
|
|
28
|
+
/** Tinted ground, for the supporting figure beside it. */
|
|
29
|
+
warm: "bg-accent-subtle text-foreground",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultVariants: { tone: "dark" },
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export type StatBannerProps = VariantProps<typeof bannerVariants> & {
|
|
37
|
+
/** Small caps kicker, e.g. "THE REVIEW CENSUS". */
|
|
38
|
+
kicker?: string;
|
|
39
|
+
/**
|
|
40
|
+
* The headline claim. On the dark tone this is the whole point; on the warm
|
|
41
|
+
* one it usually sits under `figure`.
|
|
42
|
+
*/
|
|
43
|
+
headline?: string;
|
|
44
|
+
body?: string;
|
|
45
|
+
/** The number itself, pre-formatted, e.g. "100". */
|
|
46
|
+
figure?: string;
|
|
47
|
+
/** Unit set small beside the figure, e.g. "%" or "lei". */
|
|
48
|
+
figureUnit?: string;
|
|
49
|
+
/** Oversized watermark numeral behind the content. */
|
|
50
|
+
watermark?: string;
|
|
51
|
+
/** Low, middle and high of the distribution. All three, or none. */
|
|
52
|
+
scale?: [string, string, string];
|
|
53
|
+
/** The action — pass a rendered link or button. */
|
|
54
|
+
action?: React.ReactNode;
|
|
55
|
+
className?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function StatBanner({
|
|
59
|
+
tone,
|
|
60
|
+
kicker,
|
|
61
|
+
headline,
|
|
62
|
+
body,
|
|
63
|
+
figure,
|
|
64
|
+
figureUnit,
|
|
65
|
+
watermark,
|
|
66
|
+
scale,
|
|
67
|
+
action,
|
|
68
|
+
className,
|
|
69
|
+
}: StatBannerProps) {
|
|
70
|
+
return (
|
|
71
|
+
<div className={cn(bannerVariants({ tone }), className)}>
|
|
72
|
+
{watermark && (
|
|
73
|
+
<span
|
|
74
|
+
aria-hidden
|
|
75
|
+
className="pointer-events-none absolute -bottom-8 right-2 select-none font-serif text-[150px] font-bold leading-none opacity-[0.08]"
|
|
76
|
+
>
|
|
77
|
+
{watermark}
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
<div className="relative z-[1] flex flex-1 flex-col gap-2">
|
|
82
|
+
{kicker && (
|
|
83
|
+
<span className="text-[10px] font-bold tracking-[0.11em] opacity-80">{kicker}</span>
|
|
84
|
+
)}
|
|
85
|
+
|
|
86
|
+
{figure && (
|
|
87
|
+
<div className="text-[40px] font-extrabold leading-none tracking-[-0.03em] tabular-nums">
|
|
88
|
+
{figure}
|
|
89
|
+
{figureUnit && (
|
|
90
|
+
<span className="ml-1 text-lg font-extrabold">{figureUnit}</span>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
{headline && (
|
|
96
|
+
<h3 className="font-serif text-xl font-semibold leading-[1.2] tracking-[-0.02em] lg:text-2xl">
|
|
97
|
+
{headline}
|
|
98
|
+
</h3>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
{body && <p className="text-[13px] leading-[1.5] opacity-85">{body}</p>}
|
|
102
|
+
|
|
103
|
+
{scale && (
|
|
104
|
+
<div className="mt-1 flex items-center gap-2 text-[11px] tabular-nums opacity-70">
|
|
105
|
+
<span>{scale[0]}</span>
|
|
106
|
+
<span aria-hidden className="h-px flex-1 bg-current opacity-30" />
|
|
107
|
+
<span>{scale[1]}</span>
|
|
108
|
+
<span aria-hidden className="h-px flex-1 bg-current opacity-30" />
|
|
109
|
+
<span>{scale[2]}</span>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{action && <div className="mt-auto flex items-center pt-3">{action}</div>}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|