@bearmenu/ui 0.8.10 → 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-UKBNQO6Q.js → chunk-2CXU5WZO.js} +1 -1
- package/dist/{chunk-62ES6N32.js → chunk-43RC7MHY.js} +1 -1
- package/dist/{chunk-Q6QH52LJ.js → chunk-6TEEY5YA.js} +2 -2
- package/dist/{chunk-UDN7UHJW.js → chunk-7A42J73Q.js} +3 -2
- package/dist/{chunk-5RWTOT5Y.js → chunk-CNJQUHCU.js} +11 -1
- package/dist/{chunk-TH6W2PWG.js → chunk-UDTG3RSE.js} +1 -1
- package/dist/{chunk-H3BV4VAG.js → chunk-VAXOQS7W.js} +14 -2
- package/dist/{chunk-SY6RV476.js → chunk-WCTHZKU7.js} +10 -21
- package/dist/{chunk-AKQ57NXY.js → chunk-ZQ53AGML.js} +2 -2
- package/dist/components/alert-dialog.js +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.js +1 -1
- package/dist/components/carousel.js +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/concept-compare-rail.js +2 -2
- package/dist/components/concept-compare.d.ts +1 -1
- package/dist/components/concept-compare.js +31 -10
- package/dist/components/concept-convergence.js +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/filter-category-row.js +1 -1
- package/dist/components/glass-ground.d.ts +26 -31
- package/dist/components/glass-ground.js +1 -1
- package/dist/components/haptic-button.js +1 -1
- package/dist/components/input-group.d.ts +2 -2
- package/dist/components/input.d.ts +1 -1
- package/dist/components/item.d.ts +15 -5
- package/dist/components/item.js +15 -10
- package/dist/components/kitchen-dossier.d.ts +9 -7
- package/dist/components/kitchen-dossier.js +31 -25
- package/dist/components/link-button.js +1 -1
- package/dist/components/menu-family-block.d.ts +22 -17
- package/dist/components/menu-family-block.js +62 -61
- package/dist/components/menu-family-card.js +2 -4
- 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 +18 -11
- 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-item.js +3 -3
- 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 +2 -2
- package/dist/components/pagination.js +1 -1
- package/dist/components/photo-ground.d.ts +8 -2
- package/dist/components/photo-ground.js +1 -1
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/searchable-select.js +1 -1
- package/dist/components/signature-chip.d.ts +2 -2
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/sliding-panels.js +1 -1
- package/dist/components/top-ranking-entry.js +3 -3
- package/dist/components/venue-grade-dimensions.js +1 -1
- package/dist/components/venue-quotes.d.ts +1 -1
- package/dist/components/venue-quotes.js +1 -1
- package/dist/components/venue-wall.js +5 -3
- package/dist/components/weekday-theme-banner.d.ts +24 -3
- package/dist/components/weekday-theme-banner.js +63 -15
- package/package.json +1 -1
- package/src/components/badge.tsx +1 -1
- package/src/components/button.stories.tsx +25 -2
- package/src/components/button.tsx +14 -2
- package/src/components/colour-dictionary.stories.tsx +1 -1
- package/src/components/concept-compare-rail.tsx +2 -1
- package/src/components/concept-compare.stories.tsx +3 -3
- package/src/components/concept-compare.tsx +50 -14
- package/src/components/glass-ground.tsx +31 -56
- package/src/components/item.tsx +69 -45
- package/src/components/kitchen-dossier.stories.tsx +1 -1
- package/src/components/kitchen-dossier.tsx +60 -40
- package/src/components/menu-family-block.stories.tsx +3 -3
- package/src/components/menu-family-block.tsx +94 -98
- package/src/components/menu-family-card.stories.tsx +2 -2
- package/src/components/menu-family-card.tsx +6 -13
- 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.test.tsx +39 -0
- package/src/components/menu-hub-hero.tsx +30 -12
- 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 +14 -2
- package/src/components/signature-chip.tsx +3 -3
- package/src/components/venue-grade-dimensions.tsx +9 -2
- package/src/components/venue-quotes.stories.tsx +1 -1
- package/src/components/venue-quotes.tsx +4 -2
- package/src/components/venue-wall.tsx +9 -4
- package/src/components/weekday-theme-banner.stories.tsx +43 -4
- package/src/components/weekday-theme-banner.tsx +95 -18
- 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
|
@@ -8,7 +8,7 @@ const labels = {
|
|
|
8
8
|
browse: "Browse Italian",
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
-
/** A stand-in photograph with enough variance that the
|
|
11
|
+
/** A stand-in photograph with enough variance that the ink has something to sit on. */
|
|
12
12
|
function photo(hue: number, label: string) {
|
|
13
13
|
return (
|
|
14
14
|
"data:image/svg+xml;utf8," +
|
|
@@ -35,7 +35,7 @@ const meta: Meta<typeof MenuFamilyCard> = {
|
|
|
35
35
|
docs: {
|
|
36
36
|
description: {
|
|
37
37
|
component:
|
|
38
|
-
"The hub's doorway to a family — a cuisine on the dishes tab, a drink family on the drinks tab. The same photo language as `MenuFamilyBlock` (the mosaic is the ground, wearing the family's hue;
|
|
38
|
+
"The hub's doorway to a family — a cuisine on the dishes tab, a drink family on the drinks tab. The same photo language as `MenuFamilyBlock` (the mosaic is the ground, wearing the family's hue; an ink gradient carries the type) drawn one step earlier, in a row of three, where the reader is choosing a family and not yet a venue: one number, one name, the groups the family consists of, and the `Browse` pill. It lists no venue. The count is VENUES: cuisine exists only on places, so a card counting dishes cannot be built. The whole card is one link.",
|
|
39
39
|
},
|
|
40
40
|
},
|
|
41
41
|
},
|
|
@@ -83,13 +83,6 @@ const MOSAIC_CELLS = 4;
|
|
|
83
83
|
/** The card's ground is `PhotoGround`'s, so the two cannot differ. */
|
|
84
84
|
const GROUND = PHOTO_GROUND;
|
|
85
85
|
|
|
86
|
-
/**
|
|
87
|
-
* The frost: absent over the top half, full at the foot, where the chips and
|
|
88
|
-
* the pill sit. The mask is what makes it a gradient of softness rather than
|
|
89
|
-
* a frosted pane — the rooms stay sharp behind the number.
|
|
90
|
-
*/
|
|
91
|
-
const FROST = "backdrop-blur-xl [mask-image:linear-gradient(to_bottom,transparent_28%,black_58%)]";
|
|
92
|
-
|
|
93
86
|
/**
|
|
94
87
|
* The ink. The count and the name sit at opposite ends of the card, so both
|
|
95
88
|
* ends are dark — a gradient that cleared in the middle looked better empty
|
|
@@ -137,10 +130,10 @@ export function MenuFamilyCard({
|
|
|
137
130
|
gradient={gradient}
|
|
138
131
|
/>
|
|
139
132
|
|
|
140
|
-
{/* ── Layer 2: the
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
133
|
+
{/* ── Layer 2: the ink ────────────────────────────────────────────── */}
|
|
134
|
+
{/* There was a masked backdrop-blur under this. Removed for the same
|
|
135
|
+
measurement that took it out of `GlassGround`: the type rests on the
|
|
136
|
+
ink, and the rooms stay sharp all the way down. */}
|
|
144
137
|
<span aria-hidden className={cn("absolute inset-0 -z-10", INK)} />
|
|
145
138
|
|
|
146
139
|
{/* ── The content, floating ───────────────────────────────────────── */}
|
|
@@ -179,10 +172,10 @@ export function MenuFamilyCard({
|
|
|
179
172
|
</span>
|
|
180
173
|
)}
|
|
181
174
|
|
|
182
|
-
{/* The action, as a
|
|
175
|
+
{/* The action, as a translucent pill on the photography — the block's
|
|
183
176
|
pill, so the two cards read as one family. A `span`, because the
|
|
184
177
|
card is already the link. */}
|
|
185
|
-
<span className="mt-3.5 inline-flex h-10 items-center rounded-full border border-white/20 bg-white/[0.
|
|
178
|
+
<span className="mt-3.5 inline-flex h-10 items-center rounded-full border border-white/20 bg-white/[0.16] px-4 text-[13.5px] font-bold text-white transition-colors group-hover:bg-white/25 motion-reduce:transition-none">
|
|
186
179
|
{labels.browse}
|
|
187
180
|
</span>
|
|
188
181
|
</span>
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render } from "@testing-library/react";
|
|
3
|
+
import { MenuHubHero } from "./menu-hub-hero";
|
|
4
|
+
import { VenueWall } from "./venue-wall";
|
|
5
|
+
|
|
6
|
+
const tiles = Array.from({ length: 12 }, (_, i) => `/tiles/${i}.webp`);
|
|
7
|
+
const mosaicImages = () => Array.from(document.querySelectorAll("img[loading='lazy']"));
|
|
8
|
+
|
|
9
|
+
// The phone shows six tiles; the rest are `hidden` below `lg` so a lazy
|
|
10
|
+
// still past the sixth is never requested. This is the request-count win
|
|
11
|
+
// measured on the consumer's menu page — pinned so a cell-count edit cannot
|
|
12
|
+
// silently put all twelve stills back on the phone's critical path.
|
|
13
|
+
describe("MenuHubHero mosaic", () => {
|
|
14
|
+
it("shows the first six tiles on the phone and hides the rest until lg", () => {
|
|
15
|
+
render(<MenuHubHero headline="Every kitchen" tileUrls={tiles} />);
|
|
16
|
+
const images = mosaicImages();
|
|
17
|
+
expect(images).toHaveLength(28);
|
|
18
|
+
images.slice(0, 6).forEach((img) => expect(img).not.toHaveClass("hidden"));
|
|
19
|
+
images.slice(6).forEach((img) => expect(img).toHaveClass("hidden", "lg:block"));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("cycles the tile list so the phone's six are the first six given", () => {
|
|
23
|
+
render(<MenuHubHero headline="Every kitchen" tileUrls={tiles.slice(0, 4)} />);
|
|
24
|
+
const srcs = mosaicImages()
|
|
25
|
+
.slice(0, 6)
|
|
26
|
+
.map((img) => img.getAttribute("src"));
|
|
27
|
+
expect(srcs).toEqual(["/tiles/0.webp", "/tiles/1.webp", "/tiles/2.webp", "/tiles/3.webp", "/tiles/0.webp", "/tiles/1.webp"]);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("VenueWall mosaic", () => {
|
|
32
|
+
it("follows the hero's rule: six on the phone, eighteen from lg", () => {
|
|
33
|
+
render(<VenueWall places={[]} headline="Every menu" tileUrls={tiles} />);
|
|
34
|
+
const images = mosaicImages();
|
|
35
|
+
expect(images).toHaveLength(18);
|
|
36
|
+
images.slice(0, 6).forEach((img) => expect(img).not.toHaveClass("hidden"));
|
|
37
|
+
images.slice(6).forEach((img) => expect(img).toHaveClass("hidden", "lg:block"));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -65,6 +65,13 @@ export type MenuHubHeroProps = {
|
|
|
65
65
|
|
|
66
66
|
/** Enough cells to fill the widest hero without the grid reading as a pattern. */
|
|
67
67
|
const TEXTURE_CELLS = 28;
|
|
68
|
+
/**
|
|
69
|
+
* The phone shows the first six, three across. Twenty-eight cells four across
|
|
70
|
+
* on a 375px hero were 90px tiles under a 90% scrim — a texture nobody could
|
|
71
|
+
* read as a room — and every one of the twelve stills downloaded to draw it.
|
|
72
|
+
* Six at ~125px are rooms again and cost six requests.
|
|
73
|
+
*/
|
|
74
|
+
const PHONE_CELLS = 6;
|
|
68
75
|
|
|
69
76
|
export function MenuHubHero({
|
|
70
77
|
kicker,
|
|
@@ -97,10 +104,12 @@ export function MenuHubHero({
|
|
|
97
104
|
) : (
|
|
98
105
|
<div
|
|
99
106
|
aria-hidden
|
|
100
|
-
className="absolute inset-0 grid auto-rows-fr grid-cols-
|
|
107
|
+
className="absolute inset-0 grid auto-rows-fr grid-cols-3 gap-0.5 lg:grid-cols-7"
|
|
101
108
|
>
|
|
102
109
|
{Array.from({ length: TEXTURE_CELLS }, (_, cell) => {
|
|
103
110
|
const url = tileUrls?.length ? tileUrls[cell % tileUrls.length] : undefined;
|
|
111
|
+
// `hidden` keeps a lazy image from ever being requested on the phone.
|
|
112
|
+
const phone = cell < PHONE_CELLS ? undefined : "hidden lg:block";
|
|
104
113
|
|
|
105
114
|
return url ? (
|
|
106
115
|
// eslint-disable-next-line @next/next/no-img-element
|
|
@@ -110,12 +119,15 @@ export function MenuHubHero({
|
|
|
110
119
|
alt=""
|
|
111
120
|
loading="lazy"
|
|
112
121
|
decoding="async"
|
|
113
|
-
className="h-full w-full object-cover"
|
|
122
|
+
className={cn("h-full w-full object-cover", phone)}
|
|
114
123
|
/>
|
|
115
124
|
) : (
|
|
116
125
|
<div
|
|
117
126
|
key={cell}
|
|
118
|
-
className=
|
|
127
|
+
className={cn(
|
|
128
|
+
"bg-[repeating-linear-gradient(135deg,oklch(0.6_0.02_55)_0_9px,oklch(0.68_0.016_58)_9px_18px)] opacity-[0.42]",
|
|
129
|
+
phone
|
|
130
|
+
)}
|
|
119
131
|
/>
|
|
120
132
|
);
|
|
121
133
|
})}
|
|
@@ -133,7 +145,7 @@ export function MenuHubHero({
|
|
|
133
145
|
className={cn(
|
|
134
146
|
"absolute inset-0",
|
|
135
147
|
tileUrls?.length || backgroundUrl
|
|
136
|
-
? "bg-[linear-gradient(100deg,oklch(0.16_0.012_55_/_0.96)_0%,oklch(0.16_0.012_55_/_0.
|
|
148
|
+
? "bg-[linear-gradient(100deg,oklch(0.16_0.012_55_/_0.96)_0%,oklch(0.16_0.012_55_/_0.86)_52%,oklch(0.16_0.012_55_/_0.6)_100%)]"
|
|
137
149
|
: "bg-[linear-gradient(100deg,oklch(0.18_0.012_55_/_0.95)_0%,oklch(0.18_0.012_55_/_0.72)_52%,oklch(0.18_0.012_55_/_0.3)_100%)]"
|
|
138
150
|
)}
|
|
139
151
|
/>
|
|
@@ -146,7 +158,9 @@ export function MenuHubHero({
|
|
|
146
158
|
</span>
|
|
147
159
|
)}
|
|
148
160
|
|
|
149
|
-
|
|
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]">
|
|
150
164
|
{headline}
|
|
151
165
|
</h1>
|
|
152
166
|
|
|
@@ -157,23 +171,27 @@ export function MenuHubHero({
|
|
|
157
171
|
)}
|
|
158
172
|
|
|
159
173
|
{figures && figures.length > 0 && (
|
|
160
|
-
//
|
|
161
|
-
|
|
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">
|
|
162
178
|
{figures.map((figure) => (
|
|
163
|
-
<div key={figure.label}>
|
|
164
|
-
<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]">
|
|
165
181
|
{figure.value}
|
|
166
182
|
</div>
|
|
167
|
-
<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>
|
|
168
186
|
</div>
|
|
169
187
|
))}
|
|
170
188
|
</div>
|
|
171
189
|
)}
|
|
172
190
|
|
|
173
191
|
{(proof || action) && (
|
|
174
|
-
<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">
|
|
175
193
|
{proof && (
|
|
176
|
-
<span className="inline-flex min-h-11 items-center gap-2.5 rounded-full bg-white/
|
|
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]">
|
|
177
195
|
{proofIcon}
|
|
178
196
|
{proof}
|
|
179
197
|
</span>
|
|
@@ -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">
|