@bearmenu/ui 0.8.9 → 0.8.11
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-24RFI4RQ.js +167 -0
- package/dist/{chunk-UKBNQO6Q.js → chunk-2CXU5WZO.js} +1 -1
- package/dist/chunk-2XCZJWTV.js +121 -0
- package/dist/{chunk-62ES6N32.js → chunk-43RC7MHY.js} +1 -1
- package/dist/chunk-5RWTOT5Y.js +72 -0
- package/dist/{chunk-Q6QH52LJ.js → chunk-6TEEY5YA.js} +2 -2
- package/dist/{chunk-AXV5O2IZ.js → chunk-AKQ57NXY.js} +3 -3
- package/dist/chunk-CLLY6XB3.js +41 -0
- package/dist/chunk-DTXNDLSE.js +125 -0
- package/dist/chunk-F6FMZJDG.js +113 -0
- package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
- package/dist/chunk-H3ZZDX5L.js +61 -0
- package/dist/{chunk-H3BV4VAG.js → chunk-JS3EINZD.js} +2 -2
- package/dist/chunk-MZL7CGCL.js +109 -0
- package/dist/{chunk-TH6W2PWG.js → chunk-UDTG3RSE.js} +1 -1
- package/dist/chunk-WCTHZKU7.js +37 -0
- package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
- package/dist/components/alert-dialog.js +1 -1
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/avatar.d.ts +3 -3
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.js +1 -1
- package/dist/components/card.d.ts +4 -4
- package/dist/components/carousel.js +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/command.d.ts +2 -2
- package/dist/components/concept-compare-rail.d.ts +50 -0
- package/dist/components/concept-compare-rail.js +5 -0
- package/dist/components/concept-compare.d.ts +75 -38
- package/dist/components/concept-compare.js +209 -133
- package/dist/components/concept-convergence.d.ts +41 -0
- package/dist/components/concept-convergence.js +53 -0
- package/dist/components/convergence.d.ts +25 -0
- package/dist/components/convergence.js +4 -0
- package/dist/components/dialog.d.ts +2 -2
- package/dist/components/empty-state.js +1 -1
- package/dist/components/event-card.js +1 -1
- package/dist/components/event-compact-row.js +1 -1
- package/dist/components/event-detail-panel.js +2 -2
- package/dist/components/event-index-row.js +1 -1
- package/dist/components/event-list-row-wide.js +2 -2
- package/dist/components/event-list-row.js +2 -2
- package/dist/components/explainer-steps.d.ts +34 -0
- package/dist/components/explainer-steps.js +36 -0
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/glass-ground.d.ts +61 -0
- package/dist/components/glass-ground.js +3 -0
- package/dist/components/haptic-button.js +1 -1
- package/dist/components/hub-surface.js +11 -1
- package/dist/components/hue-chip.d.ts +70 -0
- package/dist/components/hue-chip.js +3 -0
- package/dist/components/input-group.d.ts +2 -2
- package/dist/components/input.d.ts +1 -1
- package/dist/components/item.d.ts +4 -4
- package/dist/components/kbd.d.ts +2 -2
- package/dist/components/kitchen-dossier.d.ts +88 -0
- package/dist/components/kitchen-dossier.js +186 -0
- package/dist/components/link-button.js +1 -1
- package/dist/components/menu-dead-end.d.ts +56 -0
- package/dist/components/menu-dead-end.js +49 -0
- package/dist/components/menu-family-block.d.ts +98 -0
- package/dist/components/menu-family-block.js +153 -0
- package/dist/components/menu-family-card.d.ts +33 -27
- package/dist/components/menu-family-card.js +41 -85
- package/dist/components/menu-field.js +92 -2
- package/dist/components/menu-filter-chips.d.ts +86 -0
- package/dist/components/menu-filter-chips.js +122 -0
- package/dist/components/menu-group-rows.d.ts +53 -0
- package/dist/components/menu-group-rows.js +67 -0
- package/dist/components/menu-hub-hero.js +10 -5
- package/dist/components/menu-index-header.d.ts +37 -0
- package/dist/components/menu-index-header.js +68 -0
- package/dist/components/menu-index-list.d.ts +48 -0
- package/dist/components/menu-index-list.js +96 -0
- package/dist/components/menu-intent-hero.d.ts +69 -0
- package/dist/components/menu-intent-hero.js +87 -0
- package/dist/components/menu-item.js +3 -3
- package/dist/components/menu-kitchen-list.d.ts +82 -0
- package/dist/components/menu-kitchen-list.js +5 -0
- package/dist/components/menu-mocks.d.ts +37 -2
- package/dist/components/menu-mocks.js +152 -27
- package/dist/components/menu-types.d.ts +182 -6
- package/dist/components/motion-icon-controls.js +2 -2
- package/dist/components/motion-icon-set.js +6 -6
- package/dist/components/motion-icon.d.ts +22 -1
- package/dist/components/motion-icon.js +1 -1
- package/dist/components/multi-select-combobox.js +2 -2
- package/dist/components/native-select.d.ts +2 -2
- package/dist/components/pagination.js +1 -1
- package/dist/components/photo-ground.d.ts +64 -0
- package/dist/components/photo-ground.js +3 -0
- package/dist/components/place-about-tab.js +2 -2
- package/dist/components/place-details-mobile.js +3 -3
- package/dist/components/place-types.d.ts +2 -2
- package/dist/components/qa-block.d.ts +36 -0
- package/dist/components/qa-block.js +24 -0
- package/dist/components/scroll-fade.js +3 -121
- package/dist/components/searchable-select.js +1 -1
- package/dist/components/select.d.ts +1 -1
- package/dist/components/signature-chip.d.ts +2 -2
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/sliding-panels.js +1 -1
- package/dist/components/snap-rail.d.ts +26 -3
- package/dist/components/snap-rail.js +3 -109
- package/dist/components/spinner.d.ts +4 -4
- package/dist/components/story.d.ts +34 -0
- package/dist/components/story.js +3 -0
- package/dist/components/tag.d.ts +4 -4
- package/dist/components/text.d.ts +5 -5
- package/dist/components/toggle.d.ts +1 -1
- package/dist/components/top-comparison.d.ts +28 -0
- package/dist/components/top-comparison.js +76 -0
- package/dist/components/top-cross-link.d.ts +34 -0
- package/dist/components/top-cross-link.js +101 -0
- package/dist/components/top-ranking-entry.d.ts +53 -0
- package/dist/components/top-ranking-entry.js +217 -0
- package/dist/components/top-ranking-hero.d.ts +61 -0
- package/dist/components/top-ranking-hero.js +99 -0
- package/dist/components/top-ranking-legend.d.ts +69 -0
- package/dist/components/top-ranking-legend.js +124 -0
- package/dist/components/top-types.d.ts +109 -0
- package/dist/components/top-types.js +1 -0
- package/dist/components/venue-grade-dimensions.d.ts +3 -1
- package/dist/components/venue-grade-dimensions.js +1 -1
- package/dist/components/venue-quotes.d.ts +1 -1
- package/dist/components/venue-quotes.js +1 -1
- package/dist/components/venue-wall.js +5 -3
- package/dist/components/weekday-theme-banner.js +1 -1
- package/dist/components/weekend-panel.js +2 -2
- package/package.json +1 -1
- package/src/components/badge.tsx +1 -1
- package/src/components/button.tsx +2 -2
- package/src/components/concept-compare-rail.tsx +132 -0
- package/src/components/concept-compare.stories.tsx +122 -54
- package/src/components/concept-compare.tsx +368 -196
- package/src/components/concept-convergence.stories.tsx +53 -0
- package/src/components/concept-convergence.tsx +90 -0
- package/src/components/convergence.stories.tsx +54 -0
- package/src/components/convergence.tsx +177 -0
- package/src/components/explainer-steps.stories.tsx +46 -0
- package/src/components/explainer-steps.tsx +74 -0
- package/src/components/glass-ground.tsx +104 -0
- package/src/components/hub-surface.tsx +11 -1
- package/src/components/hue-chip.stories.tsx +76 -0
- package/src/components/hue-chip.test.tsx +62 -0
- package/src/components/hue-chip.tsx +99 -0
- package/src/components/kitchen-dossier.stories.tsx +108 -0
- package/src/components/kitchen-dossier.tsx +321 -0
- package/src/components/menu-dead-end.stories.tsx +67 -0
- package/src/components/menu-dead-end.tsx +127 -0
- package/src/components/menu-family-block.stories.tsx +91 -0
- package/src/components/menu-family-block.tsx +303 -0
- package/src/components/menu-family-card.stories.tsx +43 -35
- package/src/components/menu-family-card.tsx +115 -142
- package/src/components/menu-filter-chips.stories.tsx +107 -0
- package/src/components/menu-filter-chips.tsx +238 -0
- package/src/components/menu-group-rows.stories.tsx +54 -0
- package/src/components/menu-group-rows.tsx +133 -0
- package/src/components/menu-hub-hero.test.tsx +39 -0
- package/src/components/menu-hub-hero.tsx +17 -5
- package/src/components/menu-index-header.stories.tsx +72 -0
- package/src/components/menu-index-header.tsx +125 -0
- package/src/components/menu-index-list.stories.tsx +50 -0
- package/src/components/menu-index-list.tsx +149 -0
- package/src/components/menu-intent-hero.stories.tsx +67 -0
- package/src/components/menu-intent-hero.tsx +189 -0
- package/src/components/menu-kitchen-list.stories.tsx +67 -0
- package/src/components/menu-kitchen-list.test.tsx +78 -0
- package/src/components/menu-kitchen-list.tsx +284 -0
- package/src/components/menu-mocks.ts +198 -23
- package/src/components/menu-types.ts +189 -6
- package/src/components/motion-icon-controls.tsx +11 -11
- package/src/components/motion-icon-set.tsx +6 -5
- package/src/components/motion-icon.test.tsx +23 -1
- package/src/components/motion-icon.tsx +20 -0
- package/src/components/photo-ground.tsx +147 -0
- package/src/components/place-types.ts +2 -2
- package/src/components/qa-block.stories.tsx +43 -0
- package/src/components/qa-block.tsx +60 -0
- package/src/components/signature-chip.tsx +3 -3
- package/src/components/snap-rail.tsx +58 -15
- package/src/components/story.tsx +225 -0
- package/src/components/top-comparison.stories.tsx +78 -0
- package/src/components/top-comparison.tsx +151 -0
- package/src/components/top-cross-link.stories.tsx +84 -0
- package/src/components/top-cross-link.tsx +171 -0
- package/src/components/top-ranking-entry.stories.tsx +171 -0
- package/src/components/top-ranking-entry.test.tsx +105 -0
- package/src/components/top-ranking-entry.tsx +425 -0
- package/src/components/top-ranking-hero.stories.tsx +76 -0
- package/src/components/top-ranking-hero.tsx +218 -0
- package/src/components/top-ranking-legend.stories.tsx +78 -0
- package/src/components/top-ranking-legend.tsx +239 -0
- package/src/components/top-types.ts +110 -0
- package/src/components/venue-grade-dimensions.tsx +2 -2
- package/src/components/venue-quotes.stories.tsx +1 -1
- package/src/components/venue-quotes.tsx +4 -2
- package/src/components/venue-wall.tsx +9 -4
- package/src/components/weekday-theme-banner.tsx +1 -1
- package/dist/chunk-THOVPZNN.js +0 -37
- package/dist/chunk-YS6BHKZO.js +0 -92
- package/dist/components/ranked-venue-list.d.ts +0 -49
- package/dist/components/ranked-venue-list.js +0 -87
- package/dist/components/signature-line-list.d.ts +0 -45
- package/dist/components/signature-line-list.js +0 -99
- package/dist/components/venue-busyness-strip.d.ts +0 -35
- package/dist/components/venue-busyness-strip.js +0 -37
- package/dist/components/venue-signature-block.d.ts +0 -39
- package/dist/components/venue-signature-block.js +0 -6
- package/dist/components/venue-spotlight.d.ts +0 -74
- package/dist/components/venue-spotlight.js +0 -138
- package/src/components/ranked-venue-list.stories.tsx +0 -80
- package/src/components/ranked-venue-list.tsx +0 -177
- package/src/components/signature-line-list.stories.tsx +0 -85
- package/src/components/signature-line-list.tsx +0 -136
- package/src/components/venue-busyness-strip.stories.tsx +0 -66
- package/src/components/venue-busyness-strip.tsx +0 -64
- package/src/components/venue-signature-block.stories.tsx +0 -68
- package/src/components/venue-signature-block.tsx +0 -71
- package/src/components/venue-spotlight.stories.tsx +0 -141
- package/src/components/venue-spotlight.tsx +0 -236
- package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { ScrollFade } from "./scroll-fade";
|
|
4
|
+
import { Skeleton } from "./skeleton";
|
|
5
|
+
import type { MenuFilterChip } from "./menu-types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* MenuFilterChips — the intent page's filter row.
|
|
9
|
+
*
|
|
10
|
+
* ## One unit, one list that moves
|
|
11
|
+
*
|
|
12
|
+
* Every chip counts KITCHENS, whatever facet is behind it. A dietary chip reads
|
|
13
|
+
* "has a vegetarian dish" and counts venues; a dish-group chip reads "has a
|
|
14
|
+
* pizza" and counts venues. The earlier version mixed venue counts and dish
|
|
15
|
+
* counts in one row and became unreadable — you could not tell what a number
|
|
16
|
+
* was counting without reading the label that produced it. The unit is stated
|
|
17
|
+
* once, at the head of the row (`unitLabel`, e.g. "KITCHENS · 247"), and never
|
|
18
|
+
* again.
|
|
19
|
+
*
|
|
20
|
+
* ## A zero-count chip is dimmed, not disabled
|
|
21
|
+
*
|
|
22
|
+
* `empty` chips stay tappable. Pressing one swaps it in and drops whichever
|
|
23
|
+
* active filter conflicts with it, and the caller says which in a line beneath.
|
|
24
|
+
* Disabling it would refuse the reader the thing they asked for and give no
|
|
25
|
+
* reason; this way the row answers the question by being pressed. Facets the
|
|
26
|
+
* API says are unavailable for this intent are not passed in at all — not
|
|
27
|
+
* drawn, rather than drawn dead.
|
|
28
|
+
*
|
|
29
|
+
* ## The row scrolls; the escape hatch does not
|
|
30
|
+
*
|
|
31
|
+
* One line in every state. Chips scroll horizontally under a `<ScrollFade>`,
|
|
32
|
+
* and "More filters" is pinned opposite the unit label rather than wrapping,
|
|
33
|
+
* so the way out of a full row is always in the same place.
|
|
34
|
+
*
|
|
35
|
+
* Interactive: the consumer marks its own module `"use client"`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export type MenuFilterChipsProps = {
|
|
39
|
+
chips: MenuFilterChip[];
|
|
40
|
+
onToggle: (key: string) => void;
|
|
41
|
+
/** The unit and the current total, composed by the caller, e.g. "KITCHENS · 247". */
|
|
42
|
+
unitLabel?: string;
|
|
43
|
+
/** Opens the full filter sheet. Omit for a row where every facet is a chip. */
|
|
44
|
+
onMore?: () => void;
|
|
45
|
+
moreLabel?: string;
|
|
46
|
+
/**
|
|
47
|
+
* A glyph for the phone's icon-only "more filters" button.
|
|
48
|
+
*
|
|
49
|
+
* The label is the widest fixed element in the row — "More filters" is ~80px
|
|
50
|
+
* of a 358px viewport, and it is the one thing that never scrolls, so it eats
|
|
51
|
+
* the chips' runway on every phone. Given an icon, the button collapses to a
|
|
52
|
+
* square below `lg` and keeps `moreLabel` as its accessible name.
|
|
53
|
+
*
|
|
54
|
+
* A node rather than a name because the app owns its iconography, and this
|
|
55
|
+
* one is animated on press — the DS neither knows the glyph nor drives it.
|
|
56
|
+
*/
|
|
57
|
+
moreIcon?: React.ReactNode;
|
|
58
|
+
/** Press handler for the icon button, e.g. to replay the glyph's animation. */
|
|
59
|
+
onMorePointerDown?: () => void;
|
|
60
|
+
/** Locale-aware integer formatter for the counts. Defaults to `String`. */
|
|
61
|
+
formatCount?: (value: number) => string;
|
|
62
|
+
/** Accessible name for the row. */
|
|
63
|
+
ariaLabel?: string;
|
|
64
|
+
className?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function MenuFilterChips({
|
|
68
|
+
chips,
|
|
69
|
+
onToggle,
|
|
70
|
+
unitLabel,
|
|
71
|
+
onMore,
|
|
72
|
+
moreLabel,
|
|
73
|
+
moreIcon,
|
|
74
|
+
onMorePointerDown,
|
|
75
|
+
formatCount = String,
|
|
76
|
+
ariaLabel,
|
|
77
|
+
className,
|
|
78
|
+
}: MenuFilterChipsProps) {
|
|
79
|
+
return (
|
|
80
|
+
<div className={cn("relative flex items-center gap-2", className)}>
|
|
81
|
+
{/* Desktop only. It states a unit and a total that the section heading
|
|
82
|
+
directly below already gives in full ("The kitchens Cluj-Napoca rates
|
|
83
|
+
for Italian · 247"), and on a phone it is a fixed 110px bitten out of
|
|
84
|
+
the one row that needs every pixel to scroll. */}
|
|
85
|
+
{unitLabel && (
|
|
86
|
+
<span className="hidden shrink-0 pr-0.5 text-[11px] font-extrabold uppercase tracking-[0.1em] text-muted-foreground lg:inline">
|
|
87
|
+
{unitLabel}
|
|
88
|
+
</span>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
<ScrollFade
|
|
92
|
+
orientation="horizontal"
|
|
93
|
+
size="sm"
|
|
94
|
+
className="min-w-0 flex-1"
|
|
95
|
+
scrollClassName="scrollbar-hide"
|
|
96
|
+
>
|
|
97
|
+
<div className="flex items-center gap-2" role="group" aria-label={ariaLabel}>
|
|
98
|
+
{chips.map((chip) => (
|
|
99
|
+
<Chip
|
|
100
|
+
key={chip.key}
|
|
101
|
+
chip={chip}
|
|
102
|
+
onToggle={() => onToggle(chip.key)}
|
|
103
|
+
formatCount={formatCount}
|
|
104
|
+
/>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
</ScrollFade>
|
|
108
|
+
|
|
109
|
+
{onMore && (
|
|
110
|
+
<>
|
|
111
|
+
{/* One control, two shapes. On a phone it is a 40px square carrying
|
|
112
|
+
the caller's glyph, with `moreLabel` as its accessible name; from
|
|
113
|
+
`lg` it is the text link it has always been. Two elements rather
|
|
114
|
+
than one so neither has to carry the other's padding, and both
|
|
115
|
+
stay `shrink-0` against the scrolling chips. */}
|
|
116
|
+
{moreIcon && (
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={onMore}
|
|
120
|
+
onPointerDown={onMorePointerDown}
|
|
121
|
+
aria-label={moreLabel}
|
|
122
|
+
aria-haspopup="dialog"
|
|
123
|
+
className="inline-flex size-10 shrink-0 items-center justify-center rounded-full border border-border text-accent transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none lg:hidden"
|
|
124
|
+
>
|
|
125
|
+
{moreIcon}
|
|
126
|
+
</button>
|
|
127
|
+
)}
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
onClick={onMore}
|
|
131
|
+
className={cn(
|
|
132
|
+
"shrink-0 rounded-md pl-2 text-[12.5px] font-bold text-accent transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
|
|
133
|
+
moreIcon && "hidden lg:inline"
|
|
134
|
+
)}
|
|
135
|
+
>
|
|
136
|
+
{moreLabel}
|
|
137
|
+
</button>
|
|
138
|
+
</>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function Chip({
|
|
145
|
+
chip,
|
|
146
|
+
onToggle,
|
|
147
|
+
formatCount,
|
|
148
|
+
}: {
|
|
149
|
+
chip: MenuFilterChip;
|
|
150
|
+
onToggle: () => void;
|
|
151
|
+
formatCount: (value: number) => string;
|
|
152
|
+
}) {
|
|
153
|
+
return (
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
onClick={onToggle}
|
|
157
|
+
aria-pressed={chip.active}
|
|
158
|
+
className={cn(
|
|
159
|
+
"inline-flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border px-4 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
|
|
160
|
+
chip.active
|
|
161
|
+
? "border-accent font-semibold text-accent shadow-[0_1px_2px_oklch(0.51_0.095_45_/_0.08)]"
|
|
162
|
+
: "border-border bg-card text-foreground hover:border-accent/40",
|
|
163
|
+
// Dimmed, never disabled — pressing it is how the reader finds out
|
|
164
|
+
// which active filter is in the way.
|
|
165
|
+
chip.empty && "opacity-45"
|
|
166
|
+
)}
|
|
167
|
+
>
|
|
168
|
+
{chip.icon}
|
|
169
|
+
{chip.label}
|
|
170
|
+
{chip.count !== undefined && (
|
|
171
|
+
<span className={cn("tabular-nums", chip.active ? "text-accent" : "text-muted-foreground")}>
|
|
172
|
+
{formatCount(chip.count)}
|
|
173
|
+
</span>
|
|
174
|
+
)}
|
|
175
|
+
{chip.active && <X className="h-3 w-3 shrink-0" aria-hidden />}
|
|
176
|
+
</button>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* MenuFilterSummary — what the active filters did, and the way out.
|
|
182
|
+
*
|
|
183
|
+
* Sits under the row whenever anything is on. The sentence is composed by the
|
|
184
|
+
* caller because it is a real sentence in two languages ("Three filters · 34 of
|
|
185
|
+
* 247 kitchens are mid-range, have a vegetarian Italian dish, and are open
|
|
186
|
+
* now") and assembling it from fragments here would produce translated
|
|
187
|
+
* word-salad in one of them.
|
|
188
|
+
*/
|
|
189
|
+
export type MenuFilterSummaryProps = {
|
|
190
|
+
/** The composed sentence. */
|
|
191
|
+
summary: React.ReactNode;
|
|
192
|
+
clearLabel: string;
|
|
193
|
+
onClear: () => void;
|
|
194
|
+
className?: string;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export function MenuFilterSummary({
|
|
198
|
+
summary,
|
|
199
|
+
clearLabel,
|
|
200
|
+
onClear,
|
|
201
|
+
className,
|
|
202
|
+
}: MenuFilterSummaryProps) {
|
|
203
|
+
return (
|
|
204
|
+
<div className={cn("flex flex-wrap items-center gap-2.5 pt-3.5", className)}>
|
|
205
|
+
<span className="text-[13px] text-muted-foreground [&_b]:font-bold [&_b]:text-foreground">
|
|
206
|
+
{summary}
|
|
207
|
+
</span>
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
onClick={onClear}
|
|
211
|
+
className="ml-auto shrink-0 rounded-md text-[12.5px] font-bold text-accent transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
|
|
212
|
+
>
|
|
213
|
+
{clearLabel}
|
|
214
|
+
</button>
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── Skeleton ───────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* One line, as the row itself renders — never a wrapped block.
|
|
223
|
+
*
|
|
224
|
+
* The widths vary deliberately: six identical pills read as a progress bar, and
|
|
225
|
+
* the real row is a set of labels of different lengths.
|
|
226
|
+
*/
|
|
227
|
+
const CHIP_WIDTHS = [128, 104, 96, 148, 112, 88];
|
|
228
|
+
|
|
229
|
+
export function MenuFilterChipsSkeleton({ className }: { className?: string }) {
|
|
230
|
+
return (
|
|
231
|
+
<div className={cn("flex items-center gap-2 overflow-hidden", className)} aria-hidden>
|
|
232
|
+
<Skeleton className="h-3 w-[92px] shrink-0" />
|
|
233
|
+
{CHIP_WIDTHS.map((width, index) => (
|
|
234
|
+
<Skeleton key={index} className="h-9 shrink-0 rounded-full" style={{ width }} />
|
|
235
|
+
))}
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { MenuGroupRows } from "./menu-group-rows";
|
|
3
|
+
import { mockGradients } from "./menu-mocks";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof MenuGroupRows> = {
|
|
6
|
+
title: "Menu/MenuGroupRows",
|
|
7
|
+
component: MenuGroupRows,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
decorators: [
|
|
10
|
+
(Story) => (
|
|
11
|
+
<div className="w-[380px] bg-background p-4">
|
|
12
|
+
<Story />
|
|
13
|
+
</div>
|
|
14
|
+
),
|
|
15
|
+
],
|
|
16
|
+
parameters: {
|
|
17
|
+
docs: {
|
|
18
|
+
description: {
|
|
19
|
+
component:
|
|
20
|
+
"What an intent consists of — the `dish_groups` facet on a cuisine page, digest tags and graded strengths on a going-out page. One component for both, because both are the same object: a facet value, its venue count and a range. The counts are VENUES: no endpoint counts dishes per cuisine, so *120* is a hundred and twenty kitchens that make a pizza, and the `meta` line is composed by the caller so it can say which.",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj<typeof MenuGroupRows>;
|
|
28
|
+
|
|
29
|
+
export const Cuisine: Story = {
|
|
30
|
+
args: {
|
|
31
|
+
rows: [
|
|
32
|
+
{ slug: "pizza", name: "Pizza", meta: "120 kitchens", rangeLabel: "30—68", href: "#", gradient: mockGradients.pasta },
|
|
33
|
+
{ slug: "pasta", name: "Pasta", meta: "96 kitchens", rangeLabel: "38—84", href: "#", gradient: mockGradients.pasta },
|
|
34
|
+
{ slug: "salad", name: "Salad", meta: "88 kitchens", rangeLabel: "22—56", href: "#", gradient: mockGradients.greens },
|
|
35
|
+
{ slug: "beef", name: "Beef", meta: "77 kitchens", rangeLabel: "34—72", href: "#", gradient: mockGradients.meat },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** The going-out side: tags and strengths, and no price range behind them. */
|
|
41
|
+
export const GoingOut: Story = {
|
|
42
|
+
args: {
|
|
43
|
+
rows: [
|
|
44
|
+
{ slug: "cocktails", name: "Cocktails", meta: "63 places", href: "#", gradient: mockGradients.cocktail },
|
|
45
|
+
{ slug: "late_night", name: "Open late", meta: "59 places", href: "#", gradient: mockGradients.meat },
|
|
46
|
+
{ slug: "drinks_quality", name: "Strong on drinks", meta: "131 places", href: "#", gradient: mockGradients.sweet },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** No gradient and no range — the row still reads. */
|
|
52
|
+
export const Bare: Story = {
|
|
53
|
+
args: { rows: [{ slug: "soup", name: "Soup", meta: "169 kitchens" }] },
|
|
54
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { ChevronRight } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { Skeleton } from "./skeleton";
|
|
4
|
+
import type { MenuLinkComponent, MenuLinkProps } from "./menu-types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* MenuGroupRows — what an intent consists of.
|
|
8
|
+
*
|
|
9
|
+
* On a cuisine page this is the `dish_groups` facet narrowed by the cuisine
|
|
10
|
+
* ("Pizza · 120 kitchens · 30–68"); on a going-out page it is the digest tags
|
|
11
|
+
* and graded strengths ("Cocktails · 63 places"). One component for both,
|
|
12
|
+
* because both are the same object: a facet value, its venue count, and a range.
|
|
13
|
+
*
|
|
14
|
+
* ## The counts are VENUES, and the label has to say so
|
|
15
|
+
*
|
|
16
|
+
* There is no endpoint that counts dishes per cuisine — `/dishes` accepts
|
|
17
|
+
* `cuisine_slugs` and ignores it — so "120" here is a hundred and twenty
|
|
18
|
+
* kitchens that make a pizza, not a hundred and twenty pizzas. The `meta` line
|
|
19
|
+
* is composed by the caller precisely so that it can say which, in a language
|
|
20
|
+
* where the two words differ.
|
|
21
|
+
*
|
|
22
|
+
* ## Not `<ConceptCard>`
|
|
23
|
+
*
|
|
24
|
+
* That one is a cover-led card for a thing with a date and a venue. This is a
|
|
25
|
+
* row in a sidebar: a hue bar, a name, a count and a range. Giving it a cover
|
|
26
|
+
* would mean finding a photograph of "pasta", which is the kind of picture that
|
|
27
|
+
* makes a page look generated.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** @deprecated Alias of `MenuLinkProps`; kept for symmetry with the other menu surfaces. */
|
|
31
|
+
export type MenuGroupRowsLinkProps = MenuLinkProps;
|
|
32
|
+
|
|
33
|
+
function DefaultLink({ href, ...props }: MenuGroupRowsLinkProps) {
|
|
34
|
+
return <a href={href} {...props} />;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type MenuGroupRow = {
|
|
38
|
+
slug: string;
|
|
39
|
+
name: string;
|
|
40
|
+
/** Venue count line, e.g. "120 kitchens". Pluralised by the caller. */
|
|
41
|
+
meta: string;
|
|
42
|
+
/** Price range, already formatted, e.g. "30—68". Omitted where none is known. */
|
|
43
|
+
rangeLabel?: string;
|
|
44
|
+
href?: string;
|
|
45
|
+
/** Finished CSS gradient for the bar. A missing one falls back to the border colour. */
|
|
46
|
+
gradient?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type MenuGroupRowsProps = {
|
|
50
|
+
rows: MenuGroupRow[];
|
|
51
|
+
linkComponent?: MenuLinkComponent;
|
|
52
|
+
className?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function MenuGroupRows({
|
|
56
|
+
rows,
|
|
57
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
58
|
+
className,
|
|
59
|
+
}: MenuGroupRowsProps) {
|
|
60
|
+
return (
|
|
61
|
+
<ul className={cn("flex flex-col", className)}>
|
|
62
|
+
{rows.map((row) => {
|
|
63
|
+
const body = (
|
|
64
|
+
<>
|
|
65
|
+
<span
|
|
66
|
+
aria-hidden
|
|
67
|
+
className={cn(
|
|
68
|
+
"h-[34px] w-2 shrink-0 rounded-full",
|
|
69
|
+
!row.gradient && "bg-border"
|
|
70
|
+
)}
|
|
71
|
+
style={row.gradient ? { backgroundImage: row.gradient } : undefined}
|
|
72
|
+
/>
|
|
73
|
+
<span className="min-w-0 flex-1">
|
|
74
|
+
<span className="block truncate text-sm font-bold">{row.name}</span>
|
|
75
|
+
<span className="mt-px block truncate text-[11.5px] text-muted-foreground">
|
|
76
|
+
{row.meta}
|
|
77
|
+
</span>
|
|
78
|
+
</span>
|
|
79
|
+
{row.rangeLabel && (
|
|
80
|
+
<span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
|
|
81
|
+
{row.rangeLabel}
|
|
82
|
+
</span>
|
|
83
|
+
)}
|
|
84
|
+
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
|
85
|
+
</>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<li key={row.slug} className="border-t border-border first:border-t-0">
|
|
90
|
+
{row.href ? (
|
|
91
|
+
<LinkComponent
|
|
92
|
+
href={row.href}
|
|
93
|
+
className="flex items-center gap-3 py-3 transition-colors hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none"
|
|
94
|
+
>
|
|
95
|
+
{body}
|
|
96
|
+
</LinkComponent>
|
|
97
|
+
) : (
|
|
98
|
+
<span className="flex items-center gap-3 py-3">{body}</span>
|
|
99
|
+
)}
|
|
100
|
+
</li>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
</ul>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Skeleton ───────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
export function MenuGroupRowsSkeleton({
|
|
110
|
+
rows = 5,
|
|
111
|
+
className,
|
|
112
|
+
}: {
|
|
113
|
+
rows?: number;
|
|
114
|
+
className?: string;
|
|
115
|
+
}) {
|
|
116
|
+
return (
|
|
117
|
+
<div className={cn("flex flex-col", className)} aria-hidden>
|
|
118
|
+
{Array.from({ length: rows }, (_, index) => (
|
|
119
|
+
<div
|
|
120
|
+
key={index}
|
|
121
|
+
className="flex items-center gap-3 border-t border-border py-3 first:border-t-0"
|
|
122
|
+
>
|
|
123
|
+
<Skeleton className="h-[34px] w-2 rounded-full" />
|
|
124
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
125
|
+
<Skeleton className="h-3.5 w-[50%]" />
|
|
126
|
+
<Skeleton className="h-3 w-[35%]" />
|
|
127
|
+
</div>
|
|
128
|
+
<Skeleton className="h-3 w-[52px]" />
|
|
129
|
+
</div>
|
|
130
|
+
))}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render } from "@testing-library/react";
|
|
3
|
+
import { MenuHubHero } from "./menu-hub-hero";
|
|
4
|
+
import { VenueWall } from "./venue-wall";
|
|
5
|
+
|
|
6
|
+
const tiles = Array.from({ length: 12 }, (_, i) => `/tiles/${i}.webp`);
|
|
7
|
+
const mosaicImages = () => Array.from(document.querySelectorAll("img[loading='lazy']"));
|
|
8
|
+
|
|
9
|
+
// The phone shows six tiles; the rest are `hidden` below `lg` so a lazy
|
|
10
|
+
// still past the sixth is never requested. This is the request-count win
|
|
11
|
+
// measured on the consumer's menu page — pinned so a cell-count edit cannot
|
|
12
|
+
// silently put all twelve stills back on the phone's critical path.
|
|
13
|
+
describe("MenuHubHero mosaic", () => {
|
|
14
|
+
it("shows the first six tiles on the phone and hides the rest until lg", () => {
|
|
15
|
+
render(<MenuHubHero headline="Every kitchen" tileUrls={tiles} />);
|
|
16
|
+
const images = mosaicImages();
|
|
17
|
+
expect(images).toHaveLength(28);
|
|
18
|
+
images.slice(0, 6).forEach((img) => expect(img).not.toHaveClass("hidden"));
|
|
19
|
+
images.slice(6).forEach((img) => expect(img).toHaveClass("hidden", "lg:block"));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("cycles the tile list so the phone's six are the first six given", () => {
|
|
23
|
+
render(<MenuHubHero headline="Every kitchen" tileUrls={tiles.slice(0, 4)} />);
|
|
24
|
+
const srcs = mosaicImages()
|
|
25
|
+
.slice(0, 6)
|
|
26
|
+
.map((img) => img.getAttribute("src"));
|
|
27
|
+
expect(srcs).toEqual(["/tiles/0.webp", "/tiles/1.webp", "/tiles/2.webp", "/tiles/3.webp", "/tiles/0.webp", "/tiles/1.webp"]);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("VenueWall mosaic", () => {
|
|
32
|
+
it("follows the hero's rule: six on the phone, eighteen from lg", () => {
|
|
33
|
+
render(<VenueWall places={[]} headline="Every menu" tileUrls={tiles} />);
|
|
34
|
+
const images = mosaicImages();
|
|
35
|
+
expect(images).toHaveLength(18);
|
|
36
|
+
images.slice(0, 6).forEach((img) => expect(img).not.toHaveClass("hidden"));
|
|
37
|
+
images.slice(6).forEach((img) => expect(img).toHaveClass("hidden", "lg:block"));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -65,6 +65,13 @@ export type MenuHubHeroProps = {
|
|
|
65
65
|
|
|
66
66
|
/** Enough cells to fill the widest hero without the grid reading as a pattern. */
|
|
67
67
|
const TEXTURE_CELLS = 28;
|
|
68
|
+
/**
|
|
69
|
+
* The phone shows the first six, three across. Twenty-eight cells four across
|
|
70
|
+
* on a 375px hero were 90px tiles under a 90% scrim — a texture nobody could
|
|
71
|
+
* read as a room — and every one of the twelve stills downloaded to draw it.
|
|
72
|
+
* Six at ~125px are rooms again and cost six requests.
|
|
73
|
+
*/
|
|
74
|
+
const PHONE_CELLS = 6;
|
|
68
75
|
|
|
69
76
|
export function MenuHubHero({
|
|
70
77
|
kicker,
|
|
@@ -97,10 +104,12 @@ export function MenuHubHero({
|
|
|
97
104
|
) : (
|
|
98
105
|
<div
|
|
99
106
|
aria-hidden
|
|
100
|
-
className="absolute inset-0 grid auto-rows-fr grid-cols-
|
|
107
|
+
className="absolute inset-0 grid auto-rows-fr grid-cols-3 gap-0.5 lg:grid-cols-7"
|
|
101
108
|
>
|
|
102
109
|
{Array.from({ length: TEXTURE_CELLS }, (_, cell) => {
|
|
103
110
|
const url = tileUrls?.length ? tileUrls[cell % tileUrls.length] : undefined;
|
|
111
|
+
// `hidden` keeps a lazy image from ever being requested on the phone.
|
|
112
|
+
const phone = cell < PHONE_CELLS ? undefined : "hidden lg:block";
|
|
104
113
|
|
|
105
114
|
return url ? (
|
|
106
115
|
// eslint-disable-next-line @next/next/no-img-element
|
|
@@ -110,12 +119,15 @@ export function MenuHubHero({
|
|
|
110
119
|
alt=""
|
|
111
120
|
loading="lazy"
|
|
112
121
|
decoding="async"
|
|
113
|
-
className="h-full w-full object-cover"
|
|
122
|
+
className={cn("h-full w-full object-cover", phone)}
|
|
114
123
|
/>
|
|
115
124
|
) : (
|
|
116
125
|
<div
|
|
117
126
|
key={cell}
|
|
118
|
-
className=
|
|
127
|
+
className={cn(
|
|
128
|
+
"bg-[repeating-linear-gradient(135deg,oklch(0.6_0.02_55)_0_9px,oklch(0.68_0.016_58)_9px_18px)] opacity-[0.42]",
|
|
129
|
+
phone
|
|
130
|
+
)}
|
|
119
131
|
/>
|
|
120
132
|
);
|
|
121
133
|
})}
|
|
@@ -133,7 +145,7 @@ export function MenuHubHero({
|
|
|
133
145
|
className={cn(
|
|
134
146
|
"absolute inset-0",
|
|
135
147
|
tileUrls?.length || backgroundUrl
|
|
136
|
-
? "bg-[linear-gradient(100deg,oklch(0.16_0.012_55_/_0.96)_0%,oklch(0.16_0.012_55_/_0.
|
|
148
|
+
? "bg-[linear-gradient(100deg,oklch(0.16_0.012_55_/_0.96)_0%,oklch(0.16_0.012_55_/_0.86)_52%,oklch(0.16_0.012_55_/_0.6)_100%)]"
|
|
137
149
|
: "bg-[linear-gradient(100deg,oklch(0.18_0.012_55_/_0.95)_0%,oklch(0.18_0.012_55_/_0.72)_52%,oklch(0.18_0.012_55_/_0.3)_100%)]"
|
|
138
150
|
)}
|
|
139
151
|
/>
|
|
@@ -173,7 +185,7 @@ export function MenuHubHero({
|
|
|
173
185
|
{(proof || action) && (
|
|
174
186
|
<div className="mt-auto flex flex-wrap items-center gap-3 pt-5">
|
|
175
187
|
{proof && (
|
|
176
|
-
<span className="inline-flex min-h-11 items-center gap-2.5 rounded-full bg-white/
|
|
188
|
+
<span className="inline-flex min-h-11 items-center gap-2.5 rounded-full bg-white/[0.14] px-4 py-2 text-[13px]">
|
|
177
189
|
{proofIcon}
|
|
178
190
|
{proof}
|
|
179
191
|
</span>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { UtensilsCrossed, Wine } from "lucide-react";
|
|
3
|
+
import { MenuIndexHeader, MenuJumpStrip } from "./menu-index-header";
|
|
4
|
+
import { SegmentedNav } from "./segmented-nav";
|
|
5
|
+
import { mockIndexEntries } from "./menu-mocks";
|
|
6
|
+
|
|
7
|
+
const scopeSwitch = (
|
|
8
|
+
<SegmentedNav
|
|
9
|
+
label="Menu scope"
|
|
10
|
+
activeId="food"
|
|
11
|
+
items={[
|
|
12
|
+
{ id: "food", href: "/menu/cuisines", label: "Food", icon: <UtensilsCrossed className="h-4 w-4" /> },
|
|
13
|
+
{ id: "drinks", href: "/menu/drinks/families", label: "Drinks", icon: <Wine className="h-4 w-4" /> },
|
|
14
|
+
]}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const meta: Meta<typeof MenuIndexHeader> = {
|
|
19
|
+
title: "Menu/MenuIndexHeader",
|
|
20
|
+
component: MenuIndexHeader,
|
|
21
|
+
tags: ["autodocs"],
|
|
22
|
+
args: {
|
|
23
|
+
eyebrow: "MENU · CLUJ-NAPOCA",
|
|
24
|
+
title: "Browse by cuisine",
|
|
25
|
+
count: "38 cuisines",
|
|
26
|
+
subtitle: "Kitchens, the plates that define them, and what they cost.",
|
|
27
|
+
scopeSwitch,
|
|
28
|
+
},
|
|
29
|
+
decorators: [
|
|
30
|
+
(Story) => (
|
|
31
|
+
<div className="w-[1100px] bg-background p-4">
|
|
32
|
+
<Story />
|
|
33
|
+
</div>
|
|
34
|
+
),
|
|
35
|
+
],
|
|
36
|
+
parameters: {
|
|
37
|
+
docs: {
|
|
38
|
+
description: {
|
|
39
|
+
component:
|
|
40
|
+
"The hub's own section title, opened up — so the page reads as that section rather than as somewhere new, which is what the *All 38 cuisines* link promised. The scope switch is a slot, not a prop set: the two scopes are two ROUTES, so the caller passes a `SegmentedNav` with real hrefs and the DS stays out of routing.",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export default meta;
|
|
47
|
+
type Story = StoryObj<typeof MenuIndexHeader>;
|
|
48
|
+
|
|
49
|
+
export const Default: Story = {};
|
|
50
|
+
|
|
51
|
+
export const Drinks: Story = {
|
|
52
|
+
args: {
|
|
53
|
+
title: "Where to go out",
|
|
54
|
+
count: "5 kinds of night",
|
|
55
|
+
subtitle: "Bars, the glass that defines them, and what it costs.",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The strip lists only the families drawn in full — otherwise it is the index, twice. */
|
|
60
|
+
export const JumpStrip: StoryObj<typeof MenuJumpStrip> = {
|
|
61
|
+
render: () => <MenuJumpStrip entries={mockIndexEntries.slice(0, 8)} ariaLabel="Jump to a cuisine" />,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const Mobile: Story = {
|
|
65
|
+
decorators: [
|
|
66
|
+
(Story) => (
|
|
67
|
+
<div className="w-[390px] bg-background p-3">
|
|
68
|
+
<Story />
|
|
69
|
+
</div>
|
|
70
|
+
),
|
|
71
|
+
],
|
|
72
|
+
};
|