@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
|
@@ -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">
|
|
@@ -1,23 +1,32 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
2
5
|
import { cn } from "../lib/utils";
|
|
6
|
+
import { Button } from "./button";
|
|
3
7
|
import { Separator } from "./separator";
|
|
4
8
|
import { Skeleton } from "./skeleton";
|
|
5
9
|
import type { MenuIndexEntry, MenuLinkComponent, MenuLinkProps } from "./menu-types";
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
|
-
* MenuIndexList — the complete set,
|
|
12
|
+
* MenuIndexList — the complete set: one column on a phone, three from `lg`.
|
|
9
13
|
*
|
|
10
14
|
* ## This block is why the page can afford to be progressive
|
|
11
15
|
*
|
|
12
16
|
* It carries a crawlable link to EVERY destination the city has — all
|
|
13
|
-
* thirty-eight cuisines, not the
|
|
14
|
-
* it the page's entire link-equity job, which in turn means the rich
|
|
15
|
-
*
|
|
16
|
-
* crawler needs is hidden behind that control, because it is all
|
|
17
|
+
* thirty-eight cuisines, not the eight the page draws in full above. That
|
|
18
|
+
* makes it the page's entire link-equity job, which in turn means the rich
|
|
19
|
+
* blocks are free to load eight at a time behind a "show more": nothing a
|
|
20
|
+
* crawler needs is hidden behind that control, because it is all here.
|
|
21
|
+
*
|
|
22
|
+
* The phone's own "show all" is CSS, not markup: every row is in the DOM and
|
|
23
|
+
* the ones past `collapsedCount` are hidden below `lg` until the button is
|
|
24
|
+
* pressed. A list that mounted its tail on tap would have taken twenty-six
|
|
25
|
+
* links out of the page for the crawler that never taps.
|
|
17
26
|
*
|
|
18
|
-
* Get this backwards — put the whole set behind the disclosure and only
|
|
19
|
-
* links in the markup — and the page silently stops linking to
|
|
20
|
-
*
|
|
27
|
+
* Get this backwards — put the whole set behind the disclosure and only eight
|
|
28
|
+
* links in the markup — and the page silently stops linking to thirty pages
|
|
29
|
+
* it is supposed to be the index for.
|
|
21
30
|
*
|
|
22
31
|
* ## Ordered by count, and the counts overlap
|
|
23
32
|
*
|
|
@@ -25,6 +34,10 @@ import type { MenuIndexEntry, MenuLinkComponent, MenuLinkProps } from "./menu-ty
|
|
|
25
34
|
* places, because a trattoria is Italian AND pizza AND international. So this
|
|
26
35
|
* is a ranked list of ways in, never a partition, and no copy near it may total
|
|
27
36
|
* the numbers or call them a breakdown.
|
|
37
|
+
*
|
|
38
|
+
* A client component for the one piece of state the expander needs; the
|
|
39
|
+
* `linkComponent` a server caller hands in must therefore be a client
|
|
40
|
+
* reference (`ClientLink`), not `next/link` itself.
|
|
28
41
|
*/
|
|
29
42
|
|
|
30
43
|
/** @deprecated Alias of `MenuLinkProps`; kept for symmetry with the other menu surfaces. */
|
|
@@ -40,8 +53,15 @@ export type MenuIndexListProps = {
|
|
|
40
53
|
title: string;
|
|
41
54
|
/** One line under it, e.g. "Ordered by kitchens — every one has its own page". */
|
|
42
55
|
subtitle?: string;
|
|
43
|
-
/**
|
|
44
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Rows shown on a phone before the expander; the rest are hidden below `lg`
|
|
58
|
+
* until it is pressed. Omit to show every row at every width.
|
|
59
|
+
*/
|
|
60
|
+
collapsedCount?: number;
|
|
61
|
+
/** The expander's labels, e.g. "Show all 38 cuisines" / "Show fewer". */
|
|
62
|
+
expandLabels?: { showAll: string; showFewer: string };
|
|
63
|
+
/** The colour mark beside each name, keyed by slug. Falls back to the border colour. */
|
|
64
|
+
hues?: Record<string, string>;
|
|
45
65
|
linkComponent?: MenuLinkComponent;
|
|
46
66
|
className?: string;
|
|
47
67
|
};
|
|
@@ -50,10 +70,16 @@ export function MenuIndexList({
|
|
|
50
70
|
entries,
|
|
51
71
|
title,
|
|
52
72
|
subtitle,
|
|
53
|
-
|
|
73
|
+
collapsedCount,
|
|
74
|
+
expandLabels,
|
|
75
|
+
hues,
|
|
54
76
|
linkComponent: LinkComponent = DefaultLink,
|
|
55
77
|
className,
|
|
56
78
|
}: MenuIndexListProps) {
|
|
79
|
+
const [expanded, setExpanded] = useState(false);
|
|
80
|
+
const collapsible =
|
|
81
|
+
collapsedCount !== undefined && expandLabels !== undefined && entries.length > collapsedCount;
|
|
82
|
+
|
|
57
83
|
return (
|
|
58
84
|
<section
|
|
59
85
|
className={cn(
|
|
@@ -68,55 +94,76 @@ export function MenuIndexList({
|
|
|
68
94
|
)}
|
|
69
95
|
</div>
|
|
70
96
|
|
|
71
|
-
{/*
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
layout the top-left row had no rule and the top-right one, its
|
|
84
|
-
own neighbour, did. Ruling every row is both uniform and simpler
|
|
85
|
-
than restoring the exception per breakpoint. */}
|
|
86
|
-
<Separator />
|
|
87
|
-
<LinkComponent
|
|
88
|
-
href={entry.href}
|
|
89
|
-
className="group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none"
|
|
97
|
+
{/* One column on a phone — a row holds a name of realistic length, its
|
|
98
|
+
count and the chevron with room to spare, and the expander keeps the
|
|
99
|
+
list to twelve rows until asked. Three from `lg`. */}
|
|
100
|
+
<ul className="grid grid-cols-1 lg:grid-cols-3 lg:gap-x-8">
|
|
101
|
+
{entries.map((entry, index) => {
|
|
102
|
+
const hue = hues?.[entry.slug];
|
|
103
|
+
return (
|
|
104
|
+
<li
|
|
105
|
+
key={entry.slug}
|
|
106
|
+
className={cn(
|
|
107
|
+
collapsible && !expanded && index >= collapsedCount && "max-lg:hidden"
|
|
108
|
+
)}
|
|
90
109
|
>
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
{/* The DS `Separator`, above every row including the first, so
|
|
111
|
+
the rule is uniform across columns. */}
|
|
112
|
+
<Separator />
|
|
113
|
+
<LinkComponent
|
|
114
|
+
href={entry.href}
|
|
115
|
+
className="group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none"
|
|
116
|
+
>
|
|
117
|
+
{/* The family's mark: a dot in its hue. */}
|
|
118
|
+
<span
|
|
119
|
+
aria-hidden
|
|
120
|
+
className={cn("h-2 w-2 shrink-0 rounded-full", !hue && !entry.gradient && "bg-border")}
|
|
121
|
+
style={
|
|
122
|
+
hue
|
|
123
|
+
? { backgroundColor: hue }
|
|
124
|
+
: entry.gradient
|
|
125
|
+
? { backgroundImage: entry.gradient }
|
|
126
|
+
: undefined
|
|
127
|
+
}
|
|
128
|
+
/>
|
|
129
|
+
<span className="min-w-0 flex-1 truncate text-sm font-medium">{entry.name}</span>
|
|
130
|
+
<span className="shrink-0 text-[13px] tabular-nums text-muted-foreground">
|
|
131
|
+
{entry.countLabel ?? String(entry.count)}
|
|
132
|
+
</span>
|
|
133
|
+
<ChevronRight
|
|
134
|
+
className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent motion-reduce:transition-none"
|
|
135
|
+
aria-hidden
|
|
136
|
+
/>
|
|
137
|
+
</LinkComponent>
|
|
138
|
+
</li>
|
|
139
|
+
);
|
|
140
|
+
})}
|
|
110
141
|
</ul>
|
|
142
|
+
|
|
143
|
+
{collapsible && (
|
|
144
|
+
<Button
|
|
145
|
+
variant="outline"
|
|
146
|
+
fullWidth
|
|
147
|
+
className="mt-2 lg:hidden"
|
|
148
|
+
aria-expanded={expanded}
|
|
149
|
+
onClick={() => setExpanded((open) => !open)}
|
|
150
|
+
>
|
|
151
|
+
{expanded ? expandLabels.showFewer : expandLabels.showAll}
|
|
152
|
+
<ChevronDown
|
|
153
|
+
className={cn("transition-transform motion-reduce:transition-none", expanded && "rotate-180")}
|
|
154
|
+
aria-hidden
|
|
155
|
+
/>
|
|
156
|
+
</Button>
|
|
157
|
+
)}
|
|
111
158
|
</section>
|
|
112
159
|
);
|
|
113
160
|
}
|
|
114
161
|
|
|
115
162
|
// ─── Skeleton ───────────────────────────────────────────────────────────────
|
|
116
163
|
|
|
117
|
-
/**
|
|
164
|
+
/** One column on a phone, three from `lg` — the grid the list itself renders. */
|
|
118
165
|
export function MenuIndexListSkeleton({
|
|
119
|
-
rows =
|
|
166
|
+
rows = 12,
|
|
120
167
|
className,
|
|
121
168
|
}: {
|
|
122
169
|
rows?: number;
|
|
@@ -132,12 +179,12 @@ export function MenuIndexListSkeleton({
|
|
|
132
179
|
>
|
|
133
180
|
<Skeleton className="h-[15px] w-[140px]" />
|
|
134
181
|
<Skeleton className="mt-1.5 h-3 w-[240px]" />
|
|
135
|
-
<ul className="mt-2 grid grid-cols-
|
|
182
|
+
<ul className="mt-2 grid grid-cols-1 lg:grid-cols-3 lg:gap-x-8">
|
|
136
183
|
{Array.from({ length: rows }, (_, index) => (
|
|
137
184
|
<li key={index}>
|
|
138
185
|
<Separator />
|
|
139
186
|
<div className="flex items-center gap-2.5 py-2.5">
|
|
140
|
-
<Skeleton className="h-2
|
|
187
|
+
<Skeleton className="h-2 w-2 rounded-full" />
|
|
141
188
|
<Skeleton className="h-3.5 flex-1" />
|
|
142
189
|
<Skeleton className="h-3 w-6" />
|
|
143
190
|
</div>
|
|
@@ -55,7 +55,7 @@ export type MenuIntentHeroProps = {
|
|
|
55
55
|
* Finished CSS gradient for the ground, e.g. `linear-gradient(140deg, …)`.
|
|
56
56
|
*
|
|
57
57
|
* A gradient rather than a token name, for the same reason `<MenuField>` and
|
|
58
|
-
* `<
|
|
58
|
+
* `<MenuFamilyTiles>` take one: the hue tables are app-owned, and a component
|
|
59
59
|
* referencing `var(--h-pizza)` renders an unstyled panel in any consumer that
|
|
60
60
|
* does not define it. Missing, the panel falls back to the foreground colour
|
|
61
61
|
* — visibly unstyled rather than white text on white.
|
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
MenuFilterChip,
|
|
26
26
|
MenuIndexEntry,
|
|
27
27
|
MenuIntentFigure,
|
|
28
|
-
|
|
28
|
+
MenuFamilyTile,
|
|
29
29
|
MenuItemPreview,
|
|
30
30
|
MenuKitchenPreview,
|
|
31
31
|
MenuLoosenOption,
|
|
@@ -372,18 +372,41 @@ export const mockDrinkFamily: MenuFamilyPreview = {
|
|
|
372
372
|
venue_photo_urls: [],
|
|
373
373
|
};
|
|
374
374
|
|
|
375
|
-
const
|
|
375
|
+
const familyGradient = (hue: number) =>
|
|
376
376
|
`linear-gradient(150deg, oklch(0.52 0.14 ${hue}), oklch(0.43 0.13 ${hue - 4}))`;
|
|
377
377
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
378
|
+
/** A venue photograph stand-in: a tinted room, not a plate. */
|
|
379
|
+
const familyPhoto = (hue: number) =>
|
|
380
|
+
"data:image/svg+xml;utf8," +
|
|
381
|
+
encodeURIComponent(
|
|
382
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue} 28% 60%)"/><stop offset="1" stop-color="hsl(${hue} 22% 26%)"/></linearGradient></defs><rect width="320" height="320" fill="url(#g)"/><circle cx="100" cy="110" r="56" fill="hsl(${hue} 40% 82%)" opacity=".55"/><rect x="170" y="190" width="120" height="90" fill="hsl(${hue} 15% 18%)" opacity=".7"/></svg>`
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The hub's seven cuisines, in the registry's order. The photograph is a
|
|
387
|
+
* venue's, never a plate's; the last one ships without, so the story shows
|
|
388
|
+
* the initial fallback beside six real tiles.
|
|
389
|
+
*/
|
|
390
|
+
export const mockFamilyTiles: MenuFamilyTile[] = [
|
|
391
|
+
{ slug: "italian", label: "Italian", countLabel: "66", href: "/menu/cuisines/italian", gradient: familyGradient(38), photoUrl: familyPhoto(30) },
|
|
392
|
+
{ slug: "romanian", label: "Romanian", countLabel: "64", href: "/menu/cuisines/romanian", gradient: familyGradient(25), photoUrl: familyPhoto(10) },
|
|
393
|
+
{ slug: "breakfast-brunch", label: "Breakfast & brunch", countLabel: "64", href: "/menu/cuisines/breakfast-brunch", gradient: familyGradient(82), photoUrl: familyPhoto(45) },
|
|
394
|
+
{ slug: "vegetarian", label: "Vegetarian", countLabel: "64", href: "/menu/cuisines/vegetarian", gradient: familyGradient(148), photoUrl: familyPhoto(120) },
|
|
395
|
+
{ slug: "pizza", label: "Pizza", countLabel: "63", href: "/menu/cuisines/pizza", gradient: familyGradient(58), photoUrl: familyPhoto(20) },
|
|
396
|
+
{ slug: "vegan", label: "Vegan", countLabel: "38", href: "/menu/cuisines/vegan", gradient: familyGradient(135), photoUrl: familyPhoto(100) },
|
|
397
|
+
{ slug: "bakery", label: "Bakeries & patisseries", countLabel: "65", href: "/menu/cuisines/bakery", gradient: familyGradient(72) },
|
|
385
398
|
];
|
|
386
399
|
|
|
400
|
+
/** The closing tile: stone, so it reads as the index and not as an eighth cuisine. */
|
|
401
|
+
export const mockFamilyTilesAll: MenuFamilyTile = {
|
|
402
|
+
slug: "all",
|
|
403
|
+
label: "All 38 cuisines",
|
|
404
|
+
countLabel: "The full index",
|
|
405
|
+
href: "/menu/cuisines",
|
|
406
|
+
gradient: "linear-gradient(150deg, oklch(0.50 0.03 70), oklch(0.40 0.028 64))",
|
|
407
|
+
photoUrl: familyPhoto(60),
|
|
408
|
+
};
|
|
409
|
+
|
|
387
410
|
/** Formatters the stories pass. Real callers inject locale-aware ones. */
|
|
388
411
|
export const mockFormatters = {
|
|
389
412
|
price: (value: number) => `${value} lei`,
|
|
@@ -134,6 +134,20 @@ export type MenuConceptRow = {
|
|
|
134
134
|
featured?: boolean
|
|
135
135
|
/** Portion, already formatted with its unit, e.g. "260g". */
|
|
136
136
|
portion_label?: string
|
|
137
|
+
/**
|
|
138
|
+
* What this room WON — "CHEAPEST", "MOST NAMED", "HIGHEST GRADE",
|
|
139
|
+
* "DEAREST" — already localised. A card whose rooms are four winners rather
|
|
140
|
+
* than four ranks sets one per room; without it the card falls back to
|
|
141
|
+
* "cheapest, then #2, #3, #4".
|
|
142
|
+
*/
|
|
143
|
+
badge?: string
|
|
144
|
+
/** Which figure the badge is about; that figure is emphasised. */
|
|
145
|
+
lead?: 'price' | 'mentions' | 'grade'
|
|
146
|
+
/**
|
|
147
|
+
* The venue's BearMenu grade, pre-formatted ("9.2"). Takes the star's
|
|
148
|
+
* place beside the kitchen's name; `rating` (Google's) shows only without it.
|
|
149
|
+
*/
|
|
150
|
+
grade?: string
|
|
137
151
|
}
|
|
138
152
|
|
|
139
153
|
/**
|
|
@@ -203,17 +217,21 @@ export type MenuVenueSignature = {
|
|
|
203
217
|
}
|
|
204
218
|
|
|
205
219
|
/**
|
|
206
|
-
*
|
|
220
|
+
* A family tile — a cuisine on the dishes tab, a going-out family on the
|
|
221
|
+
* drinks tab — as the hub's doorway grid draws it: a hue, a name, a venue
|
|
222
|
+
* count and one venue's photograph.
|
|
207
223
|
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
* rather than linking nowhere.
|
|
224
|
+
* `href` is required: a tile that cannot resolve to a real destination is not
|
|
225
|
+
* rendered rather than linking nowhere.
|
|
211
226
|
*/
|
|
212
|
-
export type
|
|
227
|
+
export type MenuFamilyTile = {
|
|
213
228
|
slug: string
|
|
214
229
|
label: string
|
|
215
|
-
/**
|
|
216
|
-
|
|
230
|
+
/**
|
|
231
|
+
* The venue count, already formatted by the caller — "64", "1,204" — because
|
|
232
|
+
* thousands separators are locale-dependent and the DS owns no locale.
|
|
233
|
+
*/
|
|
234
|
+
countLabel?: string
|
|
217
235
|
href: string
|
|
218
236
|
/**
|
|
219
237
|
* Finished CSS gradient for the tile's ground, e.g.
|
|
@@ -226,8 +244,8 @@ export type MenuIntentTile = {
|
|
|
226
244
|
* — visible and obviously unstyled, rather than white text on white.
|
|
227
245
|
*/
|
|
228
246
|
gradient?: string
|
|
229
|
-
/**
|
|
230
|
-
|
|
247
|
+
/** Pre-resolved VENUE photograph. Venue photography is real; item photography is not. */
|
|
248
|
+
photoUrl?: string
|
|
231
249
|
}
|
|
232
250
|
|
|
233
251
|
/**
|
|
@@ -351,6 +369,11 @@ export type MenuIndexEntry = {
|
|
|
351
369
|
slug: string
|
|
352
370
|
name: string
|
|
353
371
|
count: number
|
|
372
|
+
/**
|
|
373
|
+
* `count`, already formatted ("1,204"). The list is a client component and a
|
|
374
|
+
* formatter cannot cross into it from a server caller; a string can.
|
|
375
|
+
*/
|
|
376
|
+
countLabel?: string
|
|
354
377
|
href: string
|
|
355
378
|
/** Finished CSS gradient for the mark. Falls back to the border colour. */
|
|
356
379
|
gradient?: string
|
|
@@ -62,6 +62,12 @@ export type PhotoGroundProps = {
|
|
|
62
62
|
overlayClassName?: string;
|
|
63
63
|
/** How eagerly the first photograph loads. `stack` grounds are above the fold. */
|
|
64
64
|
loading?: "lazy" | "eager";
|
|
65
|
+
/**
|
|
66
|
+
* Classes on the fallback initial — its size, when the ground is smaller
|
|
67
|
+
* than a card. The 64px default fills a card cell; a tile's 88px polaroid
|
|
68
|
+
* passes `text-[44px]`.
|
|
69
|
+
*/
|
|
70
|
+
glyphClassName?: string;
|
|
65
71
|
className?: string;
|
|
66
72
|
};
|
|
67
73
|
|
|
@@ -76,6 +82,7 @@ export function PhotoGround({
|
|
|
76
82
|
overlay,
|
|
77
83
|
overlayClassName,
|
|
78
84
|
loading = "lazy",
|
|
85
|
+
glyphClassName,
|
|
79
86
|
className,
|
|
80
87
|
}: PhotoGroundProps) {
|
|
81
88
|
const initialOf = (index: number) =>
|
|
@@ -100,7 +107,12 @@ export function PhotoGround({
|
|
|
100
107
|
photograph is not a broken image, and drawing one as a placeholder
|
|
101
108
|
makes a real city look unfinished. */
|
|
102
109
|
<span className="absolute inset-0 flex items-center justify-center">
|
|
103
|
-
<span
|
|
110
|
+
<span
|
|
111
|
+
className={cn(
|
|
112
|
+
"font-serif text-[64px] font-semibold leading-none text-white/15",
|
|
113
|
+
glyphClassName
|
|
114
|
+
)}
|
|
115
|
+
>
|
|
104
116
|
{initialOf(index)}
|
|
105
117
|
</span>
|
|
106
118
|
</span>
|
|
@@ -74,12 +74,19 @@ export function VenueGradeDimensions({
|
|
|
74
74
|
<dd className="text-[15px] font-extrabold tracking-[-0.02em] tabular-nums">
|
|
75
75
|
{formatScore(value)}
|
|
76
76
|
</dd>
|
|
77
|
+
{/* The track is `currentColor` at low alpha rather than `bg-muted`:
|
|
78
|
+
on the dossier's ink `--muted` is within a hair of the ground
|
|
79
|
+
and the empty bar vanished. */}
|
|
77
80
|
<div
|
|
78
81
|
aria-hidden
|
|
79
|
-
className="relative block h-[3px] overflow-hidden rounded-full bg-
|
|
82
|
+
className="relative block h-[3px] overflow-hidden rounded-full bg-current/15"
|
|
80
83
|
>
|
|
84
|
+
{/* `bg-[image:…]`, not `bg-[…]`: `--gradient-primary` is a
|
|
85
|
+
linear-gradient, and as a background-COLOR it is an invalid
|
|
86
|
+
declaration the browser drops — the fill rendered at the right
|
|
87
|
+
width with no paint at all. */}
|
|
81
88
|
<span
|
|
82
|
-
className="absolute inset-y-0 left-0 rounded-full bg-[var(--gradient-primary
|
|
89
|
+
className="absolute inset-y-0 left-0 rounded-full bg-current bg-[image:var(--gradient-primary)]"
|
|
83
90
|
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
|
84
91
|
/>
|
|
85
92
|
</div>
|