@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.
Files changed (206) hide show
  1. package/dist/chunk-24RFI4RQ.js +167 -0
  2. package/dist/chunk-2XCZJWTV.js +121 -0
  3. package/dist/chunk-5RWTOT5Y.js +72 -0
  4. package/dist/chunk-62ES6N32.js +27 -0
  5. package/dist/chunk-AKQ57NXY.js +58 -0
  6. package/dist/chunk-CLLY6XB3.js +41 -0
  7. package/dist/chunk-DTXNDLSE.js +125 -0
  8. package/dist/chunk-F6FMZJDG.js +113 -0
  9. package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
  10. package/dist/chunk-MZL7CGCL.js +109 -0
  11. package/dist/chunk-Q6QH52LJ.js +38 -0
  12. package/dist/chunk-SY6RV476.js +48 -0
  13. package/dist/chunk-TH6W2PWG.js +213 -0
  14. package/dist/chunk-UDN7UHJW.js +60 -0
  15. package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
  16. package/dist/components/alert.d.ts +2 -2
  17. package/dist/components/badge.d.ts +1 -1
  18. package/dist/components/concept-compare-rail.d.ts +50 -0
  19. package/dist/components/concept-compare-rail.js +5 -0
  20. package/dist/components/concept-compare.d.ts +110 -0
  21. package/dist/components/concept-compare.js +238 -0
  22. package/dist/components/concept-convergence.d.ts +41 -0
  23. package/dist/components/concept-convergence.js +53 -0
  24. package/dist/components/convergence.d.ts +25 -0
  25. package/dist/components/convergence.js +4 -0
  26. package/dist/components/date-input.d.ts +1 -1
  27. package/dist/components/event-card.js +1 -1
  28. package/dist/components/event-compact-row.d.ts +1 -1
  29. package/dist/components/event-compact-row.js +1 -1
  30. package/dist/components/event-detail-panel.d.ts +171 -25
  31. package/dist/components/event-detail-panel.js +472 -175
  32. package/dist/components/event-index-row.js +1 -1
  33. package/dist/components/event-list-row-wide.js +2 -2
  34. package/dist/components/event-list-row.js +2 -2
  35. package/dist/components/explainer-steps.d.ts +34 -0
  36. package/dist/components/explainer-steps.js +36 -0
  37. package/dist/components/glass-ground.d.ts +66 -0
  38. package/dist/components/glass-ground.js +3 -0
  39. package/dist/components/hub-surface.d.ts +27 -0
  40. package/dist/components/hub-surface.js +30 -0
  41. package/dist/components/hue-chip.d.ts +70 -0
  42. package/dist/components/hue-chip.js +3 -0
  43. package/dist/components/kitchen-dossier.d.ts +88 -0
  44. package/dist/components/kitchen-dossier.js +187 -0
  45. package/dist/components/menu-dead-end.d.ts +56 -0
  46. package/dist/components/menu-dead-end.js +49 -0
  47. package/dist/components/menu-family-block.d.ts +99 -0
  48. package/dist/components/menu-family-block.js +155 -0
  49. package/dist/components/menu-family-card.d.ts +65 -0
  50. package/dist/components/menu-family-card.js +79 -0
  51. package/dist/components/menu-field.d.ts +57 -0
  52. package/dist/components/menu-field.js +93 -0
  53. package/dist/components/menu-filter-chips.d.ts +86 -0
  54. package/dist/components/menu-filter-chips.js +122 -0
  55. package/dist/components/menu-group-rows.d.ts +53 -0
  56. package/dist/components/menu-group-rows.js +67 -0
  57. package/dist/components/menu-hub-hero.d.ts +65 -0
  58. package/dist/components/menu-hub-hero.js +103 -0
  59. package/dist/components/menu-index-header.d.ts +37 -0
  60. package/dist/components/menu-index-header.js +68 -0
  61. package/dist/components/menu-index-list.d.ts +48 -0
  62. package/dist/components/menu-index-list.js +96 -0
  63. package/dist/components/menu-intent-hero.d.ts +69 -0
  64. package/dist/components/menu-intent-hero.js +87 -0
  65. package/dist/components/menu-intent-tiles.d.ts +31 -0
  66. package/dist/components/menu-intent-tiles.js +50 -0
  67. package/dist/components/menu-item.d.ts +127 -0
  68. package/dist/components/menu-item.js +5 -0
  69. package/dist/components/menu-kitchen-list.d.ts +82 -0
  70. package/dist/components/menu-kitchen-list.js +5 -0
  71. package/dist/components/menu-mocks.d.ts +90 -0
  72. package/dist/components/menu-mocks.js +504 -0
  73. package/dist/components/menu-types.d.ts +421 -0
  74. package/dist/components/menu-types.js +1 -0
  75. package/dist/components/motion-icon-controls.js +2 -2
  76. package/dist/components/motion-icon-set.js +21 -9
  77. package/dist/components/motion-icon.d.ts +22 -1
  78. package/dist/components/motion-icon.js +1 -1
  79. package/dist/components/open-now-list.d.ts +40 -0
  80. package/dist/components/open-now-list.js +69 -0
  81. package/dist/components/photo-ground.d.ts +64 -0
  82. package/dist/components/photo-ground.js +3 -0
  83. package/dist/components/place-about-tab.js +2 -2
  84. package/dist/components/place-details-mobile.js +2 -2
  85. package/dist/components/place-types.d.ts +112 -1
  86. package/dist/components/qa-block.d.ts +36 -0
  87. package/dist/components/qa-block.js +24 -0
  88. package/dist/components/scroll-fade.js +3 -121
  89. package/dist/components/signature-chip.d.ts +37 -0
  90. package/dist/components/signature-chip.js +4 -0
  91. package/dist/components/snap-rail.d.ts +48 -5
  92. package/dist/components/snap-rail.js +3 -51
  93. package/dist/components/stat-banner.d.ts +47 -0
  94. package/dist/components/stat-banner.js +61 -0
  95. package/dist/components/story.d.ts +34 -0
  96. package/dist/components/story.js +3 -0
  97. package/dist/components/tag.d.ts +2 -2
  98. package/dist/components/text.d.ts +1 -1
  99. package/dist/components/top-comparison.d.ts +28 -0
  100. package/dist/components/top-comparison.js +76 -0
  101. package/dist/components/top-cross-link.d.ts +34 -0
  102. package/dist/components/top-cross-link.js +101 -0
  103. package/dist/components/top-ranking-entry.d.ts +53 -0
  104. package/dist/components/top-ranking-entry.js +217 -0
  105. package/dist/components/top-ranking-hero.d.ts +61 -0
  106. package/dist/components/top-ranking-hero.js +99 -0
  107. package/dist/components/top-ranking-legend.d.ts +69 -0
  108. package/dist/components/top-ranking-legend.js +124 -0
  109. package/dist/components/top-types.d.ts +109 -0
  110. package/dist/components/top-types.js +1 -0
  111. package/dist/components/venue-grade-dimensions.d.ts +38 -0
  112. package/dist/components/venue-grade-dimensions.js +3 -0
  113. package/dist/components/venue-quotes.d.ts +32 -0
  114. package/dist/components/venue-quotes.js +3 -0
  115. package/dist/components/venue-wall.d.ts +41 -0
  116. package/dist/components/venue-wall.js +118 -0
  117. package/dist/components/weekday-theme-banner.d.ts +70 -0
  118. package/dist/components/weekday-theme-banner.js +127 -0
  119. package/dist/components/weekend-panel.js +2 -2
  120. package/package.json +1 -1
  121. package/src/components/colour-dictionary.stories.tsx +227 -0
  122. package/src/components/concept-compare-rail.tsx +131 -0
  123. package/src/components/concept-compare.stories.tsx +172 -0
  124. package/src/components/concept-compare.tsx +443 -0
  125. package/src/components/concept-convergence.stories.tsx +53 -0
  126. package/src/components/concept-convergence.tsx +90 -0
  127. package/src/components/convergence.stories.tsx +54 -0
  128. package/src/components/convergence.tsx +177 -0
  129. package/src/components/event-compact-row.tsx +1 -1
  130. package/src/components/event-detail-panel.stories.tsx +205 -97
  131. package/src/components/event-detail-panel.test.tsx +181 -0
  132. package/src/components/event-detail-panel.tsx +703 -185
  133. package/src/components/explainer-steps.stories.tsx +46 -0
  134. package/src/components/explainer-steps.tsx +74 -0
  135. package/src/components/glass-ground.tsx +129 -0
  136. package/src/components/hub-surface.stories.tsx +68 -0
  137. package/src/components/hub-surface.tsx +50 -0
  138. package/src/components/hue-chip.stories.tsx +76 -0
  139. package/src/components/hue-chip.test.tsx +62 -0
  140. package/src/components/hue-chip.tsx +99 -0
  141. package/src/components/kitchen-dossier.stories.tsx +108 -0
  142. package/src/components/kitchen-dossier.tsx +322 -0
  143. package/src/components/menu-dead-end.stories.tsx +67 -0
  144. package/src/components/menu-dead-end.tsx +127 -0
  145. package/src/components/menu-family-block.stories.tsx +91 -0
  146. package/src/components/menu-family-block.tsx +314 -0
  147. package/src/components/menu-family-card.stories.tsx +120 -0
  148. package/src/components/menu-family-card.tsx +191 -0
  149. package/src/components/menu-field.stories.tsx +74 -0
  150. package/src/components/menu-field.tsx +199 -0
  151. package/src/components/menu-filter-chips.stories.tsx +107 -0
  152. package/src/components/menu-filter-chips.tsx +238 -0
  153. package/src/components/menu-group-rows.stories.tsx +54 -0
  154. package/src/components/menu-group-rows.tsx +133 -0
  155. package/src/components/menu-hub-hero.stories.tsx +92 -0
  156. package/src/components/menu-hub-hero.tsx +187 -0
  157. package/src/components/menu-index-header.stories.tsx +72 -0
  158. package/src/components/menu-index-header.tsx +125 -0
  159. package/src/components/menu-index-list.stories.tsx +50 -0
  160. package/src/components/menu-index-list.tsx +149 -0
  161. package/src/components/menu-intent-hero.stories.tsx +67 -0
  162. package/src/components/menu-intent-hero.tsx +189 -0
  163. package/src/components/menu-intent-tiles.stories.tsx +54 -0
  164. package/src/components/menu-intent-tiles.tsx +76 -0
  165. package/src/components/menu-item.tsx +416 -0
  166. package/src/components/menu-kitchen-list.stories.tsx +67 -0
  167. package/src/components/menu-kitchen-list.test.tsx +78 -0
  168. package/src/components/menu-kitchen-list.tsx +284 -0
  169. package/src/components/menu-mocks.ts +592 -0
  170. package/src/components/menu-types.ts +442 -0
  171. package/src/components/motion-icon-controls.tsx +11 -11
  172. package/src/components/motion-icon-set.tsx +36 -8
  173. package/src/components/motion-icon.test.tsx +23 -1
  174. package/src/components/motion-icon.tsx +20 -0
  175. package/src/components/open-now-list.stories.tsx +76 -0
  176. package/src/components/open-now-list.tsx +127 -0
  177. package/src/components/photo-ground.tsx +147 -0
  178. package/src/components/place-types.ts +112 -0
  179. package/src/components/qa-block.stories.tsx +43 -0
  180. package/src/components/qa-block.tsx +60 -0
  181. package/src/components/signature-chip.tsx +77 -0
  182. package/src/components/snap-rail.tsx +133 -9
  183. package/src/components/stat-banner.stories.tsx +100 -0
  184. package/src/components/stat-banner.tsx +117 -0
  185. package/src/components/story.tsx +225 -0
  186. package/src/components/top-comparison.stories.tsx +78 -0
  187. package/src/components/top-comparison.tsx +151 -0
  188. package/src/components/top-cross-link.stories.tsx +84 -0
  189. package/src/components/top-cross-link.tsx +171 -0
  190. package/src/components/top-ranking-entry.stories.tsx +171 -0
  191. package/src/components/top-ranking-entry.test.tsx +105 -0
  192. package/src/components/top-ranking-entry.tsx +425 -0
  193. package/src/components/top-ranking-hero.stories.tsx +76 -0
  194. package/src/components/top-ranking-hero.tsx +218 -0
  195. package/src/components/top-ranking-legend.stories.tsx +78 -0
  196. package/src/components/top-ranking-legend.tsx +239 -0
  197. package/src/components/top-types.ts +110 -0
  198. package/src/components/venue-grade-dimensions.stories.tsx +83 -0
  199. package/src/components/venue-grade-dimensions.tsx +91 -0
  200. package/src/components/venue-quotes.stories.tsx +59 -0
  201. package/src/components/venue-quotes.tsx +64 -0
  202. package/src/components/venue-wall.stories.tsx +78 -0
  203. package/src/components/venue-wall.tsx +185 -0
  204. package/src/components/weekday-theme-banner.stories.tsx +113 -0
  205. package/src/components/weekday-theme-banner.tsx +231 -0
  206. package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
