@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,104 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Button } from "./button";
|
|
3
|
+
import { ConceptCompare } from "./concept-compare";
|
|
4
|
+
import { mockConcept, mockGradients } from "./menu-mocks";
|
|
5
|
+
|
|
6
|
+
const labels = {
|
|
7
|
+
kind: "DISH CONCEPT",
|
|
8
|
+
alias: "ON THEIR MENU AS",
|
|
9
|
+
perPortion: "per portion",
|
|
10
|
+
cheapest: "cheapest shown",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const itemLabels = {
|
|
14
|
+
currency: "lei",
|
|
15
|
+
mentions: (count: number) => `${count} mentions`,
|
|
16
|
+
signature: "SIGNATURE",
|
|
17
|
+
mentioned: "MENTIONED",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const formatPrice = (amount: number) => `${amount} lei`;
|
|
21
|
+
|
|
22
|
+
const hrefs = Object.fromEntries(
|
|
23
|
+
(mockConcept.rows ?? []).map((row) => [row.place_slug, `/places/${row.place_slug}`])
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const meta: Meta<typeof ConceptCompare> = {
|
|
27
|
+
title: "Menu/ConceptCompare",
|
|
28
|
+
component: ConceptCompare,
|
|
29
|
+
tags: ["autodocs"],
|
|
30
|
+
decorators: [
|
|
31
|
+
(Story) => (
|
|
32
|
+
<div className="w-[1000px] bg-background p-4">
|
|
33
|
+
<Story />
|
|
34
|
+
</div>
|
|
35
|
+
),
|
|
36
|
+
],
|
|
37
|
+
parameters: {
|
|
38
|
+
docs: {
|
|
39
|
+
description: {
|
|
40
|
+
component:
|
|
41
|
+
"One dish or drink across every kitchen that makes it, under whatever name each of them uses. The row is PLACE-LED: it leads with the venue photograph, which is real and licensed, because the reader is choosing between restaurants. A price bar was drawn here first and measured the one value already printed beside it in words. From `lg` the rows are a comparison table; below it they become the production `MenuItem`, with the kitchen's own wording as the name.",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export default meta;
|
|
48
|
+
type Story = StoryObj<typeof ConceptCompare>;
|
|
49
|
+
|
|
50
|
+
export const Default: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
concept: mockConcept,
|
|
53
|
+
labels,
|
|
54
|
+
itemLabels,
|
|
55
|
+
formatPrice,
|
|
56
|
+
gradient: mockGradients.pasta,
|
|
57
|
+
figures: [
|
|
58
|
+
{ value: "44", label: "kitchens" },
|
|
59
|
+
{ value: "8—44", label: "lei" },
|
|
60
|
+
{ value: "168", label: "mentions" },
|
|
61
|
+
],
|
|
62
|
+
hrefs,
|
|
63
|
+
footnote: "Prices read from each menu as it stands. Portions normalised where the menu states them.",
|
|
64
|
+
actions: <Button size="sm">See all 44</Button>,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Below `lg` every row is a `MenuItem`, the alias leading. */
|
|
69
|
+
export const Mobile: Story = {
|
|
70
|
+
args: {
|
|
71
|
+
concept: mockConcept,
|
|
72
|
+
labels,
|
|
73
|
+
itemLabels,
|
|
74
|
+
formatPrice,
|
|
75
|
+
gradient: mockGradients.pasta,
|
|
76
|
+
figures: [
|
|
77
|
+
{ value: "44", label: "kitchens" },
|
|
78
|
+
{ value: "8—44", label: "lei" },
|
|
79
|
+
{ value: "168", label: "mentions" },
|
|
80
|
+
],
|
|
81
|
+
hrefs,
|
|
82
|
+
},
|
|
83
|
+
decorators: [
|
|
84
|
+
(Story) => (
|
|
85
|
+
<div className="w-[390px] bg-background p-3">
|
|
86
|
+
<Story />
|
|
87
|
+
</div>
|
|
88
|
+
),
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** With the concept's own photograph — the one item surface that shows one. */
|
|
93
|
+
export const WithPhoto: Story = {
|
|
94
|
+
args: {
|
|
95
|
+
concept: mockConcept,
|
|
96
|
+
labels,
|
|
97
|
+
itemLabels,
|
|
98
|
+
formatPrice,
|
|
99
|
+
gradient: mockGradients.pasta,
|
|
100
|
+
imageUrl: "https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=200&h=200&fit=crop",
|
|
101
|
+
figures: [{ value: "44", label: "kitchens" }],
|
|
102
|
+
hrefs,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { MessageSquare, Star } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { MenuField } from "./menu-field";
|
|
4
|
+
import { MenuItem, type MenuItemLabels } from "./menu-item";
|
|
5
|
+
import type { MenuConceptPreview, MenuLinkComponent, MenuLinkProps } from "./menu-types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ConceptCompare — one dish or drink across every kitchen that makes it, under
|
|
9
|
+
* whatever name each of them uses.
|
|
10
|
+
*
|
|
11
|
+
* The keystone module: a single menu can list papanași, only a catalogue can
|
|
12
|
+
* say that seventy kitchens make them under fifty different names between 8 and
|
|
13
|
+
* 44 lei. The alias column is what makes it honest — these are not the same row
|
|
14
|
+
* seventy times, they are seventy differently-named rows we matched, and
|
|
15
|
+
* showing each kitchen's own wording is the evidence for that.
|
|
16
|
+
*
|
|
17
|
+
* ## The row is place-led
|
|
18
|
+
*
|
|
19
|
+
* Each row leads with the VENUE PHOTOGRAPH, which is real and licensed. It
|
|
20
|
+
* replaced a price bar, and the trade is not close: the bar measured the one
|
|
21
|
+
* value already printed beside it in words, while the thing a reader is
|
|
22
|
+
* actually choosing between — the restaurant — had no image at all. Five
|
|
23
|
+
* restaurants that disagree about a name is the story; five bars of similar
|
|
24
|
+
* length is not.
|
|
25
|
+
*
|
|
26
|
+
* ## Two layouts, one component
|
|
27
|
+
*
|
|
28
|
+
* From `lg` the row is a table: rank, venue, alias, mentions, price. Below it,
|
|
29
|
+
* the rows become the production `<MenuItem>` — the alias as the name, the
|
|
30
|
+
* venue underneath — because a five-column table does not survive 390px and
|
|
31
|
+
* the item row already solves this exact shape everywhere else in the app.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
|
|
35
|
+
export type ConceptCompareLinkProps = MenuLinkProps;
|
|
36
|
+
|
|
37
|
+
function DefaultLink({ href, ...props }: ConceptCompareLinkProps) {
|
|
38
|
+
return <a href={href} {...props} />;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ConceptCompareFigure = {
|
|
42
|
+
/** Pre-formatted, e.g. "44". */
|
|
43
|
+
value: string;
|
|
44
|
+
label: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type ConceptCompareLabels = {
|
|
48
|
+
/** Small caps kicker, e.g. "DISH CONCEPT". */
|
|
49
|
+
kind: string;
|
|
50
|
+
/** Column heading over the aliases, e.g. "ON THEIR MENU AS". */
|
|
51
|
+
alias: string;
|
|
52
|
+
/** Under each price, e.g. "per portion". */
|
|
53
|
+
perPortion?: string;
|
|
54
|
+
/** Badge on the lowest-priced row, e.g. "cheapest shown". */
|
|
55
|
+
cheapest?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type ConceptCompareProps = {
|
|
59
|
+
concept: MenuConceptPreview;
|
|
60
|
+
labels: ConceptCompareLabels;
|
|
61
|
+
/** Passed to the mobile `<MenuItem>` rows. */
|
|
62
|
+
itemLabels: MenuItemLabels;
|
|
63
|
+
formatPrice: (amount: number) => string;
|
|
64
|
+
formatCount?: (value: number) => string;
|
|
65
|
+
/** CSS gradient for the header swatch, when there is no photograph. */
|
|
66
|
+
gradient: string;
|
|
67
|
+
/** A real photograph of the concept, when the matched menu row carries one. */
|
|
68
|
+
imageUrl?: string;
|
|
69
|
+
figures?: ConceptCompareFigure[];
|
|
70
|
+
/** Destination per row, keyed by place slug. */
|
|
71
|
+
hrefs?: Record<string, string>;
|
|
72
|
+
/** The provenance note in the footer. */
|
|
73
|
+
footnote?: string;
|
|
74
|
+
/** Actions in the footer — pass rendered links or buttons. */
|
|
75
|
+
actions?: React.ReactNode;
|
|
76
|
+
linkComponent?: MenuLinkComponent;
|
|
77
|
+
className?: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function ConceptCompare({
|
|
81
|
+
concept,
|
|
82
|
+
labels,
|
|
83
|
+
itemLabels,
|
|
84
|
+
formatPrice,
|
|
85
|
+
formatCount,
|
|
86
|
+
gradient,
|
|
87
|
+
imageUrl,
|
|
88
|
+
figures,
|
|
89
|
+
hrefs,
|
|
90
|
+
footnote,
|
|
91
|
+
actions,
|
|
92
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
93
|
+
className,
|
|
94
|
+
}: ConceptCompareProps) {
|
|
95
|
+
const rows = concept.rows ?? [];
|
|
96
|
+
// Derived, not passed: the cheapest row is a fact about THIS list, and a
|
|
97
|
+
// caller computing it separately could disagree with what is rendered.
|
|
98
|
+
const cheapest = rows.reduce<number | undefined>(
|
|
99
|
+
(lowest, row) => (lowest === undefined || row.price < lowest ? row.price : lowest),
|
|
100
|
+
undefined
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className={cn("overflow-hidden rounded-[18px] border border-border bg-card", className)}>
|
|
105
|
+
<div className="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:gap-6 lg:p-5">
|
|
106
|
+
{imageUrl ? (
|
|
107
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
108
|
+
<img
|
|
109
|
+
src={imageUrl}
|
|
110
|
+
alt=""
|
|
111
|
+
className="aspect-[16/9] w-full shrink-0 rounded-xl object-cover lg:aspect-auto lg:h-[88px] lg:w-[88px]"
|
|
112
|
+
/>
|
|
113
|
+
) : (
|
|
114
|
+
<MenuField
|
|
115
|
+
name={concept.title}
|
|
116
|
+
gradient={gradient}
|
|
117
|
+
seedKey={concept.slug}
|
|
118
|
+
className="aspect-[16/9] w-full shrink-0 rounded-xl lg:aspect-auto lg:h-[88px] lg:w-[88px]"
|
|
119
|
+
/>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
<div className="min-w-0 flex-1">
|
|
123
|
+
<span className="text-[9.5px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
|
124
|
+
{labels.kind}
|
|
125
|
+
</span>
|
|
126
|
+
<h3 className="mt-1 font-serif text-2xl font-semibold tracking-[-0.02em] lg:text-[30px]">
|
|
127
|
+
{concept.title}
|
|
128
|
+
</h3>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{figures && figures.length > 0 && (
|
|
132
|
+
<div className="flex shrink-0 gap-6 lg:gap-8">
|
|
133
|
+
{figures.map((figure) => (
|
|
134
|
+
<div key={figure.label} className="text-left lg:text-right">
|
|
135
|
+
<div className="text-xl font-extrabold leading-none tracking-[-0.03em] tabular-nums lg:text-2xl">
|
|
136
|
+
{figure.value}
|
|
137
|
+
</div>
|
|
138
|
+
<div className="mt-1 text-[10.5px] text-muted-foreground">{figure.label}</div>
|
|
139
|
+
</div>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
{/* ── Below `lg`: the production item row ─────────────────────────── */}
|
|
146
|
+
<ul className="border-t border-border lg:hidden">
|
|
147
|
+
{rows.map((row) => (
|
|
148
|
+
<li key={row.place_slug} className="border-b border-border last:border-b-0">
|
|
149
|
+
<MenuItem
|
|
150
|
+
item={{
|
|
151
|
+
id: row.place_slug,
|
|
152
|
+
slug: row.place_slug,
|
|
153
|
+
// The kitchen's OWN wording leads the row — that IS the point
|
|
154
|
+
// of the module.
|
|
155
|
+
name: row.alias,
|
|
156
|
+
kind: concept.kind,
|
|
157
|
+
mentions: row.mentions,
|
|
158
|
+
sizes_and_prices: [{ size: 0, price: row.price }],
|
|
159
|
+
}}
|
|
160
|
+
labels={itemLabels}
|
|
161
|
+
formatPrice={formatPrice}
|
|
162
|
+
formatCount={formatCount}
|
|
163
|
+
venueName={row.place_name}
|
|
164
|
+
href={hrefs?.[row.place_slug]}
|
|
165
|
+
linkComponent={LinkComponent}
|
|
166
|
+
className={cn(row.price === cheapest && "bg-accent-subtle/40")}
|
|
167
|
+
/>
|
|
168
|
+
</li>
|
|
169
|
+
))}
|
|
170
|
+
</ul>
|
|
171
|
+
|
|
172
|
+
{/* ── From `lg`: the comparison table ─────────────────────────────── */}
|
|
173
|
+
<ol className="hidden border-t border-border lg:block">
|
|
174
|
+
{rows.map((row, index) => {
|
|
175
|
+
const isCheapest = row.price === cheapest;
|
|
176
|
+
|
|
177
|
+
const content = (
|
|
178
|
+
<>
|
|
179
|
+
<span className="w-5 shrink-0 text-center text-xs font-bold tabular-nums text-muted-foreground">
|
|
180
|
+
{index + 1}
|
|
181
|
+
</span>
|
|
182
|
+
|
|
183
|
+
<span className="relative h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-[repeating-linear-gradient(135deg,oklch(0.78_0.018_55)_0_8px,oklch(0.84_0.012_58)_8px_16px)]">
|
|
184
|
+
{row.place_thumbnail_url && (
|
|
185
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
186
|
+
<img
|
|
187
|
+
src={row.place_thumbnail_url}
|
|
188
|
+
alt=""
|
|
189
|
+
loading="lazy"
|
|
190
|
+
className="h-full w-full object-cover"
|
|
191
|
+
/>
|
|
192
|
+
)}
|
|
193
|
+
{row.open_now && (
|
|
194
|
+
<i
|
|
195
|
+
aria-hidden
|
|
196
|
+
className="absolute right-0.5 top-0.5 h-[6px] w-[6px] rounded-full bg-[var(--free,currentColor)] ring-2 ring-white/80"
|
|
197
|
+
/>
|
|
198
|
+
)}
|
|
199
|
+
</span>
|
|
200
|
+
|
|
201
|
+
<span className="min-w-0 w-[200px] shrink-0">
|
|
202
|
+
<span className="block truncate text-sm font-bold tracking-[-0.01em]">
|
|
203
|
+
{row.place_name}
|
|
204
|
+
</span>
|
|
205
|
+
<span className="mt-px flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
|
206
|
+
{row.rating != null && (
|
|
207
|
+
<>
|
|
208
|
+
<Star className="h-2.5 w-2.5" aria-hidden />
|
|
209
|
+
<span className="tabular-nums">{row.rating}</span>
|
|
210
|
+
</>
|
|
211
|
+
)}
|
|
212
|
+
{row.place_area && <span className="truncate">{row.place_area}</span>}
|
|
213
|
+
</span>
|
|
214
|
+
</span>
|
|
215
|
+
|
|
216
|
+
<span className="min-w-0 flex-1">
|
|
217
|
+
<span className="block text-[9.5px] font-bold uppercase tracking-[0.1em] text-muted-foreground">
|
|
218
|
+
{labels.alias}
|
|
219
|
+
</span>
|
|
220
|
+
<span className="block truncate font-serif text-[13px] italic">{row.alias}</span>
|
|
221
|
+
</span>
|
|
222
|
+
|
|
223
|
+
{row.mentions != null && row.mentions > 0 && (
|
|
224
|
+
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] tabular-nums text-muted-foreground">
|
|
225
|
+
<MessageSquare className="h-[11px] w-[11px]" aria-hidden />
|
|
226
|
+
{row.mentions}
|
|
227
|
+
</span>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
<span className="w-[92px] shrink-0 text-right">
|
|
231
|
+
<b
|
|
232
|
+
className={cn(
|
|
233
|
+
"block text-sm font-extrabold tabular-nums",
|
|
234
|
+
isCheapest && "text-[var(--free,currentColor)]"
|
|
235
|
+
)}
|
|
236
|
+
>
|
|
237
|
+
{formatPrice(row.price)}
|
|
238
|
+
</b>
|
|
239
|
+
<span className="block text-[10px] text-muted-foreground">
|
|
240
|
+
{isCheapest && labels.cheapest ? labels.cheapest : labels.perPortion}
|
|
241
|
+
</span>
|
|
242
|
+
</span>
|
|
243
|
+
</>
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const rowClasses = "flex items-center gap-4 px-5 py-3";
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<li key={row.place_slug} className="border-b border-border last:border-b-0">
|
|
250
|
+
{hrefs?.[row.place_slug] ? (
|
|
251
|
+
<LinkComponent
|
|
252
|
+
href={hrefs[row.place_slug]}
|
|
253
|
+
className={cn(
|
|
254
|
+
rowClasses,
|
|
255
|
+
"transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
256
|
+
)}
|
|
257
|
+
>
|
|
258
|
+
{content}
|
|
259
|
+
</LinkComponent>
|
|
260
|
+
) : (
|
|
261
|
+
<div className={rowClasses}>{content}</div>
|
|
262
|
+
)}
|
|
263
|
+
</li>
|
|
264
|
+
);
|
|
265
|
+
})}
|
|
266
|
+
</ol>
|
|
267
|
+
|
|
268
|
+
{(footnote || actions) && (
|
|
269
|
+
<div className="flex flex-wrap items-center gap-3 p-4">
|
|
270
|
+
{footnote && <span className="text-[11.5px] text-muted-foreground">{footnote}</span>}
|
|
271
|
+
{actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
|
|
272
|
+
</div>
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
3
|
import userEvent from "@testing-library/user-event";
|
|
4
4
|
import { Coverflow, type CoverflowItem } from "./coverflow";
|
|
5
5
|
|
|
@@ -175,6 +175,33 @@ describe("Coverflow", () => {
|
|
|
175
175
|
});
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
+
describe("the drag", () => {
|
|
179
|
+
it("steps forward on a leftward drag and back on a rightward one", () => {
|
|
180
|
+
const onActiveIndexChange = vi.fn();
|
|
181
|
+
renderCoverflow({ activeIndex: 2, onActiveIndexChange });
|
|
182
|
+
const stage = screen.getByRole("button", { name: "Event 2" }).parentElement!
|
|
183
|
+
.parentElement as HTMLElement;
|
|
184
|
+
|
|
185
|
+
fireEvent.pointerDown(stage, { clientX: 200, pointerId: 1, button: 0 });
|
|
186
|
+
fireEvent.pointerMove(stage, { clientX: 150, pointerId: 1 });
|
|
187
|
+
fireEvent.pointerMove(stage, { clientX: 100, pointerId: 1 });
|
|
188
|
+
fireEvent.pointerUp(stage, { clientX: 100, pointerId: 1 });
|
|
189
|
+
expect(onActiveIndexChange).toHaveBeenLastCalledWith(3);
|
|
190
|
+
|
|
191
|
+
fireEvent.pointerDown(stage, { clientX: 100, pointerId: 1, button: 0 });
|
|
192
|
+
fireEvent.pointerMove(stage, { clientX: 150, pointerId: 1 });
|
|
193
|
+
fireEvent.pointerMove(stage, { clientX: 200, pointerId: 1 });
|
|
194
|
+
fireEvent.pointerUp(stage, { clientX: 200, pointerId: 1 });
|
|
195
|
+
expect(onActiveIndexChange).toHaveBeenLastCalledWith(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("keeps the arrows for desktop only", () => {
|
|
199
|
+
renderCoverflow();
|
|
200
|
+
expect(screen.getByRole("button", { name: "Next" }).className).toContain("lg:flex");
|
|
201
|
+
expect(screen.getByRole("button", { name: "Next" }).className).toContain("hidden");
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
178
205
|
describe("caption and position", () => {
|
|
179
206
|
it("renders the position on its own, with no separator dot, when there is no caption", () => {
|
|
180
207
|
renderCoverflow({ positionLabel: "1 of 7" });
|
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useId, useState } from "react";
|
|
4
4
|
import { ChevronLeft, ChevronRight, Gift } from "lucide-react";
|
|
5
5
|
import { cn } from "../lib/utils";
|
|
6
|
+
import { useDragSwipe } from "../hooks/use-drag-swipe";
|
|
6
7
|
import { EventPosterFallback } from "./event-poster-fallback";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Coverflow — the busiest-events browser: a 3D perspective lane with a large
|
|
10
11
|
* lead poster flanked by up to three shrinking, desaturating posters on each
|
|
11
|
-
* side.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* side.
|
|
13
|
+
*
|
|
14
|
+
* NOTHING MOVES ON ITS OWN. The lane used to drift ±40px on a loop; it was
|
|
15
|
+
* removed at product request — a stage that sways is read as a carousel that
|
|
16
|
+
* is about to advance, and the reader waits for it instead of touching it.
|
|
17
|
+
* The stage is driven by the reader: a horizontal drag (touch, pen or mouse)
|
|
18
|
+
* steps the ring one card in the drag's direction and the lane follows the
|
|
19
|
+
* finger a little on the way, so the gesture has weight. The prev/next arrows
|
|
20
|
+
* stay for desktop and keyboard; below `lg` they are hidden so nothing sits
|
|
21
|
+
* over the lead card — the swipe is the control there, and every card is
|
|
22
|
+
* still a focusable, named button for keyboard and screen-reader users.
|
|
14
23
|
*
|
|
15
24
|
* IT IS A RING, NOT A LANE. A card's slot comes from `ringOffset` — the
|
|
16
25
|
* SIGNED SHORTEST WAY ROUND the list, not the raw `index - activeIndex`. With
|
|
@@ -178,15 +187,12 @@ const COVERFLOW_LEAD_SHADOW_CLASS = "shadow-[0_24px_56px_oklch(0.28_0.03_45/0.32
|
|
|
178
187
|
const COVERFLOW_PILL_CLASS =
|
|
179
188
|
"inline-flex h-6 max-w-[85%] items-center gap-1 rounded-full px-2 text-[11px] font-semibold";
|
|
180
189
|
|
|
181
|
-
const COVERFLOW_DRIFT_DURATION_SECONDS = 26;
|
|
182
|
-
const COVERFLOW_DRIFT_PIXELS = 40;
|
|
183
|
-
|
|
184
190
|
/** Below the lead's z-index the arrows (z-20) and edge fades (z-10) always
|
|
185
191
|
* win, no matter how many cards are mid-transition and briefly promoted. */
|
|
186
192
|
const COVERFLOW_LEAD_Z_INDEX = 6;
|
|
187
193
|
|
|
188
194
|
const COVERFLOW_ARROW_CLASS =
|
|
189
|
-
"absolute top-1/2 z-20
|
|
195
|
+
"absolute top-1/2 z-20 hidden h-11 w-11 -translate-y-1/2 lg:flex cursor-pointer items-center justify-center rounded-full bg-background/85 text-foreground shadow-[var(--shadow-overlay)] backdrop-blur-sm transition-colors duration-150 hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none";
|
|
190
196
|
|
|
191
197
|
function wrapIndex(index: number, length: number): number {
|
|
192
198
|
return ((index % length) + length) % length;
|
|
@@ -347,7 +353,6 @@ export function Coverflow({
|
|
|
347
353
|
}: CoverflowProps) {
|
|
348
354
|
const scopeId = useId().replace(/[^a-zA-Z0-9]/g, "");
|
|
349
355
|
const scopeClass = `coverflow-${scopeId}`;
|
|
350
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
351
356
|
|
|
352
357
|
const length = items.length;
|
|
353
358
|
const active = length > 0 ? wrapIndex(activeIndex, length) : 0;
|
|
@@ -372,26 +377,21 @@ export function Coverflow({
|
|
|
372
377
|
);
|
|
373
378
|
}
|
|
374
379
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
380
|
+
// No clamp at the ends: prev from 0 reports `length - 1` and next from
|
|
381
|
+
// `length - 1` reports 0, so the index the parent stores matches the ring.
|
|
382
|
+
const goTo = (index: number) => onActiveIndexChange?.(wrapIndex(index, length));
|
|
378
383
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
return () => observer.disconnect();
|
|
387
|
-
}, []);
|
|
384
|
+
// A drag to the left means "next" — the ring turns the way the finger went.
|
|
385
|
+
// The lane follows at a third of the drag distance while the finger is down,
|
|
386
|
+
// then snaps back as the step lands.
|
|
387
|
+
const { dragX, isDragging, handlers } = useDragSwipe({
|
|
388
|
+
onSwipe: (direction) => goTo(active + direction),
|
|
389
|
+
disabled: length < 2,
|
|
390
|
+
});
|
|
388
391
|
|
|
389
392
|
if (length === 0) return null;
|
|
390
393
|
|
|
391
394
|
const activeItem = items[active];
|
|
392
|
-
// No clamp at the ends: prev from 0 reports `length - 1` and next from
|
|
393
|
-
// `length - 1` reports 0, so the index the parent stores matches the ring.
|
|
394
|
-
const goTo = (index: number) => onActiveIndexChange?.(wrapIndex(index, length));
|
|
395
395
|
|
|
396
396
|
/** How far the lead itself moved, the short way round — 0 on a settled render. */
|
|
397
397
|
const activeDelta = ringOffset(step.to - step.from, length);
|
|
@@ -446,26 +446,10 @@ export function Coverflow({
|
|
|
446
446
|
--cf-tier-base: 0.5714;
|
|
447
447
|
}
|
|
448
448
|
}
|
|
449
|
-
.${scopeClass} .coverflow-lane {
|
|
450
|
-
animation: ${scopeClass}-drift ${COVERFLOW_DRIFT_DURATION_SECONDS}s ease-in-out infinite alternate;
|
|
451
|
-
}
|
|
452
|
-
.${scopeClass}[data-offscreen="true"] .coverflow-lane {
|
|
453
|
-
animation-play-state: paused;
|
|
454
|
-
}
|
|
455
|
-
@keyframes ${scopeClass}-drift {
|
|
456
|
-
from { transform: translateX(-${COVERFLOW_DRIFT_PIXELS}px); }
|
|
457
|
-
to { transform: translateX(${COVERFLOW_DRIFT_PIXELS}px); }
|
|
458
|
-
}
|
|
459
|
-
@media (prefers-reduced-motion: reduce) {
|
|
460
|
-
.${scopeClass} .coverflow-lane {
|
|
461
|
-
animation: none;
|
|
462
|
-
transform: none;
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
449
|
`}</style>
|
|
466
450
|
|
|
467
451
|
<div
|
|
468
|
-
|
|
452
|
+
{...handlers}
|
|
469
453
|
// CLIPS HORIZONTALLY ONLY. The clip is what hides a card teleporting
|
|
470
454
|
// round the back of the ring, and that only ever happens off the left
|
|
471
455
|
// and right edges — but `overflow-hidden` clips both axes, so it was
|
|
@@ -477,7 +461,11 @@ export function Coverflow({
|
|
|
477
461
|
// container), so `overflow-x-hidden overflow-y-visible` is not a thing
|
|
478
462
|
// the cascade will give you. `clip` carries no such rule, so the pair
|
|
479
463
|
// below means what it says.
|
|
480
|
-
|
|
464
|
+
//
|
|
465
|
+
// `touch-action: pan-y` hands vertical gestures to the browser and
|
|
466
|
+
// horizontal ones to the drag handlers; `select-none` stops a mouse
|
|
467
|
+
// drag from selecting the captions underneath.
|
|
468
|
+
className="relative flex h-[300px] select-none items-center justify-center overflow-x-clip overflow-y-visible [touch-action:pan-y] [perspective:900px] sm:h-[380px] sm:[perspective:1400px]"
|
|
481
469
|
>
|
|
482
470
|
<div
|
|
483
471
|
aria-hidden="true"
|
|
@@ -511,7 +499,13 @@ export function Coverflow({
|
|
|
511
499
|
</>
|
|
512
500
|
)}
|
|
513
501
|
|
|
514
|
-
<div
|
|
502
|
+
<div
|
|
503
|
+
className={cn(
|
|
504
|
+
"coverflow-lane relative h-full w-full [transform-style:preserve-3d]",
|
|
505
|
+
!isDragging && "transition-transform duration-300 ease-out motion-reduce:transition-none"
|
|
506
|
+
)}
|
|
507
|
+
style={{ transform: `translateX(${dragX / 3}px)` }}
|
|
508
|
+
>
|
|
515
509
|
{visibleCards.map(({ item, index, offset, suppressTransition }) => (
|
|
516
510
|
<CoverflowCard
|
|
517
511
|
key={item.id}
|