@bearmenu/ui 0.8.13 → 0.8.14
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-66MAPLXS.js → chunk-2ADCZXZJ.js} +1 -1
- package/dist/{chunk-2DEIXTBN.js → chunk-2ZXIXQIU.js} +1 -1
- package/dist/{chunk-YOKHBAIH.js → chunk-4OF4T4AI.js} +12 -11
- package/dist/{chunk-MZL7CGCL.js → chunk-4XDJXMLF.js} +2 -2
- package/dist/{chunk-YYGB4G4Q.js → chunk-CZK2MRS2.js} +1 -1
- package/dist/chunk-DJD3EMPK.js +126 -0
- package/dist/chunk-EZG2NELX.js +5 -0
- package/dist/{chunk-VAXOQS7W.js → chunk-F76NCVVR.js} +1 -1
- package/dist/{chunk-VG3PND3D.js → chunk-FMTJGO76.js} +1 -1
- package/dist/{chunk-UXWAOISG.js → chunk-FVSKGCK3.js} +2 -2
- package/dist/{chunk-2XCZJWTV.js → chunk-GNCWJWFE.js} +1 -1
- package/dist/{chunk-X3WMRKOW.js → chunk-HDR7V6IZ.js} +1 -1
- package/dist/{chunk-HSIYYA2E.js → chunk-HQU6QVAN.js} +5 -5
- package/dist/chunk-I5V446BX.js +27 -0
- package/dist/{chunk-WCTHZKU7.js → chunk-ICOU5QHT.js} +1 -1
- package/dist/{chunk-G7IFL4Z4.js → chunk-L3VEMP5A.js} +57 -9
- package/dist/chunk-LQWGZIYA.js +5 -0
- package/dist/{chunk-XNA2INVQ.js → chunk-MCOHLWRS.js} +2 -2
- package/dist/{chunk-3GWWZKX7.js → chunk-MMUBH76A.js} +21 -1
- package/dist/{chunk-ZDAHKI3A.js → chunk-MWB7S54O.js} +1 -1
- package/dist/{chunk-D5QHEHXJ.js → chunk-NMEVJT5Q.js} +1 -1
- package/dist/{chunk-3TNPSN4D.js → chunk-NSNEANOQ.js} +2 -2
- package/dist/{chunk-WSRWFA6K.js → chunk-NWZGSLPU.js} +1 -1
- package/dist/{chunk-LEAYBICI.js → chunk-OIV7B44M.js} +1 -1
- package/dist/{chunk-2CXU5WZO.js → chunk-P43B3ONY.js} +1 -1
- package/dist/{chunk-FRPPIXMY.js → chunk-PDTPO6NG.js} +1 -1
- package/dist/{chunk-6XMYEZVR.js → chunk-PX6RICS5.js} +1 -1
- package/dist/{chunk-6TEEY5YA.js → chunk-S33K4RC5.js} +1 -1
- package/dist/{chunk-3VMDCCCX.js → chunk-SC2VUTUI.js} +1 -1
- package/dist/{chunk-3NMG3KNA.js → chunk-SXQZ7DLH.js} +3 -3
- package/dist/{chunk-UDTG3RSE.js → chunk-T6S2ZBV6.js} +2 -2
- package/dist/{chunk-FKNBZF5T.js → chunk-WYTJR2YU.js} +1 -1
- package/dist/{chunk-QXHMPJ35.js → chunk-XBBEX4SF.js} +1 -1
- package/dist/chunk-XCB3F6SF.js +16 -0
- package/dist/{chunk-7BWTYWM3.js → chunk-ZLMRVBD7.js} +1 -1
- package/dist/{chunk-P7XTW2CG.js → chunk-ZLPWEHZO.js} +1 -1
- package/dist/components/accordion.js +1 -1
- package/dist/components/alert-dialog.js +2 -2
- package/dist/components/alert.js +2 -2
- package/dist/components/aspect-ratio.js +2 -2
- package/dist/components/avatar.js +1 -1
- package/dist/components/badge.js +2 -2
- package/dist/components/breadcrumb.js +1 -1
- package/dist/components/button-group.js +1 -1
- package/dist/components/button.js +2 -2
- package/dist/components/calendar.js +2 -2
- package/dist/components/card.js +3 -3
- package/dist/components/carousel.js +2 -2
- package/dist/components/category-deck.js +2 -2
- package/dist/components/checkbox.js +2 -2
- package/dist/components/circular-progress.js +1 -1
- package/dist/components/collapsible.js +1 -1
- package/dist/components/combobox.js +5 -5
- package/dist/components/command.js +3 -3
- package/dist/components/concept-card.js +2 -2
- package/dist/components/concept-compare-rail.js +3 -3
- package/dist/components/concept-compare.d.ts +2 -1
- package/dist/components/concept-compare.js +5 -5
- package/dist/components/concept-convergence.js +4 -4
- package/dist/components/convergence.js +3 -3
- package/dist/components/coverflow.js +1 -1
- package/dist/components/currency-input.js +2 -2
- package/dist/components/date-input.js +2 -2
- package/dist/components/day-rail-chip.js +2 -2
- package/dist/components/description-list.js +1 -1
- package/dist/components/dialog.js +2 -2
- package/dist/components/doorway-tiles.d.ts +57 -0
- package/dist/components/doorway-tiles.js +86 -0
- package/dist/components/drawer.js +1 -1
- package/dist/components/drift-rail.js +1 -1
- package/dist/components/dropdown-menu.js +1 -1
- package/dist/components/empty-state.js +3 -3
- package/dist/components/event-card.js +4 -4
- package/dist/components/event-compact-row.js +4 -4
- package/dist/components/event-day-header.js +2 -2
- package/dist/components/event-day-separator.js +3 -3
- package/dist/components/event-detail-panel.js +4 -4
- package/dist/components/event-hub-hero.js +2 -2
- package/dist/components/event-index-row.js +4 -4
- package/dist/components/event-list-row-wide.js +5 -5
- package/dist/components/event-list-row.js +5 -5
- package/dist/components/event-poster-fallback.js +1 -1
- package/dist/components/event-types.js +1 -1
- package/dist/components/events-empty-state.js +1 -1
- package/dist/components/explainer-steps.js +1 -1
- package/dist/components/facet-group.js +2 -2
- package/dist/components/featured-rail-item.js +2 -2
- package/dist/components/field.js +2 -2
- package/dist/components/filter-category-row.js +3 -3
- package/dist/components/form.js +2 -2
- package/dist/components/glass-ground.js +2 -2
- package/dist/components/grade-atom.d.ts +3 -0
- package/dist/components/grade-atom.js +4 -0
- package/dist/components/haptic-button.js +2 -2
- package/dist/components/hour-band.js +4 -4
- package/dist/components/hour-group.js +4 -4
- package/dist/components/hub-surface.js +1 -1
- package/dist/components/hue-chip.js +1 -1
- package/dist/components/icon.js +1 -1
- package/dist/components/input-group.js +1 -1
- package/dist/components/input-otp.js +1 -1
- package/dist/components/input.js +2 -2
- package/dist/components/item.js +1 -1
- package/dist/components/kbd.js +1 -1
- package/dist/components/kitchen-dossier.d.ts +2 -1
- package/dist/components/kitchen-dossier.js +3 -3
- package/dist/components/label.js +2 -2
- package/dist/components/lazy-markdown.js +1 -1
- package/dist/components/link-button.js +2 -2
- package/dist/components/markdown-renderer.js +1 -1
- package/dist/components/menu-dead-end.d.ts +2 -1
- package/dist/components/menu-dead-end.js +1 -1
- package/dist/components/menu-family-block.d.ts +2 -1
- package/dist/components/menu-family-block.js +14 -8
- package/dist/components/menu-family-card.d.ts +2 -1
- package/dist/components/menu-family-card.js +1 -1
- package/dist/components/menu-field.js +1 -1
- package/dist/components/menu-filter-chips.d.ts +2 -1
- package/dist/components/menu-filter-chips.js +3 -3
- package/dist/components/menu-group-rows.d.ts +2 -1
- package/dist/components/menu-group-rows.js +2 -2
- package/dist/components/menu-hub-hero.js +1 -1
- package/dist/components/menu-index-header.d.ts +2 -1
- package/dist/components/menu-index-header.js +2 -2
- package/dist/components/menu-index-list.d.ts +2 -1
- package/dist/components/menu-index-list.js +4 -4
- package/dist/components/menu-intent-hero.d.ts +3 -2
- package/dist/components/menu-intent-hero.js +1 -1
- package/dist/components/menu-item.d.ts +2 -1
- package/dist/components/menu-item.js +4 -4
- package/dist/components/menu-kitchen-list.d.ts +2 -1
- package/dist/components/menu-kitchen-list.js +4 -4
- package/dist/components/menu-mocks.d.ts +16 -5
- package/dist/components/menu-mocks.js +23 -2
- package/dist/components/menu-types.d.ts +21 -34
- package/dist/components/motion-icon-actions.d.ts +27 -0
- package/dist/components/motion-icon-actions.js +336 -0
- package/dist/components/motion-icon-controls.js +3 -3
- package/dist/components/motion-icon-set.d.ts +13 -1
- package/dist/components/motion-icon-set.js +197 -6
- package/dist/components/motion-icon.d.ts +15 -1
- package/dist/components/motion-icon.js +2 -2
- package/dist/components/multi-select-combobox.js +7 -7
- package/dist/components/multi-select-menu.js +2 -2
- package/dist/components/native-select.js +1 -1
- package/dist/components/open-now-list.d.ts +2 -1
- package/dist/components/open-now-list.js +2 -2
- package/dist/components/pagination.js +2 -2
- package/dist/components/phone-frame.js +1 -1
- package/dist/components/photo-ground.js +1 -1
- package/dist/components/place-about-tab.d.ts +2 -1
- package/dist/components/place-about-tab.js +6 -6
- package/dist/components/place-card.d.ts +94 -12
- package/dist/components/place-card.js +203 -128
- package/dist/components/place-details-mobile.d.ts +2 -1
- package/dist/components/place-details-mobile.js +7 -7
- package/dist/components/place-schedule-week.d.ts +2 -1
- package/dist/components/place-schedule-week.js +3 -3
- package/dist/components/place-types.d.ts +3 -265
- package/dist/components/popover.js +2 -2
- package/dist/components/poster-stack.js +1 -1
- package/dist/components/poster-tape.js +1 -1
- package/dist/components/poster-wall.d.ts +22 -6
- package/dist/components/poster-wall.js +197 -12
- package/dist/components/progress.js +1 -1
- package/dist/components/qa-block.d.ts +2 -1
- package/dist/components/qa-block.js +1 -1
- package/dist/components/quiet-stretch.js +1 -1
- package/dist/components/radio-group.js +1 -1
- package/dist/components/rating.js +1 -1
- package/dist/components/scroll-area.js +1 -1
- package/dist/components/scroll-fade.js +2 -2
- package/dist/components/search-bar.js +1 -1
- package/dist/components/searchable-select.js +5 -5
- package/dist/components/section-header.js +2 -2
- package/dist/components/segmented-nav.d.ts +24 -4
- package/dist/components/segmented-nav.js +11 -2
- package/dist/components/select.js +1 -1
- package/dist/components/selection-bar.js +1 -1
- package/dist/components/separator.js +2 -2
- package/dist/components/sheet.js +1 -1
- package/dist/components/signature-chip.js +3 -3
- package/dist/components/skeleton-card.js +3 -3
- package/dist/components/skeleton.js +2 -2
- package/dist/components/slider.js +1 -1
- package/dist/components/sliding-panels.js +2 -2
- package/dist/components/snap-rail.js +2 -2
- package/dist/components/sonner.js +1 -1
- package/dist/components/sort-option-list.js +1 -1
- package/dist/components/spinner.js +1 -1
- package/dist/components/stat-banner.js +1 -1
- package/dist/components/stepper.js +1 -1
- package/dist/components/sticky-container.js +1 -1
- package/dist/components/story.js +1 -1
- package/dist/components/switch.js +1 -1
- package/dist/components/table.js +1 -1
- package/dist/components/tabs.js +2 -2
- package/dist/components/tag-group.js +2 -2
- package/dist/components/tag.js +2 -2
- package/dist/components/text.js +2 -2
- package/dist/components/textarea.js +1 -1
- package/dist/components/timeline-band.js +4 -4
- package/dist/components/timeline-playhead.js +4 -4
- package/dist/components/toggle-group.js +2 -2
- package/dist/components/toggle.js +2 -2
- package/dist/components/tooltip.js +1 -1
- package/dist/components/top-comparison.d.ts +2 -1
- package/dist/components/top-comparison.js +1 -1
- package/dist/components/top-cross-link.d.ts +2 -1
- package/dist/components/top-cross-link.js +1 -1
- package/dist/components/top-ranking-entry.d.ts +2 -1
- package/dist/components/top-ranking-entry.js +6 -6
- package/dist/components/top-ranking-hero.d.ts +2 -1
- package/dist/components/top-ranking-hero.js +1 -1
- package/dist/components/top-ranking-legend.js +1 -1
- package/dist/components/top-types.d.ts +3 -1
- package/dist/components/use-mobile.js +1 -1
- package/dist/components/venue-events-block.js +5 -5
- package/dist/components/venue-grade-dimensions.d.ts +2 -1
- package/dist/components/venue-grade-dimensions.js +1 -1
- package/dist/components/venue-quotes.d.ts +2 -1
- package/dist/components/venue-quotes.js +1 -1
- package/dist/components/venue-wall.d.ts +2 -1
- package/dist/components/venue-wall.js +1 -1
- package/dist/components/weekday-theme-banner.js +1 -1
- package/dist/components/weekend-panel.js +4 -4
- package/dist/hooks/use-drag-swipe.js +1 -1
- package/dist/hooks/use-haptic.js +1 -1
- package/dist/hooks/use-media-query.js +1 -1
- package/dist/hooks/use-mobile.js +1 -1
- package/dist/hooks/use-mounted-theme.js +1 -1
- package/dist/hooks/use-sticky.js +1 -1
- package/dist/lib/card-hover.js +2 -18
- package/dist/lib/grade.d.ts +54 -0
- package/dist/lib/grade.js +2 -0
- package/dist/lib/icon-depth.d.ts +35 -0
- package/dist/lib/icon-depth.js +2 -0
- package/dist/lib/icon-registry.js +1 -1
- package/dist/lib/motion.js +2 -2
- package/dist/lib/overlay-pill.d.ts +30 -0
- package/dist/lib/overlay-pill.js +2 -0
- package/dist/lib/utils.js +1 -1
- package/dist/place-types-BHdeMMiG.d.ts +317 -0
- package/package.json +1 -1
- package/src/components/colour-dictionary.stories.tsx +268 -82
- package/src/components/doorway-tiles.stories.tsx +83 -0
- package/src/components/doorway-tiles.test.tsx +51 -0
- package/src/components/doorway-tiles.tsx +225 -0
- package/src/components/grade-atom.stories.tsx +81 -0
- package/src/components/grade-atom.tsx +256 -0
- package/src/components/hue-chip.stories.tsx +2 -2
- package/src/components/menu-family-block.stories.tsx +29 -0
- package/src/components/menu-family-block.test.tsx +51 -0
- package/src/components/menu-family-block.tsx +20 -4
- package/src/components/menu-intent-hero.tsx +1 -1
- package/src/components/menu-mocks.ts +39 -3
- package/src/components/menu-types.ts +17 -33
- package/src/components/motion-icon-actions.test.tsx +115 -0
- package/src/components/motion-icon-actions.tsx +580 -0
- package/src/components/motion-icon-controls.test.tsx +1 -0
- package/src/components/motion-icon-controls.tsx +18 -13
- package/src/components/motion-icon-set.stories.tsx +62 -3
- package/src/components/motion-icon-set.tsx +349 -2
- package/src/components/motion-icon.test.tsx +180 -1
- package/src/components/motion-icon.tsx +97 -8
- package/src/components/place-card.stories.tsx +66 -76
- package/src/components/place-card.test.tsx +188 -100
- package/src/components/place-card.tsx +356 -148
- package/src/components/place-types.ts +17 -4
- package/src/components/poster-wall.stories.tsx +84 -0
- package/src/components/poster-wall.test.tsx +94 -3
- package/src/components/poster-wall.tsx +257 -9
- package/src/components/segmented-nav.stories.tsx +72 -1
- package/src/components/segmented-nav.test.tsx +96 -0
- package/src/components/segmented-nav.tsx +36 -4
- package/src/globals.css +26 -0
- package/src/lib/grade.test.ts +82 -0
- package/src/lib/grade.ts +76 -0
- package/src/lib/icon-depth.ts +35 -0
- package/src/lib/overlay-pill.ts +29 -0
- package/dist/components/menu-family-tiles.d.ts +0 -19
- package/dist/components/menu-family-tiles.js +0 -57
- package/src/components/menu-family-tiles.stories.tsx +0 -54
- package/src/components/menu-family-tiles.test.tsx +0 -30
- package/src/components/menu-family-tiles.tsx +0 -132
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { cn } from "../lib/utils";
|
|
2
|
+
import type { MenuLinkComponent, MenuLinkProps } from "./menu-types";
|
|
3
|
+
import { PhotoGround } from "./photo-ground";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* DoorwayTiles — a grid of doors: every class the reader browses by, as
|
|
7
|
+
* coloured tiles two to a row (four from `lg`), closed by one tile that opens
|
|
8
|
+
* the whole index. The menu hub's cuisine and going-out families are one
|
|
9
|
+
* caller; the homepage's occasions are another.
|
|
10
|
+
*
|
|
11
|
+
* ## A tile, not a card
|
|
12
|
+
*
|
|
13
|
+
* The tile is the class's NAME, its venue count and one photograph on the
|
|
14
|
+
* class's hue, so seven doors and the index fit on one phone screen. The
|
|
15
|
+
* reader is choosing a door here, not learning about one; the summary lives
|
|
16
|
+
* on the page the tile opens.
|
|
17
|
+
*
|
|
18
|
+
* ## Two treatments, one rule: object → polaroid, atmosphere → ground
|
|
19
|
+
*
|
|
20
|
+
* `polaroid` — one square photograph, tilted, running off the tile's top and
|
|
21
|
+
* right edges, with the name and the count under its lower edge. For a tile
|
|
22
|
+
* about a THING: a plate, a glass, a loaf. The print reads as "this is the
|
|
23
|
+
* thing"; the hue is what tells seven tiles apart at a glance.
|
|
24
|
+
*
|
|
25
|
+
* `ground` — the photograph IS the tile, multiplied by the hue. For a tile
|
|
26
|
+
* about a MOOD: a room, a table, a night. There is no specimen to print, so
|
|
27
|
+
* the room becomes the ground. Under `mix-blend-multiply` the photograph can
|
|
28
|
+
* never be lighter than the hue — the brightest pixel is the bare hue and
|
|
29
|
+
* everything else a darker shade of it — so seven tinted rooms stay seven
|
|
30
|
+
* distinguishable hues, and the label never lands on a highlight. A second
|
|
31
|
+
* layer of the hue at 22% lies over the image so shadows stay the hue's own
|
|
32
|
+
* dark and never black: black under wine or plum reads as a broken image.
|
|
33
|
+
*
|
|
34
|
+
* The treatment is per grid, never per tile. Sibling tiles share one shape.
|
|
35
|
+
*
|
|
36
|
+
* The photograph is the CLASS's — a plate, a room, a glass — served from the
|
|
37
|
+
* consumer's own origin, never a venue's (a venue on a tile about every venue
|
|
38
|
+
* reads as an endorsement). A class with no photograph gets the same tilted
|
|
39
|
+
* square holding `PhotoGround`'s serif initial (polaroid) or the initial on
|
|
40
|
+
* the bare hue (ground), so the silhouette never changes.
|
|
41
|
+
*
|
|
42
|
+
* Every measure on the polaroid is a percentage of the tile, so the tile and
|
|
43
|
+
* the design stay in proportion from a 170px phone cell to a 260px desktop
|
|
44
|
+
* one: 82% of the height, 8% above the top, 4% past the right, 8° over.
|
|
45
|
+
*
|
|
46
|
+
* ## The scrim is shared, and it is measured
|
|
47
|
+
*
|
|
48
|
+
* The honey and terrace hues sit around `oklch(0.6…)`, where white at 14.5px
|
|
49
|
+
* is about 3.7:1 bare — under the 4.5:1 the label needs. The scrim runs
|
|
50
|
+
* three quarters of the tile, 0.64 at the foot and 0.46 at 40% of its own
|
|
51
|
+
* height, so white passes 4.5:1 on every hue in the dictionary up to a
|
|
52
|
+
* two-line label's FIRST line (honey, the brightest, is 4.69:1 there in the
|
|
53
|
+
* worst case of a pure-white photo highlight). An earlier three-fifths scrim
|
|
54
|
+
* ran out exactly where a wrapped "Breakfast & brunch" starts.
|
|
55
|
+
*
|
|
56
|
+
* Server-safe.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
function DefaultLink({ href, ...props }: MenuLinkProps) {
|
|
60
|
+
return <a href={href} {...props} />;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A door — a cuisine, a going-out family, an occasion — as the doorway grid
|
|
65
|
+
* draws it: a hue, a name, a venue count and one photograph.
|
|
66
|
+
*
|
|
67
|
+
* `href` is required: a tile that cannot resolve to a real destination is not
|
|
68
|
+
* rendered rather than linking nowhere.
|
|
69
|
+
*/
|
|
70
|
+
export type DoorwayTile = {
|
|
71
|
+
slug: string;
|
|
72
|
+
label: string;
|
|
73
|
+
/**
|
|
74
|
+
* The venue count, already formatted by the caller — "64", "1,204" — because
|
|
75
|
+
* thousands separators are locale-dependent and the DS owns no locale.
|
|
76
|
+
*/
|
|
77
|
+
countLabel?: string;
|
|
78
|
+
href: string;
|
|
79
|
+
/**
|
|
80
|
+
* Finished CSS gradient for the tile's ground, e.g.
|
|
81
|
+
* `linear-gradient(150deg, …)`.
|
|
82
|
+
*
|
|
83
|
+
* A gradient rather than a token name, for the same reason `<MenuField>`
|
|
84
|
+
* takes one: the hue tables are app-owned, and a component referencing
|
|
85
|
+
* `var(--h-pizza)` renders an invisible tile in any consumer that does not
|
|
86
|
+
* happen to define that variable. A missing gradient falls back to `bg-muted`
|
|
87
|
+
* — visible and obviously unstyled, rather than white text on white.
|
|
88
|
+
*/
|
|
89
|
+
gradient?: string;
|
|
90
|
+
/** Pre-resolved photograph of the CLASS, from the consumer's own origin. */
|
|
91
|
+
photoUrl?: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type DoorwayTilesTreatment = "polaroid" | "ground";
|
|
95
|
+
|
|
96
|
+
export type DoorwayTilesProps = {
|
|
97
|
+
tiles: DoorwayTile[];
|
|
98
|
+
/**
|
|
99
|
+
* The closing tile: the index behind the doors, drawn exactly like them —
|
|
100
|
+
* its own hue and photograph, `countLabel` as the caption. Only its place in
|
|
101
|
+
* the grid says it is the index. Omit when there is none.
|
|
102
|
+
*/
|
|
103
|
+
all?: DoorwayTile;
|
|
104
|
+
/**
|
|
105
|
+
* `polaroid`: hue ground, one tilted print — a tile about a thing.
|
|
106
|
+
* `ground`: the photograph is the ground, multiplied by the hue — a tile
|
|
107
|
+
* about a mood. Required, per grid: two callers, two words, no hidden default.
|
|
108
|
+
*/
|
|
109
|
+
treatment: DoorwayTilesTreatment;
|
|
110
|
+
linkComponent?: MenuLinkComponent;
|
|
111
|
+
className?: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The tile: 16:10 on the phone (the design's 170×110 cell), 2:1 from `lg`,
|
|
116
|
+
* never shorter than two lines of name plus the count need.
|
|
117
|
+
*/
|
|
118
|
+
const TILE =
|
|
119
|
+
"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:aspect-[2/1] lg:p-4 " +
|
|
120
|
+
"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 " +
|
|
121
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The polaroid. Same literal family as `PHOTO_GROUND` for the shadow, never a
|
|
125
|
+
* black `rgba` — the shadow is the ground the card family already stands on.
|
|
126
|
+
*/
|
|
127
|
+
const POLAROID =
|
|
128
|
+
"absolute -right-[4%] -top-[8%] z-0 block aspect-square h-[82%] overflow-hidden rounded-[10%] rotate-[8deg] " +
|
|
129
|
+
"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";
|
|
130
|
+
|
|
131
|
+
/** One scrim for both treatments — see the docblock for the measure. */
|
|
132
|
+
const SCRIM =
|
|
133
|
+
"after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:z-0 after:h-3/4 after:content-[''] " +
|
|
134
|
+
"after:bg-[linear-gradient(to_top,oklch(0.16_0.012_55_/_0.64)_0%,oklch(0.16_0.012_55_/_0.46)_40%,transparent_100%)]";
|
|
135
|
+
|
|
136
|
+
/** The ground photograph, multiplied by the hue the tile already paints. */
|
|
137
|
+
const GROUND_PHOTO =
|
|
138
|
+
"absolute inset-0 z-0 h-full w-full object-cover mix-blend-multiply " +
|
|
139
|
+
"transition-transform duration-500 ease-out group-hover:scale-[1.04] motion-reduce:transition-none motion-reduce:group-hover:scale-100";
|
|
140
|
+
|
|
141
|
+
/** 22% of the hue back over the photograph: shadows stay the hue's dark, never black. */
|
|
142
|
+
const GROUND_LIFT = "absolute inset-0 z-0 opacity-[0.22]";
|
|
143
|
+
|
|
144
|
+
const GROUND_GLYPH =
|
|
145
|
+
"absolute inset-0 z-0 flex items-center justify-center font-serif text-[64px] font-semibold leading-none text-white/15";
|
|
146
|
+
|
|
147
|
+
export function DoorwayTiles({
|
|
148
|
+
tiles,
|
|
149
|
+
all,
|
|
150
|
+
treatment,
|
|
151
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
152
|
+
className,
|
|
153
|
+
}: DoorwayTilesProps) {
|
|
154
|
+
const cells = all ? [...tiles, all] : tiles;
|
|
155
|
+
const isPolaroid = treatment === "polaroid";
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div className={cn("grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-3.5", className)}>
|
|
159
|
+
{cells.map((tile) => {
|
|
160
|
+
const ground = tile.gradient ? { backgroundImage: tile.gradient } : undefined;
|
|
161
|
+
return (
|
|
162
|
+
<LinkComponent
|
|
163
|
+
key={tile.slug}
|
|
164
|
+
href={tile.href}
|
|
165
|
+
className={cn(TILE, SCRIM)}
|
|
166
|
+
style={ground}
|
|
167
|
+
>
|
|
168
|
+
{isPolaroid ? (
|
|
169
|
+
<span aria-hidden className={POLAROID}>
|
|
170
|
+
{tile.photoUrl ? (
|
|
171
|
+
/* Not lazy: the grid sits directly under the hero, and these
|
|
172
|
+
photographs are the first thing the reader looks at. */
|
|
173
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
174
|
+
<img
|
|
175
|
+
src={tile.photoUrl}
|
|
176
|
+
alt=""
|
|
177
|
+
decoding="async"
|
|
178
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
179
|
+
/>
|
|
180
|
+
) : (
|
|
181
|
+
<PhotoGround
|
|
182
|
+
photos={[undefined]}
|
|
183
|
+
initials={tile.label.charAt(0)}
|
|
184
|
+
mode="stack"
|
|
185
|
+
glyphClassName="text-[44px]"
|
|
186
|
+
/>
|
|
187
|
+
)}
|
|
188
|
+
</span>
|
|
189
|
+
) : tile.photoUrl ? (
|
|
190
|
+
<>
|
|
191
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
192
|
+
<img src={tile.photoUrl} alt="" decoding="async" className={GROUND_PHOTO} />
|
|
193
|
+
<span aria-hidden className={GROUND_LIFT} style={ground} />
|
|
194
|
+
</>
|
|
195
|
+
) : (
|
|
196
|
+
<span aria-hidden className={GROUND_GLYPH}>
|
|
197
|
+
{tile.label.charAt(0)}
|
|
198
|
+
</span>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
{/* On the polaroid both lines keep to the left 55%: the print owns the right. */}
|
|
202
|
+
<span
|
|
203
|
+
className={cn(
|
|
204
|
+
"relative z-[1] line-clamp-2 text-[14.5px] font-extrabold leading-[1.2] tracking-[-0.015em] lg:text-[16px]",
|
|
205
|
+
isPolaroid && "max-w-[55%]"
|
|
206
|
+
)}
|
|
207
|
+
>
|
|
208
|
+
{tile.label}
|
|
209
|
+
</span>
|
|
210
|
+
{tile.countLabel && (
|
|
211
|
+
<span
|
|
212
|
+
className={cn(
|
|
213
|
+
"relative z-[1] mt-0.5 truncate text-[12px] font-medium leading-[1.35] tabular-nums text-white/80 lg:text-[13px]",
|
|
214
|
+
isPolaroid && "max-w-[55%]"
|
|
215
|
+
)}
|
|
216
|
+
>
|
|
217
|
+
{tile.countLabel}
|
|
218
|
+
</span>
|
|
219
|
+
)}
|
|
220
|
+
</LinkComponent>
|
|
221
|
+
);
|
|
222
|
+
})}
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { GradeAtom, type GradeAtomLabels } from "./grade-atom";
|
|
3
|
+
|
|
4
|
+
const labels: GradeAtomLabels = {
|
|
5
|
+
soft: "Worth exploring",
|
|
6
|
+
bands: {
|
|
7
|
+
exceptional: "Exceptional",
|
|
8
|
+
great: "Great",
|
|
9
|
+
good: "Good",
|
|
10
|
+
decent: "Decent",
|
|
11
|
+
weak: "Weak",
|
|
12
|
+
},
|
|
13
|
+
aria: (value) => `Grade ${value} out of 10`,
|
|
14
|
+
capped: "Capped for low confidence",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const meta = {
|
|
18
|
+
title: "Components/GradeAtom",
|
|
19
|
+
component: GradeAtom,
|
|
20
|
+
tags: ["autodocs"],
|
|
21
|
+
args: { labels, grade: { overall: 8.4 }, size: "md" },
|
|
22
|
+
} satisfies Meta<typeof GradeAtom>;
|
|
23
|
+
|
|
24
|
+
export default meta;
|
|
25
|
+
type Story = StoryObj<typeof meta>;
|
|
26
|
+
|
|
27
|
+
export const Default: Story = {};
|
|
28
|
+
|
|
29
|
+
/** One hue family per band, none shared by neighbours. */
|
|
30
|
+
export const Bands: Story = {
|
|
31
|
+
render: (args) => (
|
|
32
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
33
|
+
{[9.4, 8.1, 6.7, 5.3, 4.2].map((overall) => (
|
|
34
|
+
<GradeAtom key={overall} {...args} grade={{ overall }} />
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const Sizes: Story = {
|
|
41
|
+
render: (args) => (
|
|
42
|
+
<div className="flex flex-wrap items-end gap-3">
|
|
43
|
+
<GradeAtom {...args} size="xs" />
|
|
44
|
+
<GradeAtom {...args} size="sm" />
|
|
45
|
+
<GradeAtom {...args} size="md" />
|
|
46
|
+
<GradeAtom {...args} size="lg" />
|
|
47
|
+
</div>
|
|
48
|
+
),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** `show_number=false` — the backend recommends against a number; nothing at `xs`. */
|
|
52
|
+
export const Soft: Story = {
|
|
53
|
+
args: { grade: { overall: 5.1, show_number: false } },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const Capped: Story = {
|
|
57
|
+
args: { grade: { overall: 7.9, capped: true } },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const OnPhoto: Story = {
|
|
61
|
+
args: { onPhoto: true, size: "lg" },
|
|
62
|
+
decorators: [
|
|
63
|
+
(Story) => (
|
|
64
|
+
<div
|
|
65
|
+
className="flex h-40 w-72 items-end rounded-xl p-4"
|
|
66
|
+
style={{
|
|
67
|
+
backgroundImage:
|
|
68
|
+
"url(https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800&q=80)",
|
|
69
|
+
backgroundSize: "cover",
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<Story />
|
|
73
|
+
</div>
|
|
74
|
+
),
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** The disclosure-trigger form used by the place hero. */
|
|
79
|
+
export const WithInfo: Story = {
|
|
80
|
+
args: { showInfo: true, size: "lg" },
|
|
81
|
+
};
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GradeAtom — the single, scalable visual for the ReviewsDigest `grade`
|
|
5
|
+
* (1–10 Romanian school scale, food-anchored). It is the hero metric that
|
|
6
|
+
* replaces the Google star across place surfaces; the star is demoted into the
|
|
7
|
+
* meta line by each caller, not here.
|
|
8
|
+
*
|
|
9
|
+
* One shape, four sizes (xs card → lg page hero). The number is always the
|
|
10
|
+
* hero; the descriptor, capped shield and (future) confidence scale in at
|
|
11
|
+
* larger sizes. Colour band comes from `grade.overall`; the label text is
|
|
12
|
+
* always the caller's — the backend `descriptor` is null in practice and the
|
|
13
|
+
* apps own this vocabulary, so it arrives translated in `labels`.
|
|
14
|
+
*
|
|
15
|
+
* States:
|
|
16
|
+
* - normal → coloured pill: number (+ descriptor at sm+) (+ capped shield)
|
|
17
|
+
* - `show_number=false` → neutral pill with a positive, honest soft label
|
|
18
|
+
* ("Worth exploring" / "Merită explorat"), no number
|
|
19
|
+
* - no `overall` → renders nothing, so callers fall back to the Google star
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { Info, ShieldAlert } from "lucide-react";
|
|
23
|
+
import { cn } from "../lib/utils";
|
|
24
|
+
import {
|
|
25
|
+
formatGrade,
|
|
26
|
+
resolveGradeBand,
|
|
27
|
+
shouldShowGradeNumber,
|
|
28
|
+
type GradeBand,
|
|
29
|
+
} from "../lib/grade";
|
|
30
|
+
import type { PlaceGrade } from "./place-types";
|
|
31
|
+
|
|
32
|
+
export type GradeAtomSize = "xs" | "sm" | "md" | "lg";
|
|
33
|
+
|
|
34
|
+
/** Labels passed to `<GradeAtom>`. All translation happens in the caller. */
|
|
35
|
+
export type GradeAtomLabels = {
|
|
36
|
+
/** The `show_number=false` state, e.g. "Worth exploring". */
|
|
37
|
+
soft: string;
|
|
38
|
+
/** Band → descriptor shown beside the number from `sm` up, e.g. "Great". */
|
|
39
|
+
bands: Record<GradeBand, string>;
|
|
40
|
+
/** Accessible name for the pill, e.g. "Grade 8.2 out of 10". */
|
|
41
|
+
aria: (value: string) => string;
|
|
42
|
+
/** Title + aria suffix when `grade.capped`, e.g. "Capped for low confidence". */
|
|
43
|
+
capped: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type GradeAtomProps = {
|
|
47
|
+
grade: PlaceGrade;
|
|
48
|
+
labels: GradeAtomLabels;
|
|
49
|
+
/** xs = card chip · sm = chat/compact · md = drawer · lg = page hero. */
|
|
50
|
+
size?: GradeAtomSize;
|
|
51
|
+
/** Force-hide the descriptor even at sizes that would show it (tight rows). */
|
|
52
|
+
hideDescriptor?: boolean;
|
|
53
|
+
/** Rendered over a photo (e.g. the page hero). Switches to a frosted-dark
|
|
54
|
+
* shell with the band hue on the number + ring, so it stays legible and
|
|
55
|
+
* band-recognizable on any image. */
|
|
56
|
+
onPhoto?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Adds a trailing `Info` glyph, for the one caller where the chip IS the
|
|
59
|
+
* disclosure trigger (the place hero, wrapped in a grade explainer).
|
|
60
|
+
*
|
|
61
|
+
* Off by default and it must stay that way: on cards this atom is a readout,
|
|
62
|
+
* and an affordance marker on something that doesn't open would be a lie. The
|
|
63
|
+
* glyph goes INSIDE the pill rather than beside it — outside, it would be a
|
|
64
|
+
* bare icon on unpredictable photo luminance, and it would read as a second
|
|
65
|
+
* control next to the chip instead of part of it.
|
|
66
|
+
*/
|
|
67
|
+
showInfo?: boolean;
|
|
68
|
+
className?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Background + text + inset ring per band, with dark-mode tints. Same shape
|
|
72
|
+
// everywhere — only the fill changes — so the bands read as one family.
|
|
73
|
+
// Fill-led bands: the colour lives in the FILL so each chip reads as a distinct
|
|
74
|
+
// hue at a glance — even at xs before the number is read. Five separate hue
|
|
75
|
+
// families (gold / green / blue / orange / rose); no two adjacent bands share
|
|
76
|
+
// one. `-800` text on `-100` fill clears WCAG AA.
|
|
77
|
+
const BAND_CLASSES: Record<GradeBand, string> = {
|
|
78
|
+
exceptional:
|
|
79
|
+
"bg-amber-100 text-amber-800 ring-amber-400 dark:bg-amber-500/20 dark:text-amber-300 dark:ring-amber-500/60",
|
|
80
|
+
great:
|
|
81
|
+
"bg-emerald-100 text-emerald-800 ring-emerald-400 dark:bg-emerald-500/20 dark:text-emerald-300 dark:ring-emerald-500/60",
|
|
82
|
+
good: "bg-sky-100 text-sky-800 ring-sky-400 dark:bg-sky-500/20 dark:text-sky-300 dark:ring-sky-500/60",
|
|
83
|
+
decent:
|
|
84
|
+
"bg-orange-100 text-orange-800 ring-orange-400 dark:bg-orange-500/20 dark:text-orange-300 dark:ring-orange-500/60",
|
|
85
|
+
weak: "bg-rose-100 text-rose-800 ring-rose-400 dark:bg-rose-500/20 dark:text-rose-200 dark:ring-rose-500/60",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const SOFT_CLASSES = "bg-muted text-muted-foreground ring-border";
|
|
89
|
+
|
|
90
|
+
// On-photo variant: a frosted-dark shell — established by the hero's other
|
|
91
|
+
// on-photo chips — with the band hue moved onto the NUMBER text + the ring
|
|
92
|
+
// (the two elements the eye hits first), descriptor in white. A drop-shadow
|
|
93
|
+
// lifts the pill off bright photo regions. Keeps a guaranteed contrast floor
|
|
94
|
+
// that a light fill can't promise on an unpredictable image.
|
|
95
|
+
const PHOTO_SHELL =
|
|
96
|
+
"bg-black/45 text-white backdrop-blur-sm ring-white/10 drop-shadow-[0_2px_6px_rgba(0,0,0,0.55)]";
|
|
97
|
+
|
|
98
|
+
const BAND_CLASSES_PHOTO: Record<GradeBand, { ring: string; num: string }> = {
|
|
99
|
+
exceptional: { ring: "ring-amber-400/70", num: "text-amber-300" },
|
|
100
|
+
great: { ring: "ring-emerald-400/70", num: "text-emerald-300" },
|
|
101
|
+
good: { ring: "ring-sky-400/70", num: "text-sky-300" },
|
|
102
|
+
// Brighter/more saturated than the warm-canvas orange so it doesn't bleed into
|
|
103
|
+
// warm (restaurant) photos or compress toward the sky band on the dark shell.
|
|
104
|
+
decent: { ring: "ring-orange-300/90", num: "text-orange-200" },
|
|
105
|
+
weak: { ring: "ring-rose-400/70", num: "text-rose-200" },
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// `minW` is what keeps a rail of chips rectangular. `formatGrade` strips a
|
|
109
|
+
// trailing `.0`, so the value is 1–3 glyphs ("9", "10", "8.2") and a
|
|
110
|
+
// content-sized pill made every card's chip a different width — the number is
|
|
111
|
+
// the one element users compare ACROSS cards, and a ragged right edge reads as
|
|
112
|
+
// noise in the comparison. Fixed on the box rather than by padding the number
|
|
113
|
+
// to "9.0": that decimal is real precision the grade doesn't carry, and it
|
|
114
|
+
// would follow `formatGrade` onto the detail hero at `text-3xl`.
|
|
115
|
+
//
|
|
116
|
+
// Each value clears the widest number the pill can hold at that size (three
|
|
117
|
+
// glyphs of `tabular-nums` plus the horizontal padding) with a little room
|
|
118
|
+
// spare. It never binds when a descriptor or the capped shield is present —
|
|
119
|
+
// that content is already wider — so only the bare-number chips are affected.
|
|
120
|
+
const SIZE: Record<
|
|
121
|
+
GradeAtomSize,
|
|
122
|
+
{
|
|
123
|
+
box: string;
|
|
124
|
+
minW: string;
|
|
125
|
+
num: string;
|
|
126
|
+
desc: string;
|
|
127
|
+
gap: string;
|
|
128
|
+
shield: string;
|
|
129
|
+
showDesc: boolean;
|
|
130
|
+
}
|
|
131
|
+
> = {
|
|
132
|
+
xs: {
|
|
133
|
+
box: "h-6 px-1.5 rounded-md",
|
|
134
|
+
minW: "min-w-9",
|
|
135
|
+
num: "text-[13px]",
|
|
136
|
+
desc: "text-2xs",
|
|
137
|
+
gap: "gap-1",
|
|
138
|
+
shield: "h-3 w-3",
|
|
139
|
+
showDesc: false,
|
|
140
|
+
},
|
|
141
|
+
sm: {
|
|
142
|
+
box: "h-7 px-2 rounded-md",
|
|
143
|
+
minW: "min-w-11",
|
|
144
|
+
num: "text-sm",
|
|
145
|
+
desc: "text-2xs",
|
|
146
|
+
gap: "gap-1.5",
|
|
147
|
+
shield: "h-3 w-3",
|
|
148
|
+
showDesc: true,
|
|
149
|
+
},
|
|
150
|
+
md: {
|
|
151
|
+
box: "h-9 px-2.5 rounded-lg",
|
|
152
|
+
minW: "min-w-14",
|
|
153
|
+
num: "text-lg",
|
|
154
|
+
desc: "text-xs",
|
|
155
|
+
gap: "gap-1.5",
|
|
156
|
+
shield: "h-3.5 w-3.5",
|
|
157
|
+
showDesc: true,
|
|
158
|
+
},
|
|
159
|
+
lg: {
|
|
160
|
+
box: "h-12 px-3.5 rounded-xl",
|
|
161
|
+
minW: "min-w-20",
|
|
162
|
+
num: "text-3xl",
|
|
163
|
+
desc: "text-sm",
|
|
164
|
+
gap: "gap-2",
|
|
165
|
+
shield: "h-4 w-4",
|
|
166
|
+
showDesc: true,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export function GradeAtom({
|
|
171
|
+
grade,
|
|
172
|
+
labels,
|
|
173
|
+
size = "md",
|
|
174
|
+
hideDescriptor,
|
|
175
|
+
onPhoto,
|
|
176
|
+
showInfo,
|
|
177
|
+
className,
|
|
178
|
+
}: GradeAtomProps) {
|
|
179
|
+
const overall = grade.overall;
|
|
180
|
+
|
|
181
|
+
// No grade yet → render nothing; the surface keeps its Google star fallback.
|
|
182
|
+
if (overall == null) return null;
|
|
183
|
+
|
|
184
|
+
const sz = SIZE[size];
|
|
185
|
+
|
|
186
|
+
// Same footprint as the capped shield, so the two trailing glyphs line up when
|
|
187
|
+
// a capped grade shows both. `opacity-70` rather than a colour: it has to sit
|
|
188
|
+
// one step behind the descriptor on the frosted shell AND on the five light
|
|
189
|
+
// band fills, and inheriting each shell's own text colour is the only rule
|
|
190
|
+
// that holds in both.
|
|
191
|
+
const infoGlyph = showInfo ? (
|
|
192
|
+
<Info className={cn("shrink-0 opacity-70", sz.shield)} aria-hidden />
|
|
193
|
+
) : null;
|
|
194
|
+
|
|
195
|
+
// Soft state: the backend recommends against showing a number (thin/divided
|
|
196
|
+
// signal). Positive, honest framing instead of a bare low score.
|
|
197
|
+
//
|
|
198
|
+
// Suppressed at `xs`: the card title-row slot is sized for a 2–3 glyph
|
|
199
|
+
// number, and this label is a sentence — it wrapped inside its own
|
|
200
|
+
// fixed-height pill and forced the place name into a hard truncation. The
|
|
201
|
+
// caller falls back to its Google star instead (gate on `showsGradeAtom`, and
|
|
202
|
+
// see its note for why callers can't test `overall != null` any more).
|
|
203
|
+
if (!shouldShowGradeNumber(grade)) {
|
|
204
|
+
if (size === "xs") return null;
|
|
205
|
+
return (
|
|
206
|
+
<span
|
|
207
|
+
className={cn(
|
|
208
|
+
"inline-flex items-center justify-center font-medium ring-1 ring-inset",
|
|
209
|
+
onPhoto ? PHOTO_SHELL : SOFT_CLASSES,
|
|
210
|
+
sz.box,
|
|
211
|
+
sz.minW,
|
|
212
|
+
sz.desc,
|
|
213
|
+
sz.gap,
|
|
214
|
+
className
|
|
215
|
+
)}
|
|
216
|
+
>
|
|
217
|
+
{labels.soft}
|
|
218
|
+
{infoGlyph}
|
|
219
|
+
</span>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const band = resolveGradeBand(overall);
|
|
224
|
+
const value = formatGrade(overall);
|
|
225
|
+
const descriptor = labels.bands[band];
|
|
226
|
+
const showDesc = sz.showDesc && !hideDescriptor;
|
|
227
|
+
const photo = onPhoto ? BAND_CLASSES_PHOTO[band] : null;
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<span
|
|
231
|
+
className={cn(
|
|
232
|
+
// `justify-center` so a 1-glyph "9" sits centred in the reserved width
|
|
233
|
+
// instead of hugging the left edge of a pill sized for "8.2".
|
|
234
|
+
"inline-flex items-center justify-center font-semibold ring-1 ring-inset",
|
|
235
|
+
photo ? cn(PHOTO_SHELL, photo.ring) : BAND_CLASSES[band],
|
|
236
|
+
sz.box,
|
|
237
|
+
sz.minW,
|
|
238
|
+
sz.gap,
|
|
239
|
+
className
|
|
240
|
+
)}
|
|
241
|
+
aria-label={grade.capped ? `${labels.aria(value)} — ${labels.capped}` : labels.aria(value)}
|
|
242
|
+
title={grade.capped ? labels.capped : undefined}
|
|
243
|
+
>
|
|
244
|
+
<span className={cn("font-extrabold tabular-nums leading-none", sz.num, photo?.num)}>
|
|
245
|
+
{value}
|
|
246
|
+
</span>
|
|
247
|
+
{showDesc && (
|
|
248
|
+
<span className={cn("font-medium leading-none", sz.desc, onPhoto && "text-white/80")}>
|
|
249
|
+
{descriptor}
|
|
250
|
+
</span>
|
|
251
|
+
)}
|
|
252
|
+
{grade.capped && <ShieldAlert className={cn("shrink-0", sz.shield)} aria-hidden />}
|
|
253
|
+
{infoGlyph}
|
|
254
|
+
</span>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
@@ -34,9 +34,9 @@ export const EveryFamily: Story = {
|
|
|
34
34
|
["Breakfast & Brunch", "oklch(0.62 0.14 82)"],
|
|
35
35
|
["Romanian", "oklch(0.48 0.16 25)"],
|
|
36
36
|
["Italian", "oklch(0.56 0.15 38)"],
|
|
37
|
-
["Vegetarian", "oklch(0.
|
|
37
|
+
["Vegetarian", "oklch(0.48 0.13 150)"],
|
|
38
38
|
["Bars", "oklch(0.47 0.10 205)"],
|
|
39
|
-
["Clubs", "oklch(0.
|
|
39
|
+
["Clubs", "oklch(0.34 0.06 210)"],
|
|
40
40
|
].map(([label, hue]) => (
|
|
41
41
|
<HueChip key={label} hue={hue}>
|
|
42
42
|
{label}
|
|
@@ -78,6 +78,35 @@ export const Bare: Story = {
|
|
|
78
78
|
},
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The dishes index's concept card — the same block with the unit swapped:
|
|
83
|
+
* a kicker over the name, the names count beside the kitchens, the mentions
|
|
84
|
+
* in the corner pill, name chips instead of dish-group chips, and no hue.
|
|
85
|
+
*/
|
|
86
|
+
export const Concept: Story = {
|
|
87
|
+
args: {
|
|
88
|
+
family: {
|
|
89
|
+
...mockFamilyBlock,
|
|
90
|
+
slug: "papanasi",
|
|
91
|
+
name: "Papanași",
|
|
92
|
+
kicker: "Dessert concept",
|
|
93
|
+
venueCount: 70,
|
|
94
|
+
facts: ["50 names"],
|
|
95
|
+
badge: "168 mentions",
|
|
96
|
+
priceBandLabel: "8–44 lei",
|
|
97
|
+
chips: [
|
|
98
|
+
{ slug: "a", label: "Papanași cu smântână", count: 0 },
|
|
99
|
+
{ slug: "b", label: "Papanași ropogoși", count: 0 },
|
|
100
|
+
{ slug: "c", label: "+48", count: 0 },
|
|
101
|
+
],
|
|
102
|
+
photoUrls: PHOTOS,
|
|
103
|
+
},
|
|
104
|
+
labels: { ...labels, browse: "See all 70 kitchens", browseShort: "See all 70" },
|
|
105
|
+
hue: undefined,
|
|
106
|
+
gradient: undefined,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
81
110
|
/** The ink runs top to bottom: clear photography under the name, the rows on the inked foot. */
|
|
82
111
|
export const Mobile: Story = {
|
|
83
112
|
args: { family: { ...mockFamilyBlock, photoUrls: PHOTOS } },
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { MenuFamilyBlock, type MenuFamilyBlockLabels } from "./menu-family-block";
|
|
4
|
+
import type { MenuFamilyBlockPreview } from "./menu-types";
|
|
5
|
+
|
|
6
|
+
const labels: MenuFamilyBlockLabels = {
|
|
7
|
+
venueUnit: (count) => `${count} kitchens`,
|
|
8
|
+
more: (count) => `+${count} more`,
|
|
9
|
+
priceBand: "kitchen price band",
|
|
10
|
+
browse: "Browse",
|
|
11
|
+
kitchens: {
|
|
12
|
+
openVenue: (name) => `Open ${name}`,
|
|
13
|
+
openDish: (name) => `See ${name}`,
|
|
14
|
+
openNow: "Open now",
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const family: MenuFamilyBlockPreview = {
|
|
19
|
+
slug: "italian",
|
|
20
|
+
name: "Italian",
|
|
21
|
+
venueCount: 96,
|
|
22
|
+
href: "/menu/cuisines/italian",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe("MenuFamilyBlock", () => {
|
|
26
|
+
it("draws the overflow pill from the venues it cannot show", () => {
|
|
27
|
+
render(<MenuFamilyBlock family={family} labels={labels} />);
|
|
28
|
+
expect(screen.getByText("+96 more")).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("as a concept card: the badge replaces the overflow pill, the kicker and facts join the count line", () => {
|
|
32
|
+
render(
|
|
33
|
+
<MenuFamilyBlock
|
|
34
|
+
family={{
|
|
35
|
+
...family,
|
|
36
|
+
kicker: "Dessert concept",
|
|
37
|
+
facts: ["50 names"],
|
|
38
|
+
badge: "168 mentions",
|
|
39
|
+
priceBandLabel: "8–44 lei",
|
|
40
|
+
}}
|
|
41
|
+
labels={labels}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
expect(screen.getByText("168 mentions")).toBeInTheDocument();
|
|
45
|
+
expect(screen.queryByText("+96 more")).not.toBeInTheDocument();
|
|
46
|
+
expect(screen.getByText("Dessert concept")).toBeInTheDocument();
|
|
47
|
+
expect(screen.getByText("96 kitchens")).toBeInTheDocument();
|
|
48
|
+
expect(screen.getByText("50 names")).toBeInTheDocument();
|
|
49
|
+
expect(screen.getByText("8–44 lei")).toBeInTheDocument();
|
|
50
|
+
});
|
|
51
|
+
});
|