@bearmenu/ui 0.8.8 → 0.8.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-24RFI4RQ.js +167 -0
- package/dist/chunk-2XCZJWTV.js +121 -0
- package/dist/chunk-5RWTOT5Y.js +72 -0
- package/dist/chunk-62ES6N32.js +27 -0
- package/dist/chunk-AKQ57NXY.js +58 -0
- package/dist/chunk-CLLY6XB3.js +41 -0
- package/dist/chunk-DTXNDLSE.js +125 -0
- package/dist/chunk-F6FMZJDG.js +113 -0
- package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
- package/dist/chunk-MZL7CGCL.js +109 -0
- package/dist/chunk-Q6QH52LJ.js +38 -0
- package/dist/chunk-SY6RV476.js +48 -0
- package/dist/chunk-TH6W2PWG.js +213 -0
- package/dist/chunk-UDN7UHJW.js +60 -0
- package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/concept-compare-rail.d.ts +50 -0
- package/dist/components/concept-compare-rail.js +5 -0
- package/dist/components/concept-compare.d.ts +110 -0
- package/dist/components/concept-compare.js +238 -0
- package/dist/components/concept-convergence.d.ts +41 -0
- package/dist/components/concept-convergence.js +53 -0
- package/dist/components/convergence.d.ts +25 -0
- package/dist/components/convergence.js +4 -0
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/event-card.js +1 -1
- package/dist/components/event-compact-row.d.ts +1 -1
- package/dist/components/event-compact-row.js +1 -1
- package/dist/components/event-detail-panel.d.ts +171 -25
- package/dist/components/event-detail-panel.js +472 -175
- package/dist/components/event-index-row.js +1 -1
- package/dist/components/event-list-row-wide.js +2 -2
- package/dist/components/event-list-row.js +2 -2
- package/dist/components/explainer-steps.d.ts +34 -0
- package/dist/components/explainer-steps.js +36 -0
- package/dist/components/glass-ground.d.ts +66 -0
- package/dist/components/glass-ground.js +3 -0
- package/dist/components/hub-surface.d.ts +27 -0
- package/dist/components/hub-surface.js +30 -0
- package/dist/components/hue-chip.d.ts +70 -0
- package/dist/components/hue-chip.js +3 -0
- package/dist/components/kitchen-dossier.d.ts +88 -0
- package/dist/components/kitchen-dossier.js +187 -0
- package/dist/components/menu-dead-end.d.ts +56 -0
- package/dist/components/menu-dead-end.js +49 -0
- package/dist/components/menu-family-block.d.ts +99 -0
- package/dist/components/menu-family-block.js +155 -0
- package/dist/components/menu-family-card.d.ts +65 -0
- package/dist/components/menu-family-card.js +79 -0
- package/dist/components/menu-field.d.ts +57 -0
- package/dist/components/menu-field.js +93 -0
- package/dist/components/menu-filter-chips.d.ts +86 -0
- package/dist/components/menu-filter-chips.js +122 -0
- package/dist/components/menu-group-rows.d.ts +53 -0
- package/dist/components/menu-group-rows.js +67 -0
- package/dist/components/menu-hub-hero.d.ts +65 -0
- package/dist/components/menu-hub-hero.js +103 -0
- package/dist/components/menu-index-header.d.ts +37 -0
- package/dist/components/menu-index-header.js +68 -0
- package/dist/components/menu-index-list.d.ts +48 -0
- package/dist/components/menu-index-list.js +96 -0
- package/dist/components/menu-intent-hero.d.ts +69 -0
- package/dist/components/menu-intent-hero.js +87 -0
- package/dist/components/menu-intent-tiles.d.ts +31 -0
- package/dist/components/menu-intent-tiles.js +50 -0
- package/dist/components/menu-item.d.ts +127 -0
- package/dist/components/menu-item.js +5 -0
- package/dist/components/menu-kitchen-list.d.ts +82 -0
- package/dist/components/menu-kitchen-list.js +5 -0
- package/dist/components/menu-mocks.d.ts +90 -0
- package/dist/components/menu-mocks.js +504 -0
- package/dist/components/menu-types.d.ts +421 -0
- package/dist/components/menu-types.js +1 -0
- package/dist/components/motion-icon-controls.js +2 -2
- package/dist/components/motion-icon-set.js +21 -9
- package/dist/components/motion-icon.d.ts +22 -1
- package/dist/components/motion-icon.js +1 -1
- package/dist/components/open-now-list.d.ts +40 -0
- package/dist/components/open-now-list.js +69 -0
- package/dist/components/photo-ground.d.ts +64 -0
- package/dist/components/photo-ground.js +3 -0
- package/dist/components/place-about-tab.js +2 -2
- package/dist/components/place-details-mobile.js +2 -2
- package/dist/components/place-types.d.ts +112 -1
- package/dist/components/qa-block.d.ts +36 -0
- package/dist/components/qa-block.js +24 -0
- package/dist/components/scroll-fade.js +3 -121
- package/dist/components/signature-chip.d.ts +37 -0
- package/dist/components/signature-chip.js +4 -0
- package/dist/components/snap-rail.d.ts +48 -5
- package/dist/components/snap-rail.js +3 -51
- package/dist/components/stat-banner.d.ts +47 -0
- package/dist/components/stat-banner.js +61 -0
- package/dist/components/story.d.ts +34 -0
- package/dist/components/story.js +3 -0
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +1 -1
- package/dist/components/top-comparison.d.ts +28 -0
- package/dist/components/top-comparison.js +76 -0
- package/dist/components/top-cross-link.d.ts +34 -0
- package/dist/components/top-cross-link.js +101 -0
- package/dist/components/top-ranking-entry.d.ts +53 -0
- package/dist/components/top-ranking-entry.js +217 -0
- package/dist/components/top-ranking-hero.d.ts +61 -0
- package/dist/components/top-ranking-hero.js +99 -0
- package/dist/components/top-ranking-legend.d.ts +69 -0
- package/dist/components/top-ranking-legend.js +124 -0
- package/dist/components/top-types.d.ts +109 -0
- package/dist/components/top-types.js +1 -0
- package/dist/components/venue-grade-dimensions.d.ts +38 -0
- package/dist/components/venue-grade-dimensions.js +3 -0
- package/dist/components/venue-quotes.d.ts +32 -0
- package/dist/components/venue-quotes.js +3 -0
- package/dist/components/venue-wall.d.ts +41 -0
- package/dist/components/venue-wall.js +118 -0
- package/dist/components/weekday-theme-banner.d.ts +70 -0
- package/dist/components/weekday-theme-banner.js +127 -0
- package/dist/components/weekend-panel.js +2 -2
- package/package.json +1 -1
- package/src/components/colour-dictionary.stories.tsx +227 -0
- package/src/components/concept-compare-rail.tsx +131 -0
- package/src/components/concept-compare.stories.tsx +172 -0
- package/src/components/concept-compare.tsx +443 -0
- package/src/components/concept-convergence.stories.tsx +53 -0
- package/src/components/concept-convergence.tsx +90 -0
- package/src/components/convergence.stories.tsx +54 -0
- package/src/components/convergence.tsx +177 -0
- package/src/components/event-compact-row.tsx +1 -1
- package/src/components/event-detail-panel.stories.tsx +205 -97
- package/src/components/event-detail-panel.test.tsx +181 -0
- package/src/components/event-detail-panel.tsx +703 -185
- package/src/components/explainer-steps.stories.tsx +46 -0
- package/src/components/explainer-steps.tsx +74 -0
- package/src/components/glass-ground.tsx +129 -0
- package/src/components/hub-surface.stories.tsx +68 -0
- package/src/components/hub-surface.tsx +50 -0
- package/src/components/hue-chip.stories.tsx +76 -0
- package/src/components/hue-chip.test.tsx +62 -0
- package/src/components/hue-chip.tsx +99 -0
- package/src/components/kitchen-dossier.stories.tsx +108 -0
- package/src/components/kitchen-dossier.tsx +322 -0
- package/src/components/menu-dead-end.stories.tsx +67 -0
- package/src/components/menu-dead-end.tsx +127 -0
- package/src/components/menu-family-block.stories.tsx +91 -0
- package/src/components/menu-family-block.tsx +314 -0
- package/src/components/menu-family-card.stories.tsx +120 -0
- package/src/components/menu-family-card.tsx +191 -0
- package/src/components/menu-field.stories.tsx +74 -0
- package/src/components/menu-field.tsx +199 -0
- package/src/components/menu-filter-chips.stories.tsx +107 -0
- package/src/components/menu-filter-chips.tsx +238 -0
- package/src/components/menu-group-rows.stories.tsx +54 -0
- package/src/components/menu-group-rows.tsx +133 -0
- package/src/components/menu-hub-hero.stories.tsx +92 -0
- package/src/components/menu-hub-hero.tsx +187 -0
- package/src/components/menu-index-header.stories.tsx +72 -0
- package/src/components/menu-index-header.tsx +125 -0
- package/src/components/menu-index-list.stories.tsx +50 -0
- package/src/components/menu-index-list.tsx +149 -0
- package/src/components/menu-intent-hero.stories.tsx +67 -0
- package/src/components/menu-intent-hero.tsx +189 -0
- package/src/components/menu-intent-tiles.stories.tsx +54 -0
- package/src/components/menu-intent-tiles.tsx +76 -0
- package/src/components/menu-item.tsx +416 -0
- package/src/components/menu-kitchen-list.stories.tsx +67 -0
- package/src/components/menu-kitchen-list.test.tsx +78 -0
- package/src/components/menu-kitchen-list.tsx +284 -0
- package/src/components/menu-mocks.ts +592 -0
- package/src/components/menu-types.ts +442 -0
- package/src/components/motion-icon-controls.tsx +11 -11
- package/src/components/motion-icon-set.tsx +36 -8
- package/src/components/motion-icon.test.tsx +23 -1
- package/src/components/motion-icon.tsx +20 -0
- package/src/components/open-now-list.stories.tsx +76 -0
- package/src/components/open-now-list.tsx +127 -0
- package/src/components/photo-ground.tsx +147 -0
- package/src/components/place-types.ts +112 -0
- package/src/components/qa-block.stories.tsx +43 -0
- package/src/components/qa-block.tsx +60 -0
- package/src/components/signature-chip.tsx +77 -0
- package/src/components/snap-rail.tsx +133 -9
- package/src/components/stat-banner.stories.tsx +100 -0
- package/src/components/stat-banner.tsx +117 -0
- package/src/components/story.tsx +225 -0
- package/src/components/top-comparison.stories.tsx +78 -0
- package/src/components/top-comparison.tsx +151 -0
- package/src/components/top-cross-link.stories.tsx +84 -0
- package/src/components/top-cross-link.tsx +171 -0
- package/src/components/top-ranking-entry.stories.tsx +171 -0
- package/src/components/top-ranking-entry.test.tsx +105 -0
- package/src/components/top-ranking-entry.tsx +425 -0
- package/src/components/top-ranking-hero.stories.tsx +76 -0
- package/src/components/top-ranking-hero.tsx +218 -0
- package/src/components/top-ranking-legend.stories.tsx +78 -0
- package/src/components/top-ranking-legend.tsx +239 -0
- package/src/components/top-types.ts +110 -0
- package/src/components/venue-grade-dimensions.stories.tsx +83 -0
- package/src/components/venue-grade-dimensions.tsx +91 -0
- package/src/components/venue-quotes.stories.tsx +59 -0
- package/src/components/venue-quotes.tsx +64 -0
- package/src/components/venue-wall.stories.tsx +78 -0
- package/src/components/venue-wall.tsx +185 -0
- package/src/components/weekday-theme-banner.stories.tsx +113 -0
- package/src/components/weekday-theme-banner.tsx +231 -0
- package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { MenuFamilyBlock } from "./menu-family-block";
|
|
3
|
+
import { mockBareFamilyBlock, mockFamilyBlock, mockFormatters, mockGradients } from "./menu-mocks";
|
|
4
|
+
|
|
5
|
+
const labels = {
|
|
6
|
+
venueUnit: (count: number) => `${count} kitchens`,
|
|
7
|
+
more: (count: number) => `+${count} more`,
|
|
8
|
+
priceBand: "kitchen price band",
|
|
9
|
+
browse: "Browse Italian",
|
|
10
|
+
kitchens: {
|
|
11
|
+
openVenue: (name: string) => `Open ${name}`,
|
|
12
|
+
openDish: (name: string) => `See ${name}`,
|
|
13
|
+
openNow: "Open now",
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const meta: Meta<typeof MenuFamilyBlock> = {
|
|
18
|
+
title: "Menu/MenuFamilyBlock",
|
|
19
|
+
component: MenuFamilyBlock,
|
|
20
|
+
tags: ["autodocs"],
|
|
21
|
+
args: {
|
|
22
|
+
family: mockFamilyBlock,
|
|
23
|
+
labels,
|
|
24
|
+
formatCount: mockFormatters.number,
|
|
25
|
+
hue: "oklch(0.56 0.15 38)",
|
|
26
|
+
gradient: mockGradients.pasta,
|
|
27
|
+
},
|
|
28
|
+
decorators: [
|
|
29
|
+
(Story) => (
|
|
30
|
+
<div className="w-[900px] bg-background p-4">
|
|
31
|
+
<Story />
|
|
32
|
+
</div>
|
|
33
|
+
),
|
|
34
|
+
],
|
|
35
|
+
parameters: {
|
|
36
|
+
docs: {
|
|
37
|
+
description: {
|
|
38
|
+
component:
|
|
39
|
+
"The cuisines index's family card. **The mosaic is the ground**: the grid of venue photographs is the background for the entire card, and everything floats on it through three layers — the mosaic wearing the family's hue, a masked `backdrop-filter` that is absent where the photography is the subject and full where the rows begin, and an ink gradient that buys the contrast frost alone cannot. Phone: top to bottom. Desktop: the same layers turned ninety degrees, photo column clear on the left, rows frosted on the right, mosaic 3×2 rather than 2×2. The content region is `.dark`-scoped, which is how `MenuKitchenList` and `HueChip` render on the ink without a fork. Two costs accepted: a masked backdrop-filter repeated ten times down a scroll, and thumbnails that sit on a photograph and survive only because the ink is heavy under them.",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export default meta;
|
|
46
|
+
type Story = StoryObj<typeof MenuFamilyBlock>;
|
|
47
|
+
|
|
48
|
+
/** A stand-in photograph with enough variance that the frost has something to soften. */
|
|
49
|
+
function photo(hue: number, label: string) {
|
|
50
|
+
return (
|
|
51
|
+
"data:image/svg+xml;utf8," +
|
|
52
|
+
encodeURIComponent(
|
|
53
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue} 30% 62%)"/><stop offset="1" stop-color="hsl(${hue} 20% 28%)"/></linearGradient></defs><rect width="480" height="320" fill="url(#g)"/><circle cx="120" cy="90" r="60" fill="hsl(${hue} 40% 80%)" opacity=".6"/><rect x="260" y="180" width="180" height="110" fill="hsl(${hue} 15% 20%)" opacity=".7"/><text x="50%" y="88%" font-family="monospace" font-size="18" fill="#fff" text-anchor="middle">${label}</text></svg>`
|
|
54
|
+
)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const PHOTOS = [40, 200, 90, 300, 20, 150].map((h, i) => photo(h, `venue ${i + 1}`));
|
|
59
|
+
|
|
60
|
+
export const Default: Story = {
|
|
61
|
+
args: { family: { ...mockFamilyBlock, photoUrls: PHOTOS } },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* No photographs: the mosaic ground with the family's initial, never a grey box.
|
|
66
|
+
* A missing photograph is not a broken image, and a placeholder makes a real
|
|
67
|
+
* city look unfinished.
|
|
68
|
+
*/
|
|
69
|
+
export const NoPhotos: Story = {
|
|
70
|
+
args: { family: { ...mockFamilyBlock, photoUrls: [] } },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** No photographs, no venue, no price — one kitchen in the whole city. */
|
|
74
|
+
export const Bare: Story = {
|
|
75
|
+
args: {
|
|
76
|
+
family: mockBareFamilyBlock,
|
|
77
|
+
labels: { ...labels, browse: "Browse Portuguese" },
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Frost and ink run top to bottom: clear photography under the name, the rows on the frosted foot. */
|
|
82
|
+
export const Mobile: Story = {
|
|
83
|
+
args: { family: { ...mockFamilyBlock, photoUrls: PHOTOS } },
|
|
84
|
+
decorators: [
|
|
85
|
+
(Story) => (
|
|
86
|
+
<div className="w-[390px] bg-background p-3">
|
|
87
|
+
<Story />
|
|
88
|
+
</div>
|
|
89
|
+
),
|
|
90
|
+
],
|
|
91
|
+
};
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { Wallet } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { HueChip } from "./hue-chip";
|
|
4
|
+
import { PHOTO_GROUND, PhotoGround } from "./photo-ground";
|
|
5
|
+
import { Separator } from "./separator";
|
|
6
|
+
import { Skeleton } from "./skeleton";
|
|
7
|
+
import { MenuKitchenList, type MenuKitchenListLabels } from "./menu-kitchen-list";
|
|
8
|
+
import type {
|
|
9
|
+
MenuFamilyBlockPreview,
|
|
10
|
+
MenuLinkComponent,
|
|
11
|
+
MenuLinkProps,
|
|
12
|
+
} from "./menu-types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* MenuFamilyBlock — the cuisines index's family card. The mosaic is the ground.
|
|
16
|
+
*
|
|
17
|
+
* ## Three layers, and everything floats on them
|
|
18
|
+
*
|
|
19
|
+
* The grid of venue photographs is the background for the ENTIRE card, edge to
|
|
20
|
+
* edge, and the facts sit on it. Three layers do the work:
|
|
21
|
+
*
|
|
22
|
+
* 1. **The mosaic**, full-bleed, wearing the family's hue through a
|
|
23
|
+
* `mix-blend-color` layer.
|
|
24
|
+
* 2. **A masked backdrop-filter** — absent where the photography is the
|
|
25
|
+
* subject, full where the rows begin — so the pictures stay sharp where
|
|
26
|
+
* they are looked at and go soft exactly where they are read over.
|
|
27
|
+
* 3. **An ink gradient** over that. This is what actually buys the contrast:
|
|
28
|
+
* frost alone blurs without darkening, and blurred mid-tones will not carry
|
|
29
|
+
* 16px type.
|
|
30
|
+
*
|
|
31
|
+
* On the phone the frost and ink run top to bottom: the name and the action
|
|
32
|
+
* sit on clear photography, the chips and the rows on the frosted foot. On
|
|
33
|
+
* desktop the same three layers turn ninety degrees — the photo column on the
|
|
34
|
+
* left stays clear, the row column on the right is the frosted one. The mosaic
|
|
35
|
+
* runs 3×2 there rather than 2×2, because at 1110px a four-tile grid makes each
|
|
36
|
+
* photograph too big to read as texture.
|
|
37
|
+
*
|
|
38
|
+
* ## The dark scope
|
|
39
|
+
*
|
|
40
|
+
* The card's content region carries the `dark` class, so every token inside —
|
|
41
|
+
* `text-foreground`, `bg-muted`, `border-border` — resolves to its dark-theme
|
|
42
|
+
* value. That is how `MenuKitchenList` and `HueChip` render correctly on the
|
|
43
|
+
* ink without a fork or a "tone" prop: the mechanism the app already uses to
|
|
44
|
+
* mark a region as dark. It also means the card looks the same in both themes,
|
|
45
|
+
* which is right — it is built on photography, and photography is not themed.
|
|
46
|
+
*
|
|
47
|
+
* ## The two costs, stated
|
|
48
|
+
*
|
|
49
|
+
* A `backdrop-filter` over a mask is expensive to composite, and this card is
|
|
50
|
+
* repeated ten times down a scroll. And the thumbnails now sit on a
|
|
51
|
+
* photograph; they survive only because the ink is heavy under them, and on a
|
|
52
|
+
* busy interior shot they will fight. Both were accepted with the design.
|
|
53
|
+
*
|
|
54
|
+
* ## No "most named" item, no venue count badge
|
|
55
|
+
*
|
|
56
|
+
* The family-level "most named" was the first venue's signature relabelled —
|
|
57
|
+
* Italian, Pizza and Mediterranean all claimed "Turkish Poached Eggs" — and
|
|
58
|
+
* the count used to print three times with two values. One count, under the
|
|
59
|
+
* name; the overflow tile says only how many photographs it stands for.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/** @deprecated Alias of `MenuLinkProps`; kept for symmetry with the other menu surfaces. */
|
|
63
|
+
export type MenuFamilyBlockLinkProps = MenuLinkProps;
|
|
64
|
+
|
|
65
|
+
function DefaultLink({ href, ...props }: MenuFamilyBlockLinkProps) {
|
|
66
|
+
return <a href={href} {...props} />;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type MenuFamilyBlockLabels = {
|
|
70
|
+
/** Unit under the family name, e.g. "247 kitchens". Pluralised by the caller. */
|
|
71
|
+
venueUnit: (count: number) => string;
|
|
72
|
+
/** The overflow pill over the mosaic, e.g. "+311 more". */
|
|
73
|
+
more: (count: number) => string;
|
|
74
|
+
/**
|
|
75
|
+
* Price-band caption, e.g. "kitchen price band".
|
|
76
|
+
*
|
|
77
|
+
* Never drawn: the band reads as a wallet and a range, and a caption on a
|
|
78
|
+
* pill that short is longer than the fact. It names the pill for a screen
|
|
79
|
+
* reader, which would otherwise be read two numbers and a currency.
|
|
80
|
+
*/
|
|
81
|
+
priceBand: string;
|
|
82
|
+
/** Main action, e.g. "Browse Italian". Sits on the photography — keep it short. */
|
|
83
|
+
browse: string;
|
|
84
|
+
/** Passed through to the venue rows. */
|
|
85
|
+
kitchens: MenuKitchenListLabels;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type MenuFamilyBlockProps = {
|
|
89
|
+
family: MenuFamilyBlockPreview;
|
|
90
|
+
labels: MenuFamilyBlockLabels;
|
|
91
|
+
formatCount?: (value: number) => string;
|
|
92
|
+
/** Finished CSS colour for the chips. */
|
|
93
|
+
hue?: string;
|
|
94
|
+
/** Finished CSS gradient — the family's colour over the mosaic. */
|
|
95
|
+
gradient?: string;
|
|
96
|
+
linkComponent?: MenuLinkComponent;
|
|
97
|
+
className?: string;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** 2×2 on the phone, 3×2 from `lg`. Tiles past the fourth are desktop-only. */
|
|
101
|
+
const MOSAIC_TILES = 6;
|
|
102
|
+
const PHONE_TILES = 4;
|
|
103
|
+
|
|
104
|
+
/** The card's ground is `PhotoGround`'s, so the two cannot differ. */
|
|
105
|
+
const GROUND = PHOTO_GROUND;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The frost: absent at the top, full at the foot on the phone; absent on the
|
|
109
|
+
* left, full on the right from `lg`. The mask is what makes it a gradient of
|
|
110
|
+
* softness rather than a frosted pane — the subject stays sharp.
|
|
111
|
+
*/
|
|
112
|
+
const FROST =
|
|
113
|
+
"backdrop-blur-xl [mask-image:linear-gradient(to_bottom,transparent_0%,black_42%)] lg:[mask-image:linear-gradient(to_right,transparent_36%,black_54%)]";
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The ink. Same direction as the frost, heavier at the foot, and it is the
|
|
117
|
+
* layer the contrast rests on. Literal rather than `var(--scrim)`: that token
|
|
118
|
+
* lives in the app's stylesheet, not this package's, so a DS component reading
|
|
119
|
+
* it renders nothing in Storybook and the other three consumers.
|
|
120
|
+
*/
|
|
121
|
+
const INK =
|
|
122
|
+
"bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.42)_0%,oklch(0.16_0.012_55_/_0.6)_30%,oklch(0.16_0.012_55_/_0.9)_48%,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%)]";
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A second, LOCAL scrim under the name, count and action — the photo column's
|
|
126
|
+
* own, rising from its foot.
|
|
127
|
+
*
|
|
128
|
+
* The global ink is deliberately light where the photography is the subject,
|
|
129
|
+
* and on a bright, warm-tinted picture (a bakery counter under the Romanian
|
|
130
|
+
* red) 30px of white serif sat at about 2.5:1 on it. Darkening the whole ink
|
|
131
|
+
* would have dulled every photograph to fix a corner of some; this darkens
|
|
132
|
+
* only the band the type actually occupies, and leaves the top of the picture
|
|
133
|
+
* clear. Same base colour as the ink, so the two read as one shadow.
|
|
134
|
+
*/
|
|
135
|
+
const TEXT_SCRIM =
|
|
136
|
+
/* `isolate` + `before:-z-10`: a positioned pseudo-element paints ABOVE the
|
|
137
|
+
column's in-flow text unless the column is its own stacking context and
|
|
138
|
+
the layer is ordered beneath. Without `isolate`, `-z-10` would instead
|
|
139
|
+
drop it behind the card's mosaic — inside the article's context. */
|
|
140
|
+
"isolate before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:top-[30%] before:-z-10 before:bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0)_0%,oklch(0.16_0.012_55_/_0.55)_45%,oklch(0.16_0.012_55_/_0.8)_100%)] before:content-['']";
|
|
141
|
+
|
|
142
|
+
export function MenuFamilyBlock({
|
|
143
|
+
family,
|
|
144
|
+
labels,
|
|
145
|
+
formatCount = String,
|
|
146
|
+
hue,
|
|
147
|
+
gradient,
|
|
148
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
149
|
+
className,
|
|
150
|
+
}: MenuFamilyBlockProps) {
|
|
151
|
+
const photos = (family.photoUrls ?? []).slice(0, MOSAIC_TILES);
|
|
152
|
+
const overflow = Math.max(family.venueCount - photos.length, 0);
|
|
153
|
+
const initial = (family.name.match(/\p{L}/u)?.[0] ?? "").toLocaleUpperCase();
|
|
154
|
+
const venues = family.venues ?? [];
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<article
|
|
158
|
+
className={cn(
|
|
159
|
+
/* `isolate` confines the blend and the backdrop-filter to this card.
|
|
160
|
+
`dark` scopes every token inside to its dark-theme value. */
|
|
161
|
+
"dark relative isolate overflow-hidden rounded-[14px] text-foreground",
|
|
162
|
+
GROUND,
|
|
163
|
+
className
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
{/* ── Layer 1: the photographs, wearing the family's colour ──────── */}
|
|
167
|
+
{/* A 2×2 on the phone, 3×2 from `lg`: at 1110px a four-tile grid makes
|
|
168
|
+
each photograph too big to read as texture. With no photographs at
|
|
169
|
+
all, one slot with the family's initial. */}
|
|
170
|
+
<PhotoGround
|
|
171
|
+
photos={photos.length > 0 ? photos : [undefined]}
|
|
172
|
+
initials={initial}
|
|
173
|
+
mosaicClassName={
|
|
174
|
+
photos.length > 0 ? "grid-cols-2 grid-rows-2 lg:grid-cols-3" : "grid-cols-1"
|
|
175
|
+
}
|
|
176
|
+
phoneTiles={PHONE_TILES}
|
|
177
|
+
gradient={gradient}
|
|
178
|
+
/>
|
|
179
|
+
|
|
180
|
+
{/* ── Layer 2: the frost ──────────────────────────────────────────── */}
|
|
181
|
+
<span aria-hidden className={cn("absolute inset-0", FROST)} />
|
|
182
|
+
|
|
183
|
+
{/* ── Layer 3: the ink ────────────────────────────────────────────── */}
|
|
184
|
+
<span aria-hidden className={cn("absolute inset-0", INK)} />
|
|
185
|
+
|
|
186
|
+
{/* ── The content, floating ───────────────────────────────────────── */}
|
|
187
|
+
<div className="relative grid grid-cols-1 lg:grid-cols-[38%_1fr]">
|
|
188
|
+
{/* The photo column. On the phone it is the card's head; on desktop
|
|
189
|
+
the left third, with its stack anchored to the foot. */}
|
|
190
|
+
<div className={cn("relative flex flex-col p-3.5 lg:min-h-[320px] lg:justify-end lg:p-5", TEXT_SCRIM)}>
|
|
191
|
+
{overflow > 0 && (
|
|
192
|
+
<span className="mb-16 self-end rounded-full bg-[oklch(0.16_0.012_55_/_0.72)] px-2.5 py-1 text-[11px] font-semibold text-white backdrop-blur-[2px] lg:mb-3 lg:self-start">
|
|
193
|
+
{labels.more(overflow)}
|
|
194
|
+
</span>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
<h3 className="text-balance font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white lg:text-[30px]">
|
|
198
|
+
{family.name}
|
|
199
|
+
</h3>
|
|
200
|
+
<span className="mt-0.5 block text-[13px] font-medium tabular-nums text-white/70">
|
|
201
|
+
{labels.venueUnit(family.venueCount)}
|
|
202
|
+
</span>
|
|
203
|
+
|
|
204
|
+
{/* The action, as a frosted pill on the photography. `bg-white/12`
|
|
205
|
+
on the ink rather than `bg-accent`: ten filled accent buttons on
|
|
206
|
+
one page is ten primary actions, and the pill reads as part of the
|
|
207
|
+
picture rather than as a control laid on top of it.
|
|
208
|
+
|
|
209
|
+
No glyph. An arrow read as "leaves the site" beside a label that
|
|
210
|
+
already says where it goes, and a chevron would have made the pill
|
|
211
|
+
one more row: every venue row to its right ends in exactly that
|
|
212
|
+
chevron. The shape and the label are the whole affordance. */}
|
|
213
|
+
<LinkComponent
|
|
214
|
+
href={family.href}
|
|
215
|
+
className="mt-3.5 inline-flex h-10 w-max items-center rounded-full border border-white/20 bg-white/[0.12] px-4 text-[13.5px] font-bold text-white backdrop-blur-md transition-colors hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white motion-reduce:transition-none"
|
|
216
|
+
>
|
|
217
|
+
{labels.browse}
|
|
218
|
+
</LinkComponent>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
{/* The frosted column: chips, then the rows. */}
|
|
222
|
+
<div className="flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5">
|
|
223
|
+
<ul className="flex flex-wrap items-center gap-1.5">
|
|
224
|
+
{family.priceBandLabel && (
|
|
225
|
+
/* No hue: a fact about money, not about the family. In the dark
|
|
226
|
+
scope `HueChip`'s `--muted` fallback is the dark pill the design
|
|
227
|
+
draws. */
|
|
228
|
+
<HueChip
|
|
229
|
+
as="li"
|
|
230
|
+
className="font-bold tabular-nums"
|
|
231
|
+
aria-label={`${labels.priceBand}: ${family.priceBandLabel}`}
|
|
232
|
+
icon={<Wallet className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />}
|
|
233
|
+
>
|
|
234
|
+
{family.priceBandLabel}
|
|
235
|
+
</HueChip>
|
|
236
|
+
)}
|
|
237
|
+
{family.chips?.map((chip) => (
|
|
238
|
+
/* Label only. The counts are VENUES beside a dish group's name,
|
|
239
|
+
which every reader takes for dishes. */
|
|
240
|
+
<HueChip key={chip.slug} as="li" hue={hue}>
|
|
241
|
+
{chip.label}
|
|
242
|
+
</HueChip>
|
|
243
|
+
))}
|
|
244
|
+
</ul>
|
|
245
|
+
|
|
246
|
+
{venues.length > 0 && (
|
|
247
|
+
<>
|
|
248
|
+
<Separator className="my-3" />
|
|
249
|
+
<MenuKitchenList
|
|
250
|
+
kitchens={venues}
|
|
251
|
+
labels={labels.kitchens}
|
|
252
|
+
linkComponent={LinkComponent}
|
|
253
|
+
/>
|
|
254
|
+
</>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
</article>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ─── Skeleton ───────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The block's own loading state, at the block's footprint.
|
|
266
|
+
*
|
|
267
|
+
* The ground is the card's own, so the placeholder is already the dark
|
|
268
|
+
* surface the photographs will land on — nothing flashes from paper to ink.
|
|
269
|
+
* `venues` matches the rows the caller will render, so a block showing three
|
|
270
|
+
* does not settle from a placeholder showing one.
|
|
271
|
+
*/
|
|
272
|
+
export function MenuFamilyBlockSkeleton({
|
|
273
|
+
venues = 3,
|
|
274
|
+
className,
|
|
275
|
+
}: {
|
|
276
|
+
venues?: number;
|
|
277
|
+
className?: string;
|
|
278
|
+
}) {
|
|
279
|
+
return (
|
|
280
|
+
<div
|
|
281
|
+
className={cn("dark relative overflow-hidden rounded-[14px]", GROUND, className)}
|
|
282
|
+
aria-hidden
|
|
283
|
+
>
|
|
284
|
+
<div className="grid grid-cols-1 lg:grid-cols-[38%_1fr]">
|
|
285
|
+
<div className="flex flex-col p-3.5 lg:min-h-[320px] lg:justify-end lg:p-5">
|
|
286
|
+
<Skeleton className="mb-16 h-6 w-[72px] self-end rounded-full lg:mb-3 lg:self-start" />
|
|
287
|
+
<Skeleton className="h-7 w-[60%]" />
|
|
288
|
+
<Skeleton className="mt-2 h-3.5 w-[90px]" />
|
|
289
|
+
<Skeleton className="mt-3.5 h-10 w-[160px] rounded-full" />
|
|
290
|
+
</div>
|
|
291
|
+
<div className="flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5">
|
|
292
|
+
<div className="flex gap-1.5">
|
|
293
|
+
<Skeleton className="h-7 w-[86px] rounded-full" />
|
|
294
|
+
<Skeleton className="h-7 w-[74px] rounded-full" />
|
|
295
|
+
<Skeleton className="h-7 w-[66px] rounded-full" />
|
|
296
|
+
</div>
|
|
297
|
+
<Separator className="my-3" />
|
|
298
|
+
{Array.from({ length: venues }, (_, index) => (
|
|
299
|
+
<div key={index}>
|
|
300
|
+
{index > 0 && <Separator />}
|
|
301
|
+
<div className="flex items-center gap-3 py-2.5">
|
|
302
|
+
<Skeleton className="h-[46px] w-[46px] rounded-[11px]" />
|
|
303
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
304
|
+
<Skeleton className="h-3.5 w-[46%]" />
|
|
305
|
+
<Skeleton className="h-3 w-[58%]" />
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
))}
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { MenuFamilyCard } from "./menu-family-card";
|
|
3
|
+
import { mockDrinkFamily, mockFamily, mockGradients } from "./menu-mocks";
|
|
4
|
+
|
|
5
|
+
const labels = {
|
|
6
|
+
venueUnit: "kitchens",
|
|
7
|
+
priceBand: "kitchen price band",
|
|
8
|
+
browse: "Browse Italian",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** A stand-in photograph with enough variance that the frost has something to soften. */
|
|
12
|
+
function photo(hue: number, label: string) {
|
|
13
|
+
return (
|
|
14
|
+
"data:image/svg+xml;utf8," +
|
|
15
|
+
encodeURIComponent(
|
|
16
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue} 30% 62%)"/><stop offset="1" stop-color="hsl(${hue} 20% 28%)"/></linearGradient></defs><rect width="320" height="240" fill="url(#g)"/><circle cx="80" cy="70" r="44" fill="hsl(${hue} 40% 80%)" opacity=".6"/><rect x="170" y="130" width="120" height="80" fill="hsl(${hue} 15% 20%)" opacity=".7"/><text x="50%" y="88%" font-family="monospace" font-size="16" fill="#fff" text-anchor="middle">${label}</text></svg>`
|
|
17
|
+
)
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const PHOTOS = [40, 200, 90, 300].map((h, i) => photo(h, `venue ${i + 1}`));
|
|
22
|
+
|
|
23
|
+
const meta: Meta<typeof MenuFamilyCard> = {
|
|
24
|
+
title: "Menu/MenuFamilyCard",
|
|
25
|
+
component: MenuFamilyCard,
|
|
26
|
+
tags: ["autodocs"],
|
|
27
|
+
decorators: [
|
|
28
|
+
(Story) => (
|
|
29
|
+
<div className="w-[360px] bg-background p-4">
|
|
30
|
+
<Story />
|
|
31
|
+
</div>
|
|
32
|
+
),
|
|
33
|
+
],
|
|
34
|
+
parameters: {
|
|
35
|
+
docs: {
|
|
36
|
+
description: {
|
|
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; a masked frost and an ink gradient carry 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
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export default meta;
|
|
45
|
+
type Story = StoryObj<typeof MenuFamilyCard>;
|
|
46
|
+
|
|
47
|
+
export const Default: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
family: { ...mockFamily, venue_photo_urls: PHOTOS },
|
|
50
|
+
labels,
|
|
51
|
+
href: "/menu/cuisines/italian",
|
|
52
|
+
hue: "oklch(0.55 0.15 40)",
|
|
53
|
+
gradient: mockGradients.pasta,
|
|
54
|
+
priceBandLabel: "32–84 lei",
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** The drinks tab. Same card, different unit and vocabulary. */
|
|
59
|
+
export const DrinkFamily: Story = {
|
|
60
|
+
args: {
|
|
61
|
+
family: { ...mockDrinkFamily, venue_photo_urls: PHOTOS },
|
|
62
|
+
labels: { venueUnit: "bars", priceBand: "bar price band", browse: "Browse cocktails" },
|
|
63
|
+
href: "/menu/drinks/cocktail",
|
|
64
|
+
hue: "oklch(0.5 0.13 240)",
|
|
65
|
+
gradient: mockGradients.cocktail,
|
|
66
|
+
priceBandLabel: "18–54 lei",
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** A one-venue cuisine: the count is honest, and the mosaic repeats one photo. */
|
|
71
|
+
export const Sparse: Story = {
|
|
72
|
+
args: {
|
|
73
|
+
family: {
|
|
74
|
+
slug: "portuguese",
|
|
75
|
+
name: "Portuguese",
|
|
76
|
+
venue_count: 1,
|
|
77
|
+
chips: [{ slug: "seafood-dish", label: "seafood" }],
|
|
78
|
+
venue_photo_urls: PHOTOS.slice(0, 1),
|
|
79
|
+
},
|
|
80
|
+
labels: { ...labels, browse: "Browse Portuguese" },
|
|
81
|
+
href: "/menu/cuisines/portuguese",
|
|
82
|
+
hue: "oklch(0.52 0.13 340)",
|
|
83
|
+
gradient: mockGradients.sweet,
|
|
84
|
+
priceBandLabel: "42–88 lei",
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** No photography at all: the ground with the family's initial, and no tint. */
|
|
89
|
+
export const NoPhotos: Story = {
|
|
90
|
+
args: {
|
|
91
|
+
...Default.args,
|
|
92
|
+
family: { ...mockFamily, venue_photo_urls: [] },
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** The row of three the card is designed to be scanned in. */
|
|
97
|
+
export const Row: Story = {
|
|
98
|
+
render: () => (
|
|
99
|
+
<div className="grid grid-cols-3 gap-5">
|
|
100
|
+
{[mockFamily, mockDrinkFamily, mockFamily].map((family, index) => (
|
|
101
|
+
<MenuFamilyCard
|
|
102
|
+
key={index}
|
|
103
|
+
family={{ ...family, venue_photo_urls: PHOTOS }}
|
|
104
|
+
labels={labels}
|
|
105
|
+
href={`/menu/cuisines/${family.slug}`}
|
|
106
|
+
hue="oklch(0.55 0.15 40)"
|
|
107
|
+
gradient={Object.values(mockGradients)[index]}
|
|
108
|
+
priceBandLabel="32–84 lei"
|
|
109
|
+
/>
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
),
|
|
113
|
+
decorators: [
|
|
114
|
+
(Story) => (
|
|
115
|
+
<div className="w-[1100px] bg-background p-4">
|
|
116
|
+
<Story />
|
|
117
|
+
</div>
|
|
118
|
+
),
|
|
119
|
+
],
|
|
120
|
+
};
|