@@ -0,0 +1,416 @@
1
+ import { memo } from "react";
2
+ import { MessageSquareQuote } from "lucide-react";
3
+ import { cn } from "../lib/utils";
4
+ import { SignatureChip } from "./signature-chip";
5
+ import type { MenuItemPreview, MenuLinkProps, MenuLinkComponent } from "./menu-types";
6
+
7
+ /**
8
+ * MenuItem — THE menu row.
9
+ *
10
+ * A venue's page (its menu list, its signature picks, the "pairs well with"
11
+ * list inside the entity drawer) and the cross-venue surfaces (the city menu,
12
+ * the hub's "known for" list, the daily editorial buckets) all render this one
13
+ * component, so an item is visibly the same object wherever it is met.
14
+ *
15
+ * Hoisted out of the app, where it had grown a second and a third lookalike
16
+ * beside it. It carries the app version's design decisions verbatim; only the
17
+ * couplings changed — the locale, the translation keys and the category-colour
18
+ * table are injected, because the DS owns none of those.
19
+ *
20
+ * ## The left mark
21
+ *
22
+ * An 8px rounded tab in the section's CATEGORY colour, flat against the row's
23
+ * left edge and bulging right. It replaced an earlier monogram chip, and the
24
+ * trade was deliberate:
25
+ *
26
+ * + The text column widens. At 390px the monogram costs 50px (38 + gap) and
27
+ * the tab costs 16px, so the text column goes 308px → 342px: ~11% more room
28
+ * for names and descriptions, which means fewer wrapped names and more
29
+ * items per screen.
30
+ * + The hue answers "which section am I in" rather than "what is this made
31
+ * of". Every row in a category draws the same mark, so it survives the
32
+ * category header scrolling out of view — which a per-item ingredient hue
33
+ * cannot do, since it changes for reasons the reader cannot see.
34
+ * − COLOUR-BLINDNESS. The monogram gave a reader who cannot separate the hues
35
+ * a LETTER; a rule collapses to the same grey bar for everyone. Both marks
36
+ * are `aria-hidden` and neither carries meaning the text lacks, so this is
37
+ * not a WCAG 1.4.1 failure — but it is a real loss of scannability for ~8%
38
+ * of men, and it has not been bought back yet.
39
+ *
40
+ * `markGradient` is passed IN, never derived: inside a menu the colour depends
41
+ * on the neighbouring sections, which a single row cannot see.
42
+ *
43
+ * ## No chevron
44
+ *
45
+ * The row is already `role="button"`, so an arrow would only restate that, and
46
+ * the right edge belongs to the mention count, which the arrow would collide
47
+ * with.
48
+ */
49
+
50
+ /** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
51
+ export type MenuItemLinkProps = MenuLinkProps;
52
+
53
+ function DefaultLink({ href, ...props }: MenuItemLinkProps) {
54
+ return <a href={href} {...props} />;
55
+ }
56
+
57
+ export type MenuItemLabels = {
58
+ /** Currency unit beside the price, e.g. "RON". */
59
+ currency: string;
60
+ /** Fully-worded mention count — the accessible name for the glyph-and-numeral pair. */
61
+ mentions: (count: number) => string;
62
+ /** Chip text for a `signature`-strength item. */
63
+ signature: string;
64
+ /** Chip text for a `weak` (merely mentioned) item. */
65
+ mentioned: string;
66
+ };
67
+
68
+ export type MenuItemProps = {
69
+ item: MenuItemPreview;
70
+ labels: MenuItemLabels;
71
+ /** Locale-aware money formatter. The DS owns no `Intl` locale. */
72
+ formatPrice: (amount: number) => string;
73
+ /** Locale-aware integer formatter for the mention count. Defaults to `String`. */
74
+ formatCount?: (value: number) => string;
75
+ /**
76
+ * Opens the item's own sheet. Ignored when `href` is set.
77
+ *
78
+ * Neither this nor `href` → an INERT row: same geometry, mark, type and meta
79
+ * line, but no `role="button"`, no tab stop and no hover or press states.
80
+ * That is the must-try mention with no menu row behind it — it has nothing to
81
+ * open, so it renders as content rather than as a control that goes nowhere.
82
+ */
83
+ onOpen?: () => void;
84
+ showSignatureBadge?: boolean;
85
+ signatureTier?: "strong" | "weak";
86
+ onSignatureEvidence?: () => void;
87
+ /** Opens the reviews behind the mention count. */
88
+ onMentions?: () => void;
89
+ /** The mark's colour, as a CSS `background-image` value. */
90
+ markGradient?: string;
91
+ /**
92
+ * The venue this item belongs to — an attribution line under the name.
93
+ *
94
+ * Set it ONLY on cross-venue surfaces, where ten near-identical burgers come
95
+ * from ten kitchens and the venue IS the tap decision. A venue's own menu
96
+ * page must never pass it: the page already said whose menu this is. Passed
97
+ * in rather than read off `item.place` so the redundant case cannot be the
98
+ * default.
99
+ */
100
+ venueName?: string;
101
+ /**
102
+ * Explicit subtitle, in italic, in place of the description. Used where the
103
+ * reviewer's reason IS the row's content rather than the payoff for tapping.
104
+ */
105
+ subtitle?: string;
106
+ /**
107
+ * Render the row as a LINK instead of a `role="button"` div — for surfaces
108
+ * where it navigates rather than opening a sheet, so it stays
109
+ * middle-clickable and shareable. Implemented as a stretched overlay rather
110
+ * than by wrapping the row: the mention count is a control in its own right
111
+ * and a button inside an anchor is invalid.
112
+ */
113
+ href?: string;
114
+ /** Accessible name for the link overlay. Defaults to the item's name. */
115
+ linkLabel?: string;
116
+ linkComponent?: MenuLinkComponent;
117
+ disabled?: boolean;
118
+ className?: string;
119
+ };
120
+
121
+ /**
122
+ * The mention count: a filled quote glyph and the numeral, pinned right.
123
+ *
124
+ * Deliberately NOT a badge — a chip would add a filled shape to a row that has
125
+ * none, and would read as a status label the venue was given rather than a
126
+ * count drawn from the same reviews.
127
+ */
128
+ export function MentionSignal({
129
+ label,
130
+ count,
131
+ onMentions,
132
+ formatCount = String,
133
+ }: {
134
+ label: string;
135
+ count: number;
136
+ onMentions?: () => void;
137
+ formatCount?: (value: number) => string;
138
+ }) {
139
+ /* The glyph and the numeral have to read as ONE object: `gap-0.5` instead of
140
+ 4, and the glyph lighter and a step smaller than the numeral so it reads as
141
+ a prefix mark qualifying the value rather than a peer competing with it. */
142
+ const content = (
143
+ <span className="inline-flex items-center gap-0.5">
144
+ <MessageSquareQuote className="h-3.5 w-3.5 opacity-60" aria-hidden />
145
+ <span className="tabular-nums">{formatCount(count)}</span>
146
+ </span>
147
+ );
148
+
149
+ const shared = cn(
150
+ // `relative z-[2]` keeps this above the row's stretched link overlay.
151
+ "relative z-[2] inline-flex shrink-0 items-center text-accent",
152
+ // Same size and weight as the price at the other end of the meta row — keep
153
+ // these two in step if the price's type changes.
154
+ "text-[15px] font-bold",
155
+ // `min-h-6` holds the tap target at the 24px WCAG 2.5.8 floor; `px-1.5
156
+ // -mx-1.5` buys the horizontal half without moving the pair on screen.
157
+ "min-h-6 px-1.5 -mx-1.5"
158
+ );
159
+
160
+ if (!onMentions) {
161
+ return (
162
+ <span className={shared} aria-label={label}>
163
+ {content}
164
+ </span>
165
+ );
166
+ }
167
+
168
+ return (
169
+ <button
170
+ type="button"
171
+ // Read by the row's `:has()` rule, which drops the row's own hover while
172
+ // the pointer is on this button — two nested targets, one of them lit.
173
+ data-slot="mentions"
174
+ aria-label={label}
175
+ onClick={(event) => {
176
+ event.stopPropagation();
177
+ event.preventDefault();
178
+ onMentions();
179
+ }}
180
+ className={cn(
181
+ shared,
182
+ "rounded-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
183
+ )}
184
+ >
185
+ {content}
186
+ </button>
187
+ );
188
+ }
189
+
190
+ export const MenuItem = memo(function MenuItem({
191
+ item,
192
+ labels,
193
+ formatPrice,
194
+ formatCount = String,
195
+ onOpen,
196
+ showSignatureBadge = false,
197
+ signatureTier = "strong",
198
+ onSignatureEvidence,
199
+ onMentions,
200
+ markGradient,
201
+ venueName,
202
+ subtitle,
203
+ href,
204
+ linkLabel,
205
+ linkComponent: LinkComponent = DefaultLink,
206
+ disabled = false,
207
+ className,
208
+ }: MenuItemProps) {
209
+ const firstEntry = item.sizes_and_prices?.[0];
210
+ const quantity = firstEntry?.size;
211
+ const hasQuantity = quantity != null && quantity > 0;
212
+
213
+ const allPrices =
214
+ item.sizes_and_prices
215
+ ?.map((entry) => entry.price)
216
+ .filter((price): price is number => typeof price === "number" && price > 0) ?? [];
217
+ const minPrice = allPrices.length > 0 ? Math.min(...allPrices) : null;
218
+ const maxPrice = allPrices.length > 0 ? Math.max(...allPrices) : null;
219
+ const isPriceRange = minPrice != null && maxPrice != null && minPrice !== maxPrice;
220
+
221
+ // `mentions` is the census count and is populated catalogue-wide;
222
+ // `review_count` counts full review objects and is sparse. Prefer the former,
223
+ // fall back rather than render nothing.
224
+ const mentionCount = item.mentions ?? item.review_count ?? 0;
225
+
226
+ // A row with neither destination is content, not a control.
227
+ const isInert = !href && !onOpen;
228
+ const isActionable = !disabled && !isInert;
229
+
230
+ const handleRowClick = () => {
231
+ if (disabled) return;
232
+ onOpen?.();
233
+ };
234
+
235
+ const handleKeyDown = (event: React.KeyboardEvent) => {
236
+ if (disabled) return;
237
+ if (event.key === "Enter" || event.key === " ") {
238
+ event.preventDefault();
239
+ onOpen?.();
240
+ }
241
+ };
242
+
243
+ // No `aria-label` on the row: an `aria-label` REPLACES the element's contents
244
+ // in the accessible-name computation, so a "Preview: <name>" string would be
245
+ // the whole announcement and the price, size and mention count would never
246
+ // reach a screen-reader user. Without it, `role="button"` names itself from
247
+ // its own content.
248
+ return (
249
+ <div
250
+ // A link row hands all of this to the browser via the stretched anchor
251
+ // below: native focus, Enter, middle-click, context menu. Keeping
252
+ // `role="button"` alongside would announce the row twice.
253
+ role={href || isInert ? undefined : "button"}
254
+ tabIndex={href || isInert ? undefined : disabled ? -1 : 0}
255
+ aria-disabled={disabled || undefined}
256
+ onClick={href || isInert ? undefined : handleRowClick}
257
+ onKeyDown={href || isInert ? undefined : handleKeyDown}
258
+ className={cn(
259
+ "group relative flex min-h-[72px] items-start gap-3 px-4 py-3",
260
+ // Radius only where the row is a grid cell. Full-bleed rows run edge to
261
+ // edge, where a corner radius would round against the viewport.
262
+ "md:rounded-xl",
263
+ // 180ms is the EXIT; `hover:duration-*` overrides it to 120ms on the
264
+ // way in. A hover that leaves as fast as it arrives reads as a flicker
265
+ // when the pointer crosses a list.
266
+ !isInert && [
267
+ "transition-[background-color,box-shadow,transform] duration-[180ms] ease-out motion-reduce:transition-none",
268
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
269
+ ],
270
+ isActionable && [
271
+ "cursor-pointer hover:duration-[120ms]",
272
+ // Opaque, never alpha: `hover:bg-muted/40` composited to a lightness
273
+ // delta of 0.004 on the light canvas — a hover state that measured as
274
+ // nothing.
275
+ "hover:bg-card dark:hover:bg-surface-2",
276
+ // Grid cells only. On a full-bleed row the shadow runs off both
277
+ // viewport edges and reads as a smear; that row widens its tab instead.
278
+ "md:hover:shadow-[var(--shadow-row-hover)]",
279
+ // Hover lifts, press sinks — the two must not read as one gesture.
280
+ "active:scale-[0.985] active:duration-[90ms] active:ease-in active:shadow-none",
281
+ "active:bg-muted",
282
+ "motion-reduce:active:scale-100",
283
+ // The mention count is a button inside a button. While the pointer is
284
+ // on it the row stops claiming the hover, so only one target is lit.
285
+ "has-[[data-slot=mentions]:hover]:bg-transparent has-[[data-slot=mentions]:hover]:shadow-none",
286
+ ],
287
+ disabled && "cursor-not-allowed opacity-50",
288
+ className
289
+ )}
290
+ >
291
+ {/* Stretched link. Covers the row so the whole thing is clickable, but
292
+ sits BELOW the mention pair (`z-[2]`) so that control keeps its own
293
+ clicks. `aria-label` rather than wrapping the heading: the anchor has
294
+ no text of its own, and letting it swallow the row's contents would be
295
+ the same "one button announcing everything" problem. */}
296
+ {href && (
297
+ <LinkComponent
298
+ href={href}
299
+ aria-label={linkLabel ?? item.name}
300
+ className="absolute inset-0 z-[1] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent md:rounded-xl"
301
+ />
302
+ )}
303
+
304
+ {/* `self-stretch` spans the row's content box, so the mark grows with a
305
+ wrapped name instead of being a fixed stub. `-ml-4` cancels the row's
306
+ `px-4` so the mark's flat edge sits against the row edge and only the
307
+ curve reads. Decorative, hence `aria-hidden`. */}
308
+ <div
309
+ aria-hidden
310
+ className={cn(
311
+ "-ml-4 w-2 shrink-0 self-stretch rounded-r-full bg-muted",
312
+ // The full-bleed row's hover signal, since it cannot take a shadow.
313
+ // `-mr-1` absorbs the extra 4px so the text column does not shift as
314
+ // the mark grows. Grid cells get the shadow instead and hold the mark
315
+ // still — the tab encodes which section you are in, so the less it
316
+ // moves, the better it does that job.
317
+ !isInert && [
318
+ "transition-[width] duration-[120ms] ease-out motion-reduce:transition-none",
319
+ "group-hover:-mr-1 group-hover:w-3 md:group-hover:mr-0 md:group-hover:w-2",
320
+ ]
321
+ )}
322
+ style={markGradient ? { backgroundImage: markGradient } : undefined}
323
+ />
324
+
325
+ <div className="min-w-0 flex-1">
326
+ <div className="flex items-start gap-1.5">
327
+ <h3 className="text-[15px] font-semibold leading-snug text-foreground">{item.name}</h3>
328
+ {showSignatureBadge && (
329
+ <SignatureChip
330
+ label={signatureTier === "weak" ? labels.mentioned : labels.signature}
331
+ onEvidence={onSignatureEvidence}
332
+ className="mt-0.5"
333
+ />
334
+ )}
335
+ </div>
336
+
337
+ {/* Venue attribution — cross-venue surfaces only. Medium weight at 12px
338
+ against the 12px regular muted description below is what makes it
339
+ read as a different KIND of fact at a glance. No pin glyph:
340
+ repeated down a list it is visual lint, and a pin reads "address"
341
+ when the fact is "kitchen". */}
342
+ {venueName && (
343
+ <p className="mt-0.5 truncate text-[12px] font-medium text-foreground/70">{venueName}</p>
344
+ )}
345
+
346
+ {/* The description gives up a line to the venue rather than the row
347
+ growing one: name / venue / description(1) / meta is the same four
348
+ text lines and the same `min-h-[72px]` as name / description(2) /
349
+ meta on a venue's own page, so an item does not change height
350
+ between the city menu and the menu it lives on. */}
351
+ {subtitle ? (
352
+ <p className="mt-0.5 line-clamp-2 text-xs italic leading-snug text-muted-foreground">
353
+ {subtitle}
354
+ </p>
355
+ ) : (
356
+ item.description && (
357
+ <p
358
+ className={cn(
359
+ "mt-0.5 text-xs text-muted-foreground",
360
+ venueName ? "line-clamp-1" : "line-clamp-2"
361
+ )}
362
+ >
363
+ {item.description}
364
+ </p>
365
+ )
366
+ )}
367
+
368
+ {/* Meta row. Price and quantity cluster left; `justify-between` pins the
369
+ mention pair right so it forms a column down the list.
370
+
371
+ The reverse — price right, mentions left — was built and rejected on
372
+ sight. Right-aligned tabular numerals align by MAGNITUDE, which is a
373
+ real argument, but leading the meta line with a bare evidence count
374
+ makes it a stray number with nothing to attach to, and pushing price
375
+ to the far edge severs it from the name it belongs to. Do not
376
+ re-litigate without looking at it.
377
+
378
+ Suppressed entirely when the row has none of the three: a mention
379
+ with no menu row behind it has no price and no size, and an empty
380
+ meta line still spent its top margin. */}
381
+ {(minPrice != null || hasQuantity || mentionCount > 0) && (
382
+ <div className="mt-1.5 flex items-baseline justify-between gap-x-2 gap-y-1">
383
+ <div className="flex min-w-0 items-baseline gap-x-2">
384
+ {minPrice != null && (
385
+ <span className="flex items-baseline gap-1">
386
+ <span className="text-[15px] font-bold tabular-nums text-foreground">
387
+ {isPriceRange
388
+ ? `${formatPrice(minPrice)}–${formatPrice(maxPrice!)}`
389
+ : formatPrice(minPrice)}
390
+ </span>
391
+ <span className="text-[11px] text-muted-foreground">{labels.currency}</span>
392
+ </span>
393
+ )}
394
+
395
+ {hasQuantity && (
396
+ <span className="text-[12px] tabular-nums text-muted-foreground">
397
+ {quantity}
398
+ {item.kind === "dish" ? "g" : "ml"}
399
+ </span>
400
+ )}
401
+ </div>
402
+
403
+ {mentionCount > 0 && (
404
+ <MentionSignal
405
+ label={labels.mentions(mentionCount)}
406
+ count={mentionCount}
407
+ onMentions={onMentions}
408
+ formatCount={formatCount}
409
+ />
410
+ )}
411
+ </div>
412
+ )}
413
+ </div>
414
+ </div>
415
+ );
416
+ });
@@ -0,0 +1,67 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { MenuKitchenList } from "./menu-kitchen-list";
3
+ import { mockKitchens, mockMatchedKitchens } from "./menu-mocks";
4
+
5
+ const labels = {
6
+ openVenue: (name: string) => `Open ${name}`,
7
+ openDish: (name: string) => `See ${name}`,
8
+ openNow: "Open now",
9
+ showMore: "Show 10 more",
10
+ };
11
+
12
+ const meta: Meta<typeof MenuKitchenList> = {
13
+ title: "Menu/MenuKitchenList",
14
+ component: MenuKitchenList,
15
+ tags: ["autodocs"],
16
+ args: { labels },
17
+ decorators: [
18
+ (Story) => (
19
+ <div className="w-[1100px] bg-background p-4">
20
+ <Story />
21
+ </div>
22
+ ),
23
+ ],
24
+ parameters: {
25
+ docs: {
26
+ description: {
27
+ component:
28
+ "The intent page's spine — a shortlist of rooms you could walk into, not a grid of dishes. **Two targets per row**: the row goes to the venue, the dish name opens the item drawer. That is why this is not `RankedVenueList`, which wraps its whole row in one link and so cannot express a second destination. The mechanism is a stretched overlay, as in `MenuItem`; nesting one anchor inside another is invalid and breaks middle-click on both. The dish is underlined so it reads as a target before it is pressed, and the chevron stays on the right where it belongs to the venue.",
29
+ },
30
+ },
31
+ },
32
+ };
33
+
34
+ export default meta;
35
+ type Story = StoryObj<typeof MenuKitchenList>;
36
+
37
+ export const Default: Story = { args: { kitchens: mockKitchens } };
38
+
39
+ /**
40
+ * Filtered: every row swaps its most-named dish for the one that matched, and
41
+ * the kicker changes with it. A filtered list whose rows still advertise their
42
+ * most-named dish is claiming the filter matched something it did not.
43
+ */
44
+ export const Matched: Story = { args: { kitchens: mockMatchedKitchens } };
45
+
46
+ /**
47
+ * The degradations, all from real API gaps: row 3's must-try has no `slug`, so
48
+ * the dish line is inert rather than a link to nowhere; row 4 has no dish at
49
+ * all and must still read as a venue; neither carries a mention count, because
50
+ * a list endpoint does not serve one.
51
+ */
52
+ export const Degraded: Story = { args: { kitchens: mockKitchens.slice(2) } };
53
+
54
+ export const WithShowMore: Story = {
55
+ args: { kitchens: mockKitchens, onShowMore: () => {} },
56
+ };
57
+
58
+ export const Mobile: Story = {
59
+ args: { kitchens: mockKitchens },
60
+ decorators: [
61
+ (Story) => (
62
+ <div className="w-[390px] bg-background p-3">
63
+ <Story />
64
+ </div>
65
+ ),
66
+ ],
67
+ };
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { MenuKitchenList, type MenuKitchenListLabels } from "./menu-kitchen-list";
4
+ import type { MenuKitchenPreview } from "./menu-types";
5
+
6
+ const labels: MenuKitchenListLabels = {
7
+ openVenue: (name) => `Open ${name}`,
8
+ openDish: (name) => `See ${name}`,
9
+ openNow: "Open now",
10
+ showMore: "Show 10 more",
11
+ };
12
+
13
+ const kitchen: MenuKitchenPreview = {
14
+ place: { id: "p1", slug: "salt-and-stone", name: "Salt & Stone", open_now: true },
15
+ href: "/places/salt-and-stone",
16
+ metaLabel: "courtyard · Centru",
17
+ hoursLabel: "kitchen until 23:00",
18
+ priceLabel: "62 lei",
19
+ dish: {
20
+ name: "Truffle Tagliatelle",
21
+ href: "/menu/dishes/truffle-tagliatelle",
22
+ kicker: "MOST NAMED HERE",
23
+ meta: "17 reviews name it",
24
+ },
25
+ };
26
+
27
+ describe("MenuKitchenList", () => {
28
+ /**
29
+ * The reason this component exists rather than a flag on `RankedVenueList`.
30
+ * If these ever collapse into one link the surface has silently lost the
31
+ * drawer, which is the second half of what the row is for.
32
+ */
33
+ it("gives the row and the dish separate destinations", () => {
34
+ render(<MenuKitchenList kitchens={[kitchen]} labels={labels} />);
35
+
36
+ const venue = screen.getByRole("link", { name: "Open Salt & Stone" });
37
+ const dish = screen.getByRole("link", { name: "See Truffle Tagliatelle" });
38
+
39
+ expect(venue).toHaveAttribute("href", "/places/salt-and-stone");
40
+ expect(dish).toHaveAttribute("href", "/menu/dishes/truffle-tagliatelle");
41
+ // Anchors must be siblings, never nested — nesting is invalid HTML and
42
+ // breaks middle-click on both targets.
43
+ expect(venue.contains(dish)).toBe(false);
44
+ expect(dish.contains(venue)).toBe(false);
45
+ });
46
+
47
+ /**
48
+ * `must_try` arrives from a list endpoint, so roughly a third of entries have
49
+ * no `slug`. Those must render as content, not as a link to nowhere.
50
+ */
51
+ it("renders an inert dish line when the must-try has no menu row behind it", () => {
52
+ const noHref = { ...kitchen, dish: { name: "Risotto ai Funghi", kicker: "MOST NAMED HERE" } };
53
+ render(<MenuKitchenList kitchens={[noHref]} labels={labels} />);
54
+
55
+ expect(screen.getByText("Risotto ai Funghi")).toBeInTheDocument();
56
+ expect(screen.queryByRole("link", { name: /^See / })).not.toBeInTheDocument();
57
+ expect(screen.getByRole("link", { name: "Open Salt & Stone" })).toBeInTheDocument();
58
+ });
59
+
60
+ it("still reads as a venue with no dish at all", () => {
61
+ const bare: MenuKitchenPreview = {
62
+ place: { id: "p2", slug: "green-room", name: "Green Room" },
63
+ href: "/places/green-room",
64
+ };
65
+ render(<MenuKitchenList kitchens={[bare]} labels={labels} />);
66
+
67
+ expect(screen.getByText("Green Room")).toBeInTheDocument();
68
+ expect(screen.getByRole("link", { name: "Open Green Room" })).toBeInTheDocument();
69
+ });
70
+
71
+ it("omits the show-more control until the caller wires one", () => {
72
+ const { rerender } = render(<MenuKitchenList kitchens={[kitchen]} labels={labels} />);
73
+ expect(screen.queryByRole("button", { name: "Show 10 more" })).not.toBeInTheDocument();
74
+
75
+ rerender(<MenuKitchenList kitchens={[kitchen]} labels={labels} onShowMore={() => {}} />);
76
+ expect(screen.getByRole("button", { name: "Show 10 more" })).toBeInTheDocument();
77
+ });
78
+ });