@bearmenu/ui 0.8.7 → 0.8.9
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-637L7XQ3.js → chunk-3TNPSN4D.js} +28 -9
- package/dist/chunk-62ES6N32.js +27 -0
- package/dist/chunk-AHL45DD2.js +145 -0
- package/dist/chunk-AXV5O2IZ.js +58 -0
- package/dist/chunk-IYDH5IU6.js +66 -0
- package/dist/chunk-OPVKL4VS.js +18 -0
- package/dist/chunk-Q6QH52LJ.js +38 -0
- package/dist/chunk-TH6W2PWG.js +213 -0
- package/dist/chunk-THOVPZNN.js +37 -0
- package/dist/chunk-YS6BHKZO.js +92 -0
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/category-deck.js +10 -1
- package/dist/components/command.d.ts +7 -7
- package/dist/components/concept-compare.d.ts +73 -0
- package/dist/components/concept-compare.js +162 -0
- package/dist/components/coverflow.d.ts +11 -3
- package/dist/components/coverflow.js +34 -51
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/drift-rail.d.ts +24 -10
- package/dist/components/drift-rail.js +1 -1
- package/dist/components/event-card.d.ts +8 -0
- package/dist/components/event-card.js +64 -34
- package/dist/components/event-compact-row.d.ts +33 -6
- package/dist/components/event-compact-row.js +120 -39
- package/dist/components/event-day-header.js +3 -1
- package/dist/components/event-day-separator.d.ts +7 -1
- package/dist/components/event-day-separator.js +3 -2
- package/dist/components/event-detail-panel.d.ts +171 -25
- package/dist/components/event-detail-panel.js +471 -174
- package/dist/components/event-hub-hero.d.ts +25 -4
- package/dist/components/event-hub-hero.js +89 -19
- package/dist/components/event-list-row-wide.js +1 -1
- package/dist/components/event-list-row.d.ts +19 -6
- package/dist/components/event-list-row.js +79 -35
- package/dist/components/hour-band.d.ts +38 -40
- package/dist/components/hour-band.js +55 -27
- package/dist/components/hour-group.js +1 -1
- package/dist/components/hub-surface.d.ts +27 -0
- package/dist/components/hub-surface.js +20 -0
- package/dist/components/menu-family-card.d.ts +59 -0
- package/dist/components/menu-family-card.js +121 -0
- package/dist/components/menu-field.d.ts +57 -0
- package/dist/components/menu-field.js +3 -0
- package/dist/components/menu-hub-hero.d.ts +65 -0
- package/dist/components/menu-hub-hero.js +103 -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-mocks.d.ts +55 -0
- package/dist/components/menu-mocks.js +379 -0
- package/dist/components/menu-types.d.ts +245 -0
- package/dist/components/menu-types.js +1 -0
- package/dist/components/motion-icon-set.js +15 -3
- package/dist/components/open-now-list.d.ts +40 -0
- package/dist/components/open-now-list.js +69 -0
- package/dist/components/place-types.d.ts +112 -1
- package/dist/components/poster-tape.js +1 -1
- package/dist/components/poster-wall.d.ts +3 -3
- package/dist/components/poster-wall.js +73 -17
- package/dist/components/ranked-venue-list.d.ts +49 -0
- package/dist/components/ranked-venue-list.js +87 -0
- package/dist/components/section-header.d.ts +16 -1
- package/dist/components/section-header.js +26 -7
- package/dist/components/signature-chip.d.ts +37 -0
- package/dist/components/signature-chip.js +4 -0
- package/dist/components/signature-line-list.d.ts +45 -0
- package/dist/components/signature-line-list.js +99 -0
- package/dist/components/snap-rail.d.ts +54 -0
- package/dist/components/snap-rail.js +109 -0
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/stat-banner.d.ts +47 -0
- package/dist/components/stat-banner.js +61 -0
- package/dist/components/text.d.ts +3 -3
- package/dist/components/timeline-band.d.ts +20 -2
- package/dist/components/timeline-band.js +1 -1
- package/dist/components/timeline-playhead.d.ts +26 -0
- package/dist/components/timeline-playhead.js +25 -0
- package/dist/components/venue-busyness-strip.d.ts +35 -0
- package/dist/components/venue-busyness-strip.js +37 -0
- package/dist/components/venue-grade-dimensions.d.ts +36 -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-signature-block.d.ts +39 -0
- package/dist/components/venue-signature-block.js +6 -0
- package/dist/components/venue-spotlight.d.ts +74 -0
- package/dist/components/venue-spotlight.js +138 -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.d.ts +5 -4
- package/dist/components/weekend-panel.js +85 -71
- package/dist/hooks/use-drag-swipe.d.ts +44 -0
- package/dist/hooks/use-drag-swipe.js +2 -0
- package/dist/hooks/use-media-query.d.ts +17 -0
- package/dist/hooks/use-media-query.js +2 -0
- package/package.json +1 -1
- package/src/components/category-deck.tsx +19 -8
- package/src/components/colour-dictionary.stories.tsx +227 -0
- package/src/components/concept-compare.stories.tsx +104 -0
- package/src/components/concept-compare.tsx +276 -0
- package/src/components/coverflow.test.tsx +28 -1
- package/src/components/coverflow.tsx +37 -43
- package/src/components/drift-rail.tsx +167 -42
- package/src/components/event-card.tsx +55 -10
- package/src/components/event-compact-row.stories.tsx +30 -0
- package/src/components/event-compact-row.test.tsx +49 -0
- package/src/components/event-compact-row.tsx +162 -38
- package/src/components/event-day-header.tsx +3 -1
- package/src/components/event-day-separator.tsx +8 -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/event-hub-hero.stories.tsx +30 -0
- package/src/components/event-hub-hero.tsx +102 -30
- package/src/components/event-list-row.stories.tsx +34 -0
- package/src/components/event-list-row.test.tsx +53 -0
- package/src/components/event-list-row.tsx +135 -87
- package/src/components/hour-band.stories.tsx +28 -5
- package/src/components/hour-band.tsx +90 -70
- package/src/components/hub-surface.stories.tsx +68 -0
- package/src/components/hub-surface.tsx +40 -0
- package/src/components/menu-family-card.stories.tsx +112 -0
- package/src/components/menu-family-card.tsx +211 -0
- package/src/components/menu-field.stories.tsx +74 -0
- package/src/components/menu-field.tsx +199 -0
- package/src/components/menu-hub-hero.stories.tsx +92 -0
- package/src/components/menu-hub-hero.tsx +187 -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-mocks.ts +417 -0
- package/src/components/menu-types.ts +259 -0
- package/src/components/motion-icon-set.tsx +30 -3
- package/src/components/open-now-list.stories.tsx +76 -0
- package/src/components/open-now-list.tsx +127 -0
- package/src/components/place-types.ts +112 -0
- package/src/components/poster-wall.test.tsx +28 -0
- package/src/components/poster-wall.tsx +96 -23
- package/src/components/ranked-venue-list.stories.tsx +80 -0
- package/src/components/ranked-venue-list.tsx +177 -0
- package/src/components/section-header.stories.tsx +24 -0
- package/src/components/section-header.test.tsx +28 -0
- package/src/components/section-header.tsx +45 -7
- package/src/components/signature-chip.tsx +77 -0
- package/src/components/signature-line-list.stories.tsx +85 -0
- package/src/components/signature-line-list.tsx +136 -0
- package/src/components/snap-rail.stories.tsx +79 -0
- package/src/components/snap-rail.test.tsx +18 -0
- package/src/components/snap-rail.tsx +148 -0
- package/src/components/stat-banner.stories.tsx +100 -0
- package/src/components/stat-banner.tsx +117 -0
- package/src/components/timeline-band.tsx +52 -12
- package/src/components/timeline-playhead.stories.tsx +30 -0
- package/src/components/timeline-playhead.test.tsx +11 -0
- package/src/components/timeline-playhead.tsx +46 -0
- package/src/components/venue-busyness-strip.stories.tsx +66 -0
- package/src/components/venue-busyness-strip.tsx +64 -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-signature-block.stories.tsx +68 -0
- package/src/components/venue-signature-block.tsx +71 -0
- package/src/components/venue-spotlight.stories.tsx +141 -0
- package/src/components/venue-spotlight.tsx +236 -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/src/components/weekend-panel.test.tsx +43 -0
- package/src/components/weekend-panel.tsx +142 -108
- package/src/hooks/use-drag-swipe.test.tsx +66 -0
- package/src/hooks/use-drag-swipe.ts +105 -0
- package/src/hooks/use-media-query.ts +31 -0
- package/dist/chunk-GPH6L4DX.js +0 -55
|
@@ -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
|
+
});
|