@bearmenu/ui 0.8.11 → 0.8.13
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 → chunk-3NMG3KNA.js} +1 -1
- package/dist/{chunk-5RWTOT5Y.js → chunk-CNJQUHCU.js} +11 -1
- package/dist/{chunk-DTXNDLSE.js → chunk-FKNBZF5T.js} +15 -0
- package/dist/{chunk-H3ZZDX5L.js → chunk-P7XTW2CG.js} +2 -2
- package/dist/{chunk-JS3EINZD.js → chunk-VAXOQS7W.js} +12 -0
- package/dist/{chunk-AKQ57NXY.js → chunk-ZQ53AGML.js} +2 -2
- 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/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 +2 -2
- package/dist/components/carousel.js +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/command.d.ts +15 -15
- package/dist/components/concept-compare-rail.js +3 -3
- package/dist/components/concept-compare.d.ts +1 -1
- package/dist/components/concept-compare.js +29 -8
- package/dist/components/concept-convergence.js +1 -1
- package/dist/components/dialog.d.ts +2 -2
- package/dist/components/empty-state.js +1 -1
- package/dist/components/event-detail-panel.js +1 -1
- package/dist/components/event-list-row-wide.js +1 -1
- package/dist/components/haptic-button.js +1 -1
- package/dist/components/input-group.d.ts +4 -4
- package/dist/components/input.d.ts +2 -2
- package/dist/components/item.d.ts +17 -7
- package/dist/components/item.js +15 -10
- package/dist/components/kbd.d.ts +2 -2
- package/dist/components/kitchen-dossier.d.ts +2 -0
- package/dist/components/kitchen-dossier.js +25 -18
- package/dist/components/link-button.js +1 -1
- package/dist/components/menu-family-block.d.ts +15 -9
- package/dist/components/menu-family-block.js +63 -60
- package/dist/components/menu-family-card.js +1 -1
- package/dist/components/menu-family-tiles.d.ts +19 -0
- package/dist/components/menu-family-tiles.js +57 -0
- package/dist/components/menu-hub-hero.js +8 -6
- package/dist/components/menu-index-list.d.ts +31 -12
- package/dist/components/menu-index-list.js +68 -31
- package/dist/components/menu-intent-hero.d.ts +1 -1
- package/dist/components/menu-kitchen-list.js +1 -1
- package/dist/components/menu-mocks.d.ts +10 -3
- package/dist/components/menu-mocks.js +21 -9
- package/dist/components/menu-types.d.ts +33 -10
- package/dist/components/multi-select-combobox.js +1 -1
- package/dist/components/native-select.d.ts +2 -2
- package/dist/components/pagination.js +1 -1
- package/dist/components/photo-ground.d.ts +7 -1
- package/dist/components/photo-ground.js +1 -1
- package/dist/components/searchable-select.js +1 -1
- package/dist/components/select.d.ts +1 -1
- package/dist/components/sliding-panels.js +1 -1
- package/dist/components/snap-rail.d.ts +2 -7
- package/dist/components/snap-rail.js +1 -1
- package/dist/components/spinner.d.ts +4 -4
- 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/venue-grade-dimensions.js +1 -1
- package/dist/components/weekday-theme-banner.d.ts +24 -3
- package/dist/components/weekday-theme-banner.js +62 -14
- package/package.json +1 -1
- package/src/components/button.stories.tsx +25 -2
- package/src/components/button.tsx +12 -0
- package/src/components/colour-dictionary.stories.tsx +1 -1
- package/src/components/concept-compare.tsx +40 -9
- package/src/components/item.tsx +69 -45
- package/src/components/kitchen-dossier.tsx +45 -24
- package/src/components/menu-family-block.tsx +86 -79
- package/src/components/menu-family-tiles.stories.tsx +54 -0
- package/src/components/menu-family-tiles.test.tsx +30 -0
- package/src/components/menu-family-tiles.tsx +132 -0
- package/src/components/menu-hub-hero.tsx +13 -7
- package/src/components/menu-index-list.stories.tsx +6 -1
- package/src/components/menu-index-list.tsx +101 -54
- package/src/components/menu-intent-hero.tsx +1 -1
- package/src/components/menu-kitchen-list.tsx +4 -1
- package/src/components/menu-mocks.ts +32 -9
- package/src/components/menu-types.ts +32 -9
- package/src/components/photo-ground.tsx +13 -1
- package/src/components/snap-rail.tsx +19 -0
- package/src/components/venue-grade-dimensions.tsx +9 -2
- package/src/components/weekday-theme-banner.stories.tsx +43 -4
- package/src/components/weekday-theme-banner.tsx +94 -17
- package/dist/components/menu-intent-tiles.d.ts +0 -31
- package/dist/components/menu-intent-tiles.js +0 -50
- package/src/components/menu-intent-tiles.stories.tsx +0 -54
- package/src/components/menu-intent-tiles.tsx +0 -76
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Wallet } from "lucide-react";
|
|
2
2
|
import { cn } from "../lib/utils";
|
|
3
|
+
import { buttonVariants } from "./button";
|
|
3
4
|
import { HueChip } from "./hue-chip";
|
|
4
5
|
import { PHOTO_GROUND, PhotoGround } from "./photo-ground";
|
|
5
6
|
import { Separator } from "./separator";
|
|
@@ -12,15 +13,16 @@ import type {
|
|
|
12
13
|
} from "./menu-types";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
|
-
* MenuFamilyBlock — the cuisines index's family card.
|
|
16
|
+
* MenuFamilyBlock — the cuisines index's family card. One photograph is the ground.
|
|
16
17
|
*
|
|
17
18
|
* ## Three layers, and everything floats on them
|
|
18
19
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
20
|
+
* One venue photograph is the background for the ENTIRE card, edge to edge,
|
|
21
|
+
* and the facts sit on it. Three layers do the work:
|
|
21
22
|
*
|
|
22
|
-
* 1. **The
|
|
23
|
-
* `mix-blend-color` layer.
|
|
23
|
+
* 1. **The photograph**, full-bleed, wearing the family's hue through a
|
|
24
|
+
* `mix-blend-color` layer. It was a mosaic of six; the design draws one
|
|
25
|
+
* plate, and six tiles under the ink read as texture rather than a room.
|
|
24
26
|
* 2. **An ink gradient** — absent where the photography is the subject, full
|
|
25
27
|
* where the rows begin. This is what buys the contrast. There used to be a
|
|
26
28
|
* masked backdrop-filter under it; it was removed for the measurement
|
|
@@ -29,10 +31,9 @@ import type {
|
|
|
29
31
|
*
|
|
30
32
|
* On the phone the ink runs top to bottom: the name and the action sit on
|
|
31
33
|
* clear photography, the chips and the rows on the inked foot. On desktop the
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* photograph too big to read as texture.
|
|
34
|
+
* photo column on the left (a third) stays clear, and the row column on the
|
|
35
|
+
* right is opaque paper in the card's own dark — three venue rows never sit
|
|
36
|
+
* on a photograph.
|
|
36
37
|
*
|
|
37
38
|
* ## The dark scope
|
|
38
39
|
*
|
|
@@ -80,6 +81,12 @@ export type MenuFamilyBlockLabels = {
|
|
|
80
81
|
priceBand: string;
|
|
81
82
|
/** Main action, e.g. "Browse Italian". Sits on the photography — keep it short. */
|
|
82
83
|
browse: string;
|
|
84
|
+
/**
|
|
85
|
+
* The action from `lg`, where the photo column is a third of the card and
|
|
86
|
+
* "Browse Bakeries & patisseries" does not fit — e.g. "Browse". The full
|
|
87
|
+
* `browse` stays the accessible name.
|
|
88
|
+
*/
|
|
89
|
+
browseShort?: string;
|
|
83
90
|
/** Passed through to the venue rows. */
|
|
84
91
|
kitchens: MenuKitchenListLabels;
|
|
85
92
|
};
|
|
@@ -96,22 +103,22 @@ export type MenuFamilyBlockProps = {
|
|
|
96
103
|
className?: string;
|
|
97
104
|
};
|
|
98
105
|
|
|
99
|
-
/** 2×2 on the phone, 3×2 from `lg`. Tiles past the fourth are desktop-only. */
|
|
100
|
-
const MOSAIC_TILES = 6;
|
|
101
|
-
const PHONE_TILES = 4;
|
|
102
|
-
|
|
103
106
|
/** The card's ground is `PhotoGround`'s, so the two cannot differ. */
|
|
104
107
|
const GROUND = PHOTO_GROUND;
|
|
105
108
|
|
|
106
109
|
/**
|
|
107
|
-
* The ink.
|
|
108
|
-
*
|
|
110
|
+
* The ink. Nearly absent at the top, heavier at the foot on the phone — the
|
|
111
|
+
* top 40% is the photograph, the rows sit on 0.92+; a to-right ramp from
|
|
112
|
+
* `lg`, where the row column brings its own opaque paper anyway. It is the
|
|
109
113
|
* layer the contrast rests on. Literal rather than `var(--scrim)`: that token
|
|
110
114
|
* lives in the app's stylesheet, not this package's, so a DS component reading
|
|
111
115
|
* it renders nothing in Storybook and the other three consumers.
|
|
112
116
|
*/
|
|
113
117
|
const INK =
|
|
114
|
-
"bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.
|
|
118
|
+
"bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.12)_0%,oklch(0.16_0.012_55_/_0.55)_38%,oklch(0.16_0.012_55_/_0.92)_62%,oklch(0.16_0.012_55_/_0.96)_100%)] lg:bg-[linear-gradient(to_right,oklch(0.16_0.012_55_/_0.38)_0%,oklch(0.16_0.012_55_/_0.6)_30%,oklch(0.16_0.012_55_/_0.92)_46%,oklch(0.16_0.012_55_/_0.96)_100%)]";
|
|
119
|
+
|
|
120
|
+
/** The row column's paper from `lg`: the card's dark, opaque. */
|
|
121
|
+
const PAPER = "lg:bg-[oklch(0.21_0.012_55)]";
|
|
115
122
|
|
|
116
123
|
/**
|
|
117
124
|
* A second, LOCAL scrim under the name, count and action — the photo column's
|
|
@@ -140,10 +147,12 @@ export function MenuFamilyBlock({
|
|
|
140
147
|
linkComponent: LinkComponent = DefaultLink,
|
|
141
148
|
className,
|
|
142
149
|
}: MenuFamilyBlockProps) {
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
150
|
+
const [photo] = family.photoUrls ?? [];
|
|
151
|
+
// "+N more" counts past the rows shown, which are the venues the reader can
|
|
152
|
+
// see — the photograph is one of them, not a venue on its own.
|
|
146
153
|
const venues = family.venues ?? [];
|
|
154
|
+
const overflow = Math.max(family.venueCount - venues.length, 0);
|
|
155
|
+
const initial = (family.name.match(/\p{L}/u)?.[0] ?? "").toLocaleUpperCase();
|
|
147
156
|
|
|
148
157
|
return (
|
|
149
158
|
<article
|
|
@@ -155,30 +164,26 @@ export function MenuFamilyBlock({
|
|
|
155
164
|
className
|
|
156
165
|
)}
|
|
157
166
|
>
|
|
158
|
-
{/* ── Layer 1: the
|
|
159
|
-
{/*
|
|
160
|
-
|
|
161
|
-
all, one slot with the family's initial. */}
|
|
162
|
-
<PhotoGround
|
|
163
|
-
photos={photos.length > 0 ? photos : [undefined]}
|
|
164
|
-
initials={initial}
|
|
165
|
-
mosaicClassName={
|
|
166
|
-
photos.length > 0 ? "grid-cols-2 grid-rows-2 lg:grid-cols-3" : "grid-cols-1"
|
|
167
|
-
}
|
|
168
|
-
phoneTiles={PHONE_TILES}
|
|
169
|
-
gradient={gradient}
|
|
170
|
-
/>
|
|
167
|
+
{/* ── Layer 1: the photograph, wearing the family's colour ───────── */}
|
|
168
|
+
{/* One venue's photograph, or the family's initial on the ground. */}
|
|
169
|
+
<PhotoGround photos={[photo]} initials={initial} mode="stack" gradient={gradient} />
|
|
171
170
|
|
|
172
171
|
{/* ── Layer 2: the ink ────────────────────────────────────────────── */}
|
|
173
172
|
<span aria-hidden className={cn("absolute inset-0", INK)} />
|
|
174
173
|
|
|
175
174
|
{/* ── The content, floating ───────────────────────────────────────── */}
|
|
176
|
-
<div className="relative grid grid-cols-1 lg:grid-cols-[
|
|
177
|
-
{/* The photo column. On the phone it is the card's head
|
|
178
|
-
|
|
179
|
-
|
|
175
|
+
<div className="relative grid grid-cols-1 lg:grid-cols-[34%_1fr]">
|
|
176
|
+
{/* The photo column. On the phone it is the card's head, with the
|
|
177
|
+
overflow pill pinned to the corner and the photograph clear above
|
|
178
|
+
the name; on desktop the left third, its stack anchored to the foot. */}
|
|
179
|
+
<div
|
|
180
|
+
className={cn(
|
|
181
|
+
"relative flex min-h-[220px] flex-col justify-end p-3.5 lg:min-h-[320px] lg:p-5",
|
|
182
|
+
TEXT_SCRIM
|
|
183
|
+
)}
|
|
184
|
+
>
|
|
180
185
|
{overflow > 0 && (
|
|
181
|
-
<span className="
|
|
186
|
+
<span className="absolute right-3.5 top-3.5 rounded-full bg-[oklch(0.16_0.012_55_/_0.78)] px-2.5 py-1 text-[11px] font-semibold text-white lg:static lg:mb-3 lg:self-start">
|
|
182
187
|
{labels.more(overflow)}
|
|
183
188
|
</span>
|
|
184
189
|
)}
|
|
@@ -186,55 +191,57 @@ export function MenuFamilyBlock({
|
|
|
186
191
|
<h3 className="text-balance font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white lg:text-[30px]">
|
|
187
192
|
{family.name}
|
|
188
193
|
</h3>
|
|
189
|
-
|
|
194
|
+
{/* The count and the band on one line: two facts about the family,
|
|
195
|
+
read together — "96 kitchens, 14–46 lei". */}
|
|
196
|
+
<span className="mt-1 flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[13px] font-medium tabular-nums text-white/70">
|
|
190
197
|
{labels.venueUnit(family.venueCount)}
|
|
198
|
+
{family.priceBandLabel && (
|
|
199
|
+
<span
|
|
200
|
+
className="inline-flex items-center gap-1"
|
|
201
|
+
aria-label={`${labels.priceBand}: ${family.priceBandLabel}`}
|
|
202
|
+
>
|
|
203
|
+
<Wallet className="h-3.5 w-3.5 shrink-0 text-white/60" aria-hidden />
|
|
204
|
+
{family.priceBandLabel}
|
|
205
|
+
</span>
|
|
206
|
+
)}
|
|
191
207
|
</span>
|
|
192
208
|
|
|
193
|
-
{/* The action
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
picture rather than as a control laid on top of it.
|
|
197
|
-
|
|
198
|
-
No glyph. An arrow read as "leaves the site" beside a label that
|
|
199
|
-
already says where it goes, and a chevron would have made the pill
|
|
200
|
-
one more row: every venue row to its right ends in exactly that
|
|
201
|
-
chevron. The shape and the label are the whole affordance. */}
|
|
209
|
+
{/* The hub's one action on ink. No glyph: a chevron would have made
|
|
210
|
+
the pill one more row, and every venue row beside it ends in
|
|
211
|
+
exactly that chevron. */}
|
|
202
212
|
<LinkComponent
|
|
203
213
|
href={family.href}
|
|
204
|
-
|
|
214
|
+
aria-label={labels.browse}
|
|
215
|
+
className={cn(buttonVariants({ variant: "ink", size: "pill" }), "mt-3.5 w-max max-w-full")}
|
|
205
216
|
>
|
|
206
|
-
{labels.
|
|
217
|
+
{labels.browseShort ? (
|
|
218
|
+
<>
|
|
219
|
+
<span className="lg:hidden">{labels.browse}</span>
|
|
220
|
+
<span className="hidden lg:inline">{labels.browseShort}</span>
|
|
221
|
+
</>
|
|
222
|
+
) : (
|
|
223
|
+
labels.browse
|
|
224
|
+
)}
|
|
207
225
|
</LinkComponent>
|
|
208
226
|
</div>
|
|
209
227
|
|
|
210
|
-
{/* The
|
|
211
|
-
<div className="flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5">
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
{family.priceBandLabel}
|
|
224
|
-
</HueChip>
|
|
225
|
-
)}
|
|
226
|
-
{family.chips?.map((chip) => (
|
|
227
|
-
/* Label only. The counts are VENUES beside a dish group's name,
|
|
228
|
-
which every reader takes for dishes. */
|
|
229
|
-
<HueChip key={chip.slug} as="li" hue={hue}>
|
|
230
|
-
{chip.label}
|
|
231
|
-
</HueChip>
|
|
232
|
-
))}
|
|
233
|
-
</ul>
|
|
228
|
+
{/* The row column: chips, then the rows, on paper from `lg`. */}
|
|
229
|
+
<div className={cn("flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5", PAPER)}>
|
|
230
|
+
{family.chips && family.chips.length > 0 && (
|
|
231
|
+
<ul className="flex flex-wrap items-center gap-1.5 lg:justify-end">
|
|
232
|
+
{family.chips.map((chip) => (
|
|
233
|
+
/* Label only. The counts are VENUES beside a dish group's name,
|
|
234
|
+
which every reader takes for dishes. */
|
|
235
|
+
<HueChip key={chip.slug} as="li" hue={hue}>
|
|
236
|
+
{chip.label}
|
|
237
|
+
</HueChip>
|
|
238
|
+
))}
|
|
239
|
+
</ul>
|
|
240
|
+
)}
|
|
234
241
|
|
|
235
242
|
{venues.length > 0 && (
|
|
236
243
|
<>
|
|
237
|
-
<Separator className="my-3" />
|
|
244
|
+
<Separator className="my-3 via-white/15" />
|
|
238
245
|
<MenuKitchenList
|
|
239
246
|
kitchens={venues}
|
|
240
247
|
labels={labels.kitchens}
|
|
@@ -270,12 +277,12 @@ export function MenuFamilyBlockSkeleton({
|
|
|
270
277
|
className={cn("dark relative overflow-hidden rounded-[14px]", GROUND, className)}
|
|
271
278
|
aria-hidden
|
|
272
279
|
>
|
|
273
|
-
<div className="grid grid-cols-1 lg:grid-cols-[
|
|
274
|
-
<div className="flex flex-col p-3.5 lg:min-h-[320px] lg:
|
|
275
|
-
<Skeleton className="
|
|
280
|
+
<div className="grid grid-cols-1 lg:grid-cols-[34%_1fr]">
|
|
281
|
+
<div className="relative flex min-h-[220px] flex-col justify-end p-3.5 lg:min-h-[320px] lg:p-5">
|
|
282
|
+
<Skeleton className="absolute right-3.5 top-3.5 h-6 w-[72px] rounded-full lg:static lg:mb-3" />
|
|
276
283
|
<Skeleton className="h-7 w-[60%]" />
|
|
277
|
-
<Skeleton className="mt-2 h-3.5 w-[
|
|
278
|
-
<Skeleton className="mt-3.5 h-
|
|
284
|
+
<Skeleton className="mt-2 h-3.5 w-[150px]" />
|
|
285
|
+
<Skeleton className="mt-3.5 h-11 w-[160px] rounded-full" />
|
|
279
286
|
</div>
|
|
280
287
|
<div className="flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5">
|
|
281
288
|
<div className="flex gap-1.5">
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { MenuFamilyTiles } from "./menu-family-tiles";
|
|
3
|
+
import { mockFamilyTiles, mockFamilyTilesAll } from "./menu-mocks";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof MenuFamilyTiles> = {
|
|
6
|
+
title: "Menu/MenuFamilyTiles",
|
|
7
|
+
component: MenuFamilyTiles,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
decorators: [
|
|
10
|
+
(Story) => (
|
|
11
|
+
<div className="w-[1100px] bg-background p-4">
|
|
12
|
+
<Story />
|
|
13
|
+
</div>
|
|
14
|
+
),
|
|
15
|
+
],
|
|
16
|
+
parameters: {
|
|
17
|
+
docs: {
|
|
18
|
+
description: {
|
|
19
|
+
component:
|
|
20
|
+
"The hub's doorways: every family the city browses by, as a grid of coloured tiles — the family's name, its venue count and one venue's photograph tilted off the corner — closed by one tile that opens the whole index. Two to a row on a phone, four from `lg`, so seven families and the index make two rows at every width.",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj<typeof MenuFamilyTiles>;
|
|
28
|
+
|
|
29
|
+
export const Default: Story = { args: { tiles: mockFamilyTiles, all: mockFamilyTilesAll } };
|
|
30
|
+
|
|
31
|
+
/** Two-up on a phone — seven families and the index in four rows. */
|
|
32
|
+
export const Mobile: Story = {
|
|
33
|
+
args: { tiles: mockFamilyTiles, all: mockFamilyTilesAll },
|
|
34
|
+
// The grid's columns follow the viewport, not the wrapper.
|
|
35
|
+
parameters: { viewport: { defaultViewport: "mobile1" } },
|
|
36
|
+
decorators: [
|
|
37
|
+
(Story) => (
|
|
38
|
+
<div className="w-[390px] bg-background p-3">
|
|
39
|
+
<Story />
|
|
40
|
+
</div>
|
|
41
|
+
),
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** No photographs anywhere: every polaroid holds the family's initial. */
|
|
46
|
+
export const WithoutPhotos: Story = {
|
|
47
|
+
args: {
|
|
48
|
+
tiles: mockFamilyTiles.map(({ photoUrl: _photoUrl, ...tile }) => tile),
|
|
49
|
+
all: { ...mockFamilyTilesAll, photoUrl: undefined },
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The drinks tab's five going-out families, no index tile. */
|
|
54
|
+
export const Partial: Story = { args: { tiles: mockFamilyTiles.slice(0, 5) } };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { MenuFamilyTiles } from "./menu-family-tiles";
|
|
4
|
+
import { mockFamilyTiles, mockFamilyTilesAll } from "./menu-mocks";
|
|
5
|
+
|
|
6
|
+
describe("MenuFamilyTiles", () => {
|
|
7
|
+
it("renders one link per family plus the index tile, in order", () => {
|
|
8
|
+
render(<MenuFamilyTiles tiles={mockFamilyTiles} all={mockFamilyTilesAll} />);
|
|
9
|
+
const links = screen.getAllByRole("link");
|
|
10
|
+
expect(links).toHaveLength(mockFamilyTiles.length + 1);
|
|
11
|
+
expect(links[0]).toHaveAttribute("href", mockFamilyTiles[0].href);
|
|
12
|
+
expect(links.at(-1)).toHaveAttribute("href", mockFamilyTilesAll.href);
|
|
13
|
+
expect(links.at(-1)).toHaveTextContent(mockFamilyTilesAll.label);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// A family without a photograph keeps the polaroid — the initial fills it —
|
|
17
|
+
// so a row of seven never has one tile with a different silhouette.
|
|
18
|
+
it("draws the initial where a family has no photograph", () => {
|
|
19
|
+
const [withPhoto, withoutPhoto] = [mockFamilyTiles[0], mockFamilyTiles.at(-1)!];
|
|
20
|
+
expect(withoutPhoto.photoUrl).toBeUndefined();
|
|
21
|
+
render(<MenuFamilyTiles tiles={[withPhoto, withoutPhoto]} />);
|
|
22
|
+
expect(document.querySelectorAll("img")).toHaveLength(1);
|
|
23
|
+
expect(screen.getByText(withoutPhoto.label.charAt(0))).toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("omits the index tile when no index is given", () => {
|
|
27
|
+
render(<MenuFamilyTiles tiles={mockFamilyTiles.slice(0, 3)} />);
|
|
28
|
+
expect(screen.getAllByRole("link")).toHaveLength(3);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { cn } from "../lib/utils";
|
|
2
|
+
import type { MenuFamilyTile, MenuLinkComponent, MenuLinkProps } from "./menu-types";
|
|
3
|
+
import { PhotoGround } from "./photo-ground";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* MenuFamilyTiles — the hub's doorways: every family the city browses by, as
|
|
7
|
+
* a grid of coloured tiles, closed by one tile that opens the whole index.
|
|
8
|
+
*
|
|
9
|
+
* ## A tile, not a card
|
|
10
|
+
*
|
|
11
|
+
* The family card this replaces was a full-width mosaic with chips, a price
|
|
12
|
+
* band and a browse pill — a summary of the family, in a rail the reader had
|
|
13
|
+
* to swipe through one family at a time. The tile is the family's NAME, its
|
|
14
|
+
* venue count and one photograph on the family's hue, two to a row, so seven
|
|
15
|
+
* families and the index fit on one phone screen. The reader is choosing a
|
|
16
|
+
* family here, not learning about one; the summary lives on the page the tile
|
|
17
|
+
* opens.
|
|
18
|
+
*
|
|
19
|
+
* ## The photograph is a polaroid, not a ground
|
|
20
|
+
*
|
|
21
|
+
* One square photograph, tilted, running off the tile's top and right edges
|
|
22
|
+
* — the tile's `overflow-hidden` crops it, so it reads as a print dropped on
|
|
23
|
+
* the tile rather than a thumbnail placed in it — with the name and the count
|
|
24
|
+
* in the bottom-left, under the print's lower edge. Not a ground: white type
|
|
25
|
+
* on a photograph needs a scrim heavy enough to hide the photograph, and the
|
|
26
|
+
* hue is what tells seven tiles apart at a glance. The photograph is the
|
|
27
|
+
* FAMILY's — a plate, a room, a glass — served from the consumer's own origin,
|
|
28
|
+
* never a venue's (a venue on a tile about every venue reads as an
|
|
29
|
+
* endorsement). A family with no photograph gets the same tilted square
|
|
30
|
+
* holding `PhotoGround`'s serif initial, so the silhouette never changes.
|
|
31
|
+
*
|
|
32
|
+
* Every measure on the polaroid is a percentage of the tile, so the tile and
|
|
33
|
+
* the design stay in proportion from a 170px phone cell to a 260px desktop
|
|
34
|
+
* one: 82% of the height, 8% above the top, 4% past the right, 8° over —
|
|
35
|
+
* measured off the design frame.
|
|
36
|
+
*
|
|
37
|
+
* The bottom scrim is mandatory. The honey and terrace hues sit around
|
|
38
|
+
* `oklch(0.6…)`, where white at 14.5px is about 3:1 — under the 4.5:1 the
|
|
39
|
+
* label needs. The scrim takes the label's corner down to where it passes on
|
|
40
|
+
* every hue in the dictionary.
|
|
41
|
+
*
|
|
42
|
+
* Server-safe.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
function DefaultLink({ href, ...props }: MenuLinkProps) {
|
|
46
|
+
return <a href={href} {...props} />;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type MenuFamilyTilesProps = {
|
|
50
|
+
tiles: MenuFamilyTile[];
|
|
51
|
+
/**
|
|
52
|
+
* The closing tile: the index behind the seven, drawn exactly like them —
|
|
53
|
+
* its own hue and photograph, `countLabel` as the caption. Only its place in
|
|
54
|
+
* the grid says it is the index. Omit when there is none.
|
|
55
|
+
*/
|
|
56
|
+
all?: MenuFamilyTile;
|
|
57
|
+
linkComponent?: MenuLinkComponent;
|
|
58
|
+
className?: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The tile: the design's 16:10, never shorter than two lines of name plus the
|
|
63
|
+
* count need.
|
|
64
|
+
*/
|
|
65
|
+
const TILE =
|
|
66
|
+
"group relative isolate flex aspect-[16/10] min-h-[108px] flex-col justify-end overflow-hidden rounded-2xl bg-muted p-3.5 text-white lg:p-4 " +
|
|
67
|
+
"transition-[transform,box-shadow] duration-200 hover:-translate-y-0.5 hover:shadow-[var(--shadow-card-hover)] active:translate-y-0 active:scale-[0.99] motion-reduce:transition-none " +
|
|
68
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The polaroid. Same literal family as `PHOTO_GROUND` for the shadow, never a
|
|
72
|
+
* black `rgba` — the shadow is the ground the card family already stands on.
|
|
73
|
+
*/
|
|
74
|
+
const POLAROID =
|
|
75
|
+
"absolute -right-[4%] -top-[8%] z-0 block aspect-square h-[82%] overflow-hidden rounded-[10%] rotate-[8deg] " +
|
|
76
|
+
"shadow-[0_10px_22px_-6px_oklch(0.16_0.012_55_/_0.65)] transition-transform duration-300 group-hover:rotate-[5deg] motion-reduce:transition-none";
|
|
77
|
+
|
|
78
|
+
const SCRIM =
|
|
79
|
+
"after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:z-0 after:h-3/5 after:bg-gradient-to-t after:from-[oklch(0.16_0.012_55_/_0.55)] after:to-transparent after:content-['']";
|
|
80
|
+
|
|
81
|
+
export function MenuFamilyTiles({
|
|
82
|
+
tiles,
|
|
83
|
+
all,
|
|
84
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
85
|
+
className,
|
|
86
|
+
}: MenuFamilyTilesProps) {
|
|
87
|
+
const cells = all ? [...tiles, all] : tiles;
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className={cn("grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-3.5", className)}>
|
|
91
|
+
{cells.map((tile) => (
|
|
92
|
+
<LinkComponent
|
|
93
|
+
key={tile.slug}
|
|
94
|
+
href={tile.href}
|
|
95
|
+
className={cn(TILE, SCRIM)}
|
|
96
|
+
style={tile.gradient ? { backgroundImage: tile.gradient } : undefined}
|
|
97
|
+
>
|
|
98
|
+
<span aria-hidden className={POLAROID}>
|
|
99
|
+
{tile.photoUrl ? (
|
|
100
|
+
/* Not lazy: the grid sits directly under the hero, and these
|
|
101
|
+
photographs are the first thing the reader looks at. */
|
|
102
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
103
|
+
<img
|
|
104
|
+
src={tile.photoUrl}
|
|
105
|
+
alt=""
|
|
106
|
+
decoding="async"
|
|
107
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
108
|
+
/>
|
|
109
|
+
) : (
|
|
110
|
+
<PhotoGround
|
|
111
|
+
photos={[undefined]}
|
|
112
|
+
initials={tile.label.charAt(0)}
|
|
113
|
+
mode="stack"
|
|
114
|
+
glyphClassName="text-[44px]"
|
|
115
|
+
/>
|
|
116
|
+
)}
|
|
117
|
+
</span>
|
|
118
|
+
|
|
119
|
+
{/* Both lines keep to the left 55%: the polaroid owns the right. */}
|
|
120
|
+
<span className="relative z-[1] line-clamp-2 max-w-[55%] text-[14.5px] font-extrabold leading-[1.2] tracking-[-0.015em] lg:text-[16px]">
|
|
121
|
+
{tile.label}
|
|
122
|
+
</span>
|
|
123
|
+
{tile.countLabel && (
|
|
124
|
+
<span className="relative z-[1] mt-0.5 max-w-[55%] truncate text-[12px] font-medium leading-[1.35] tabular-nums text-white/80 lg:text-[13px]">
|
|
125
|
+
{tile.countLabel}
|
|
126
|
+
</span>
|
|
127
|
+
)}
|
|
128
|
+
</LinkComponent>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -158,7 +158,9 @@ export function MenuHubHero({
|
|
|
158
158
|
</span>
|
|
159
159
|
)}
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
{/* 28px on a phone: 32px on a 358px column is five or six words a
|
|
162
|
+
line, which left "walk in blind." wrapping alone. */}
|
|
163
|
+
<h1 className="max-w-[640px] font-serif text-[28px] font-semibold leading-[1.06] tracking-[-0.025em] lg:text-[50px] lg:leading-[1.04]">
|
|
162
164
|
{headline}
|
|
163
165
|
</h1>
|
|
164
166
|
|
|
@@ -169,21 +171,25 @@ export function MenuHubHero({
|
|
|
169
171
|
)}
|
|
170
172
|
|
|
171
173
|
{figures && figures.length > 0 && (
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
+
// One row of four on the phone. It was two by two, which was the
|
|
175
|
+
// taller half of a 500px hero; at 20px the widest figure ("19,090")
|
|
176
|
+
// is ~68px and four of them share a 358px column with room.
|
|
177
|
+
<div className="flex justify-between gap-x-3 pt-2 lg:flex-wrap lg:justify-start lg:gap-x-8 lg:gap-y-4 lg:pt-1.5">
|
|
174
178
|
{figures.map((figure) => (
|
|
175
|
-
<div key={figure.label}>
|
|
176
|
-
<div className="text-
|
|
179
|
+
<div key={figure.label} className="min-w-0">
|
|
180
|
+
<div className="text-xl font-black leading-none tracking-[-0.02em] tabular-nums lg:text-[32px] lg:tracking-[-0.03em]">
|
|
177
181
|
{figure.value}
|
|
178
182
|
</div>
|
|
179
|
-
<div className="mt-[
|
|
183
|
+
<div className="mt-1 text-[11px] leading-tight opacity-[0.72] lg:mt-[5px] lg:text-xs">
|
|
184
|
+
{figure.label}
|
|
185
|
+
</div>
|
|
180
186
|
</div>
|
|
181
187
|
))}
|
|
182
188
|
</div>
|
|
183
189
|
)}
|
|
184
190
|
|
|
185
191
|
{(proof || action) && (
|
|
186
|
-
<div className="mt-auto flex flex-wrap items-center gap-3 pt-5">
|
|
192
|
+
<div className="mt-auto flex flex-wrap items-center gap-3 pt-4 lg:pt-5">
|
|
187
193
|
{proof && (
|
|
188
194
|
<span className="inline-flex min-h-11 items-center gap-2.5 rounded-full bg-white/[0.14] px-4 py-2 text-[13px]">
|
|
189
195
|
{proofIcon}
|
|
@@ -38,8 +38,13 @@ export const Tail: Story = {
|
|
|
38
38
|
args: { entries: mockIndexEntries.slice(-8), title: "The tail" },
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
-
/**
|
|
41
|
+
/** One column with the expander: twelve rows, then "Show all 38 cuisines". */
|
|
42
42
|
export const Mobile: Story = {
|
|
43
|
+
args: {
|
|
44
|
+
collapsedCount: 12,
|
|
45
|
+
expandLabels: { showAll: "Show all 38 cuisines", showFewer: "Show fewer" },
|
|
46
|
+
},
|
|
47
|
+
parameters: { viewport: { defaultViewport: "mobile1" } },
|
|
43
48
|
decorators: [
|
|
44
49
|
(Story) => (
|
|
45
50
|
<div className="w-[390px] bg-background p-3">
|