@bearmenu/ui 0.8.11 → 0.8.12
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-H3ZZDX5L.js → chunk-7A42J73Q.js} +1 -1
- package/dist/{chunk-5RWTOT5Y.js → chunk-CNJQUHCU.js} +11 -1
- 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 +4 -4
- package/dist/components/carousel.js +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/command.d.ts +8 -8
- package/dist/components/concept-compare-rail.js +2 -2
- package/dist/components/concept-compare.d.ts +1 -1
- package/dist/components/concept-compare.js +28 -7
- package/dist/components/concept-convergence.js +1 -1
- package/dist/components/date-input.d.ts +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 +19 -9
- 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 +62 -59
- 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-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 -2
- package/dist/components/spinner.d.ts +4 -4
- package/dist/components/tag.d.ts +4 -4
- package/dist/components/text.d.ts +3 -3
- 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-mocks.ts +32 -9
- package/src/components/menu-types.ts +32 -9
- package/src/components/photo-ground.tsx +13 -1
- 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
|
@@ -79,7 +79,7 @@ export type ConceptCompareFigure = {
|
|
|
79
79
|
export type ConceptCompareLabels = {
|
|
80
80
|
/** Small caps kicker, e.g. "DISH CONCEPT". */
|
|
81
81
|
kind: string;
|
|
82
|
-
/** The badge on the lowest-priced room, e.g. "CHEAPEST"
|
|
82
|
+
/** The badge on the lowest-priced room, e.g. "CHEAPEST" — the fallback when a row carries no `badge`. */
|
|
83
83
|
cheapest: string;
|
|
84
84
|
/** The badge on every other room, e.g. "#2". Defaults to `#${rank}`. */
|
|
85
85
|
rank?: (rank: number) => string;
|
|
@@ -277,7 +277,15 @@ export function ConceptCompare({
|
|
|
277
277
|
>
|
|
278
278
|
{rooms.map((room, index) => {
|
|
279
279
|
const href = hrefs?.[room.place_slug];
|
|
280
|
-
|
|
280
|
+
// A winner room says what it won; a ranked room says its rank.
|
|
281
|
+
// The CHEAPEST badge — and only it — is green with the arrow: it
|
|
282
|
+
// is the census's signature claim, the one badge a diner scans
|
|
283
|
+
// for. Gated on the badge, not the price: drink concepts cluster
|
|
284
|
+
// on price, and a "most named" room at the minimum must not wear
|
|
285
|
+
// the cheapest's glyph.
|
|
286
|
+
const badge = room.badge ?? (room.price === cheapest ? labels.cheapest : rankOf(index + 1));
|
|
287
|
+
const isCheapest = badge === labels.cheapest;
|
|
288
|
+
const lead = room.lead ?? (isCheapest ? "price" : undefined);
|
|
281
289
|
const roomClass = cn(
|
|
282
290
|
/* Transparent on the phone — the card behind it is the room's
|
|
283
291
|
photograph — with only its scrim and its line. A photograph of
|
|
@@ -312,7 +320,7 @@ export function ConceptCompare({
|
|
|
312
320
|
)}
|
|
313
321
|
>
|
|
314
322
|
{isCheapest && <ArrowDown className="h-3 w-3 shrink-0" aria-hidden />}
|
|
315
|
-
{
|
|
323
|
+
{badge}
|
|
316
324
|
</span>
|
|
317
325
|
|
|
318
326
|
{/* Name and rating on one line: the pin leads, the name takes
|
|
@@ -322,11 +330,25 @@ export function ConceptCompare({
|
|
|
322
330
|
<b className="min-w-0 flex-1 truncate text-[15px] font-bold leading-tight">
|
|
323
331
|
{room.place_name}
|
|
324
332
|
</b>
|
|
325
|
-
{
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
333
|
+
{/* The BearMenu grade, in the grade's amber — the number
|
|
334
|
+
the census computed, beside the kitchen it computed it
|
|
335
|
+
for. Google's star only for a kitchen with no grade. */}
|
|
336
|
+
{room.grade ? (
|
|
337
|
+
<span
|
|
338
|
+
className={cn(
|
|
339
|
+
"shrink-0 text-[14px] font-extrabold leading-none tabular-nums text-[oklch(0.82_0.15_85)]",
|
|
340
|
+
lead === "grade" && "text-[16px]"
|
|
341
|
+
)}
|
|
342
|
+
>
|
|
343
|
+
{room.grade}
|
|
329
344
|
</span>
|
|
345
|
+
) : (
|
|
346
|
+
room.rating !== undefined && (
|
|
347
|
+
<span className="flex shrink-0 items-center gap-1 text-[12px] text-white/70">
|
|
348
|
+
<Star className="h-3 w-3 shrink-0" aria-hidden />
|
|
349
|
+
<span className="tabular-nums">{room.rating}</span>
|
|
350
|
+
</span>
|
|
351
|
+
)
|
|
330
352
|
)}
|
|
331
353
|
</span>
|
|
332
354
|
{room.place_area && (
|
|
@@ -343,7 +365,11 @@ export function ConceptCompare({
|
|
|
343
365
|
is a 0.32-lightness grey that vanishes on inked photography. */}
|
|
344
366
|
<Separator className="my-2.5 via-white/20" />
|
|
345
367
|
|
|
346
|
-
|
|
368
|
+
{/* The figures: the one the badge is about leads — the badge
|
|
369
|
+
says what the room won, the number is the evidence. Green
|
|
370
|
+
is the cheapest's alone (a dearest room's price in green
|
|
371
|
+
would read as cheap); the other winners lead in full white. */}
|
|
372
|
+
<span className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
347
373
|
<b
|
|
348
374
|
className={cn(
|
|
349
375
|
"text-[19px] font-bold leading-none tabular-nums tracking-[-0.02em]",
|
|
@@ -353,7 +379,12 @@ export function ConceptCompare({
|
|
|
353
379
|
{formatPrice(room.price)}
|
|
354
380
|
</b>
|
|
355
381
|
{room.mentions !== undefined && room.mentions > 0 && (
|
|
356
|
-
<span
|
|
382
|
+
<span
|
|
383
|
+
className={cn(
|
|
384
|
+
"text-[11.5px]",
|
|
385
|
+
lead === "mentions" ? "font-bold text-white" : "text-white/65"
|
|
386
|
+
)}
|
|
387
|
+
>
|
|
357
388
|
{labels.mentions(room.mentions)}
|
|
358
389
|
</span>
|
|
359
390
|
)}
|
package/src/components/item.tsx
CHANGED
|
@@ -52,63 +52,87 @@ const Item = React.forwardRef<HTMLDivElement, ItemProps>(
|
|
|
52
52
|
);
|
|
53
53
|
Item.displayName = "Item";
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
55
|
+
/**
|
|
56
|
+
* The parts take `asChild` too. An `Item asChild` around a `<button>` makes
|
|
57
|
+
* the parts the button's children, and a button may hold only phrasing
|
|
58
|
+
* content — so the parts have to be able to render as `<span>`s there.
|
|
59
|
+
*/
|
|
60
|
+
type ItemPartProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
61
|
+
asChild?: boolean;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const ItemMedia = React.forwardRef<HTMLDivElement, ItemPartProps>(
|
|
65
|
+
({ className, asChild, ...props }, ref) => {
|
|
66
|
+
const Comp = asChild ? Slot : "div";
|
|
67
|
+
return (
|
|
68
|
+
<Comp
|
|
69
|
+
ref={ref}
|
|
70
|
+
data-slot="item-media"
|
|
71
|
+
className={cn("inline-flex shrink-0 items-center justify-center text-muted-foreground", className)}
|
|
72
|
+
{...props}
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
64
76
|
);
|
|
65
77
|
ItemMedia.displayName = "ItemMedia";
|
|
66
78
|
|
|
67
|
-
const ItemContent = React.forwardRef<HTMLDivElement,
|
|
68
|
-
({ className, ...props }, ref) =>
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
const ItemContent = React.forwardRef<HTMLDivElement, ItemPartProps>(
|
|
80
|
+
({ className, asChild, ...props }, ref) => {
|
|
81
|
+
const Comp = asChild ? Slot : "div";
|
|
82
|
+
return (
|
|
83
|
+
<Comp
|
|
84
|
+
ref={ref}
|
|
85
|
+
data-slot="item-content"
|
|
86
|
+
className={cn("flex min-w-0 flex-1 flex-col gap-0.5", className)}
|
|
87
|
+
{...props}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
76
91
|
);
|
|
77
92
|
ItemContent.displayName = "ItemContent";
|
|
78
93
|
|
|
79
|
-
const ItemTitle = React.forwardRef<HTMLDivElement,
|
|
80
|
-
({ className, ...props }, ref) =>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
const ItemTitle = React.forwardRef<HTMLDivElement, ItemPartProps>(
|
|
95
|
+
({ className, asChild, ...props }, ref) => {
|
|
96
|
+
const Comp = asChild ? Slot : "div";
|
|
97
|
+
return (
|
|
98
|
+
<Comp
|
|
99
|
+
ref={ref}
|
|
100
|
+
data-slot="item-title"
|
|
101
|
+
className={cn("truncate text-sm font-semibold text-foreground", className)}
|
|
102
|
+
{...props}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
88
106
|
);
|
|
89
107
|
ItemTitle.displayName = "ItemTitle";
|
|
90
108
|
|
|
91
|
-
const ItemDescription = React.forwardRef<HTMLDivElement,
|
|
92
|
-
({ className, ...props }, ref) =>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
109
|
+
const ItemDescription = React.forwardRef<HTMLDivElement, ItemPartProps>(
|
|
110
|
+
({ className, asChild, ...props }, ref) => {
|
|
111
|
+
const Comp = asChild ? Slot : "div";
|
|
112
|
+
return (
|
|
113
|
+
<Comp
|
|
114
|
+
ref={ref}
|
|
115
|
+
data-slot="item-description"
|
|
116
|
+
className={cn("truncate text-xs text-muted-foreground", className)}
|
|
117
|
+
{...props}
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
100
121
|
);
|
|
101
122
|
ItemDescription.displayName = "ItemDescription";
|
|
102
123
|
|
|
103
|
-
const ItemActions = React.forwardRef<HTMLDivElement,
|
|
104
|
-
({ className, ...props }, ref) =>
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
const ItemActions = React.forwardRef<HTMLDivElement, ItemPartProps>(
|
|
125
|
+
({ className, asChild, ...props }, ref) => {
|
|
126
|
+
const Comp = asChild ? Slot : "div";
|
|
127
|
+
return (
|
|
128
|
+
<Comp
|
|
129
|
+
ref={ref}
|
|
130
|
+
data-slot="item-actions"
|
|
131
|
+
className={cn("ml-auto inline-flex shrink-0 items-center gap-1", className)}
|
|
132
|
+
{...props}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
112
136
|
);
|
|
113
137
|
ItemActions.displayName = "ItemActions";
|
|
114
138
|
|
|
@@ -72,6 +72,8 @@ export type KitchenDossierLabels = {
|
|
|
72
72
|
next: string;
|
|
73
73
|
/** Dimension key → translated label. */
|
|
74
74
|
dimensions: Partial<Record<VenueGradeDimensionKey, string>>;
|
|
75
|
+
/** Beside the grade in the title row, e.g. "/10". Omit for the bare numeral. */
|
|
76
|
+
gradeUnit?: string;
|
|
75
77
|
};
|
|
76
78
|
|
|
77
79
|
export type KitchenDossierProps = {
|
|
@@ -202,25 +204,18 @@ export function KitchenDossier({
|
|
|
202
204
|
.sort((a, b) => (dimensions[b] ?? 0) - (dimensions[a] ?? 0))
|
|
203
205
|
.slice(0, DIMENSION_LIMIT)
|
|
204
206
|
: [];
|
|
205
|
-
|
|
207
|
+
// The overall grade leads the title row now, so the part shows what the
|
|
208
|
+
// title cannot: the three strongest dimensions. No numeral repeated.
|
|
209
|
+
if (dimensions && strongest.length > 0) {
|
|
206
210
|
parts.push(
|
|
207
211
|
<Part key="grade" kicker={labels.theGrade}>
|
|
208
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
<VenueGradeDimensions
|
|
216
|
-
className="min-w-0 flex-1"
|
|
217
|
-
dimensions={dimensions}
|
|
218
|
-
labels={labels.dimensions}
|
|
219
|
-
order={strongest}
|
|
220
|
-
formatScore={formatScore}
|
|
221
|
-
/>
|
|
222
|
-
)}
|
|
223
|
-
</div>
|
|
212
|
+
<VenueGradeDimensions
|
|
213
|
+
className="min-w-0"
|
|
214
|
+
dimensions={dimensions}
|
|
215
|
+
labels={labels.dimensions}
|
|
216
|
+
order={strongest}
|
|
217
|
+
formatScore={formatScore}
|
|
218
|
+
/>
|
|
224
219
|
</Part>
|
|
225
220
|
);
|
|
226
221
|
}
|
|
@@ -281,16 +276,42 @@ export function KitchenDossier({
|
|
|
281
276
|
{place.name}
|
|
282
277
|
</LinkComponent>
|
|
283
278
|
</h3>
|
|
284
|
-
{
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
279
|
+
{/* The GRADE leads — the number the rail is named for, in the
|
|
280
|
+
grade's amber. Google's star used to sit here while the
|
|
281
|
+
grade waited in story part three; a card rated by BearMenu
|
|
282
|
+
cannot lead with a number BearMenu did not compute. The
|
|
283
|
+
star drops to the meta line, and the title row only falls
|
|
284
|
+
back to it for a kitchen with no grade. */}
|
|
285
|
+
{grade?.overall ? (
|
|
286
|
+
<span className="inline-flex shrink-0 items-baseline gap-1 text-[22px] font-extrabold leading-none tabular-nums text-[oklch(0.82_0.15_85)] lg:text-[26px]">
|
|
287
|
+
{grade.overall}
|
|
288
|
+
{labels.gradeUnit && (
|
|
289
|
+
<span className="text-[11px] font-bold text-white/70">{labels.gradeUnit}</span>
|
|
290
|
+
)}
|
|
288
291
|
</span>
|
|
292
|
+
) : (
|
|
293
|
+
rating !== undefined && (
|
|
294
|
+
<span className="inline-flex shrink-0 items-center gap-1 text-[18px] font-extrabold leading-none tabular-nums text-white lg:text-[22px]">
|
|
295
|
+
{rating.toFixed(1)}
|
|
296
|
+
<Star className="h-3.5 w-3.5 lg:h-4 lg:w-4" aria-hidden />
|
|
297
|
+
</span>
|
|
298
|
+
)
|
|
289
299
|
)}
|
|
290
300
|
</div>
|
|
291
|
-
{(meta || labels.reviewCount) && (
|
|
292
|
-
<p className="mt-1
|
|
293
|
-
{
|
|
301
|
+
{(meta || labels.reviewCount || (grade?.overall && rating !== undefined)) && (
|
|
302
|
+
<p className="mt-1 flex min-w-0 items-center gap-1 text-[12.5px] text-white/70">
|
|
303
|
+
{(meta || labels.reviewCount) && (
|
|
304
|
+
<span className="truncate">
|
|
305
|
+
{[meta, labels.reviewCount].filter(Boolean).join(" · ")}
|
|
306
|
+
</span>
|
|
307
|
+
)}
|
|
308
|
+
{grade?.overall && rating !== undefined && (
|
|
309
|
+
<span className="inline-flex shrink-0 items-center gap-1 tabular-nums">
|
|
310
|
+
{(meta || labels.reviewCount) && <span aria-hidden>·</span>}
|
|
311
|
+
{rating.toFixed(1)}
|
|
312
|
+
<Star className="h-3 w-3" aria-hidden />
|
|
313
|
+
</span>
|
|
314
|
+
)}
|
|
294
315
|
</p>
|
|
295
316
|
)}
|
|
296
317
|
</div>
|
|
@@ -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) } };
|