@bearmenu/ui 0.8.55 → 0.8.57
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-CZ2HC7WY.js → chunk-EGN5DOLQ.js} +2 -2
- package/dist/{chunk-YE4ZQUTI.js → chunk-QOXXIYDG.js} +14 -2
- package/dist/{chunk-I5V446BX.js → chunk-TDTLEDX6.js} +1 -1
- package/dist/{chunk-CBBECMBD.js → chunk-WBTSOARU.js} +55 -2
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/banner.d.ts +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/card.d.ts +2 -2
- package/dist/components/command.d.ts +15 -15
- package/dist/components/concept-compare.d.ts +1 -1
- package/dist/components/day-rail-chip.d.ts +8 -1
- package/dist/components/day-rail-chip.js +5 -2
- package/dist/components/doorway-tiles.d.ts +1 -1
- package/dist/components/empty-state.d.ts +2 -2
- package/dist/components/event-detail-panel.d.ts +1 -1
- package/dist/components/event-detail-panel.js +2 -2
- package/dist/components/grade-atom.d.ts +1 -1
- package/dist/components/grade-atom.js +2 -2
- package/dist/components/kitchen-dossier.d.ts +1 -1
- package/dist/components/menu-dead-end.d.ts +1 -1
- package/dist/components/menu-family-block.d.ts +1 -1
- package/dist/components/menu-family-block.js +3 -3
- package/dist/components/menu-family-card.d.ts +1 -1
- package/dist/components/menu-filter-chips.d.ts +1 -1
- package/dist/components/menu-group-rows.d.ts +1 -1
- package/dist/components/menu-index-header.d.ts +1 -1
- package/dist/components/menu-index-list.d.ts +1 -1
- package/dist/components/menu-intent-hero.d.ts +1 -1
- package/dist/components/menu-item.d.ts +34 -1
- package/dist/components/menu-item.js +3 -1
- package/dist/components/menu-kitchen-list.d.ts +1 -1
- package/dist/components/menu-kitchen-list.js +3 -3
- package/dist/components/menu-mocks.d.ts +1 -1
- package/dist/components/menu-types.d.ts +1 -1
- package/dist/components/open-now-list.d.ts +1 -1
- package/dist/components/place-about-tab.d.ts +1 -1
- package/dist/components/place-card.d.ts +1 -1
- package/dist/components/place-card.js +2 -2
- package/dist/components/place-details-mobile.d.ts +1 -1
- package/dist/components/place-schedule-week.d.ts +1 -1
- package/dist/components/place-types.d.ts +1 -1
- package/dist/components/qa-block.d.ts +1 -1
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +2 -2
- package/dist/components/top-comparison.d.ts +1 -1
- package/dist/components/top-cross-link.d.ts +1 -1
- package/dist/components/top-ranking-entry.d.ts +1 -1
- package/dist/components/top-ranking-entry.js +3 -1
- package/dist/components/top-ranking-hero.d.ts +1 -1
- package/dist/components/top-types.d.ts +1 -1
- package/dist/components/venue-grade-dimensions.d.ts +1 -1
- package/dist/components/venue-quotes.d.ts +1 -1
- package/dist/components/venue-wall.d.ts +1 -1
- package/dist/components/weekday-chip.d.ts +13 -2
- package/dist/components/weekday-chip.js +14 -2
- package/dist/lib/grade.d.ts +1 -1
- package/dist/lib/grade.js +1 -1
- package/dist/{place-types-Dzy6FH67.d.ts → place-types-CjB4JlH0.d.ts} +2 -2
- package/package.json +1 -1
- package/src/components/booking-primitives.test.tsx +7 -0
- package/src/components/day-rail-chip.test.tsx +58 -0
- package/src/components/day-rail-chip.tsx +17 -1
- package/src/components/grade-atom.test.tsx +15 -0
- package/src/components/grade-atom.tsx +15 -3
- package/src/components/menu-item.stories.tsx +108 -0
- package/src/components/menu-item.test.tsx +100 -0
- package/src/components/menu-item.tsx +89 -6
- package/src/components/weekday-chip.stories.tsx +4 -0
- package/src/components/weekday-chip.tsx +25 -2
- package/src/lib/grade.ts +2 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import type { GradeAtomLabels } from "./grade-atom";
|
|
3
4
|
import { MentionSignal, MenuItem, type MenuItemLabels } from "./menu-item";
|
|
4
5
|
import type { MenuItemPreview } from "./menu-types";
|
|
5
6
|
|
|
@@ -131,3 +132,102 @@ describe("MenuItem emphasis", () => {
|
|
|
131
132
|
expect(price.className).toContain("text-[13px]");
|
|
132
133
|
});
|
|
133
134
|
});
|
|
135
|
+
|
|
136
|
+
// On cross-venue surfaces the venue line can say whether the place is open:
|
|
137
|
+
// a dot (success green open, muted closed) and the words. The words carry the
|
|
138
|
+
// meaning; the dot is the glance. A long venue gives way, the status never.
|
|
139
|
+
describe("MenuItem venue status", () => {
|
|
140
|
+
const row = (venueStatus?: { tone: "open" | "closed"; label: string }) =>
|
|
141
|
+
render(
|
|
142
|
+
<MenuItem
|
|
143
|
+
item={wine}
|
|
144
|
+
labels={labels}
|
|
145
|
+
formatPrice={String}
|
|
146
|
+
venueName="Texas Grill · Centru"
|
|
147
|
+
venueStatus={venueStatus}
|
|
148
|
+
onOpen={() => {}}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
it("says an open place closes, with a green dot", () => {
|
|
153
|
+
const { container } = row({ tone: "open", label: "Closes at 23:00" });
|
|
154
|
+
const status = container.querySelector('[data-slot="venue-status"]')!;
|
|
155
|
+
expect(status.textContent).toBe("Closes at 23:00");
|
|
156
|
+
expect(status.getAttribute("data-tone")).toBe("open");
|
|
157
|
+
expect(status.querySelector("[aria-hidden]")!.className).toContain("bg-success");
|
|
158
|
+
expect(status.className).toContain("shrink-0");
|
|
159
|
+
expect(screen.getByText("Texas Grill · Centru").className).toContain("min-w-0");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("says a closed place opens, with a muted dot", () => {
|
|
163
|
+
const { container } = row({ tone: "closed", label: "Opens at 09:00" });
|
|
164
|
+
const dot = container.querySelector('[data-slot="venue-status"] [aria-hidden]')!;
|
|
165
|
+
expect(dot.className).toContain("bg-muted-foreground");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("draws no status when none is given", () => {
|
|
169
|
+
const { container } = row();
|
|
170
|
+
expect(container.querySelector('[data-slot="venue-status"]')).toBeNull();
|
|
171
|
+
expect(screen.getByText("Texas Grill · Centru")).toBeTruthy();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// The venue line has its own order (Adrian, 2026-11-09): the place in full
|
|
176
|
+
// ink, its grade beside it, then its neighbourhood and hours, muted. Before,
|
|
177
|
+
// all of it was one grey string and the place weighed what its details did.
|
|
178
|
+
describe("MenuItem venue line", () => {
|
|
179
|
+
const grade = { overall: 8.7, show_number: true, capped: false, confidence: "high" } as const;
|
|
180
|
+
const gradeLabels: GradeAtomLabels = {
|
|
181
|
+
soft: "Worth exploring",
|
|
182
|
+
bands: { exceptional: "Exceptional", great: "Great", good: "Good", decent: "Decent", weak: "Weak" },
|
|
183
|
+
aria: (value) => `Grade ${value} out of 10`,
|
|
184
|
+
capped: "Capped",
|
|
185
|
+
};
|
|
186
|
+
const venue = (props: Partial<Parameters<typeof MenuItem>[0]>) =>
|
|
187
|
+
render(
|
|
188
|
+
<MenuItem
|
|
189
|
+
item={wine}
|
|
190
|
+
labels={labels}
|
|
191
|
+
formatPrice={String}
|
|
192
|
+
venueName="Texas Grill"
|
|
193
|
+
onOpen={() => {}}
|
|
194
|
+
{...props}
|
|
195
|
+
/>
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
it("sets the place in full ink and its neighbourhood muted after it", () => {
|
|
199
|
+
venue({ venueDetail: "Centru" });
|
|
200
|
+
const place = screen.getByText("Texas Grill");
|
|
201
|
+
expect(place.className).toContain("text-foreground");
|
|
202
|
+
expect(place.className).toContain("font-semibold");
|
|
203
|
+
const detail = screen.getByText("Centru");
|
|
204
|
+
expect(detail.className).toContain("text-muted-foreground");
|
|
205
|
+
expect(detail.className).toContain("font-normal");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("puts the place's grade beside its name", () => {
|
|
209
|
+
venue({ venueGrade: { grade, labels: gradeLabels } });
|
|
210
|
+
// The inline size: the pill fits the line instead of making it taller.
|
|
211
|
+
expect(screen.getByLabelText("Grade 8.7 out of 10").className).toContain("h-[18px]");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// The map's Drinks rows lead with the bar; their venue line says what it
|
|
215
|
+
// is praised for. The grade goes with the bar's name, never beside
|
|
216
|
+
// "Praised for cocktails" (Adrian, 9 Nov).
|
|
217
|
+
it("can put the grade beside the row's name when the name is the place", () => {
|
|
218
|
+
const { container } = venue({
|
|
219
|
+
item: { ...wine, name: "The Soviet" },
|
|
220
|
+
venueName: "Praised for cocktails",
|
|
221
|
+
nameGrade: { grade, labels: gradeLabels },
|
|
222
|
+
});
|
|
223
|
+
const pill = screen.getByLabelText("Grade 8.7 out of 10");
|
|
224
|
+
expect(container.querySelector("h3")!.parentElement!.contains(pill)).toBe(true);
|
|
225
|
+
expect(screen.getByText("Praised for cocktails").parentElement!.contains(pill)).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("draws no grade without a number to show", () => {
|
|
229
|
+
venue({ venueGrade: { grade: { ...grade, show_number: false }, labels: gradeLabels } });
|
|
230
|
+
expect(screen.queryByLabelText(/^Grade/)).toBeNull();
|
|
231
|
+
expect(screen.queryByText("Worth exploring")).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import { MessageSquareQuote, Star, ThumbsUp } from "lucide-react";
|
|
3
|
+
import { showsGradeAtom } from "../lib/grade";
|
|
3
4
|
import { cn } from "../lib/utils";
|
|
5
|
+
import { GradeAtom, type GradeAtomLabels } from "./grade-atom";
|
|
6
|
+
import type { PlaceGrade } from "./place-types";
|
|
4
7
|
import { SignatureChip } from "./signature-chip";
|
|
5
8
|
import type { MenuItemPreview, MenuLinkProps, MenuLinkComponent } from "./menu-types";
|
|
6
9
|
|
|
@@ -142,6 +145,30 @@ export type MenuItemProps = {
|
|
|
142
145
|
* default.
|
|
143
146
|
*/
|
|
144
147
|
venueName?: string;
|
|
148
|
+
/**
|
|
149
|
+
* Whether the venue is open, on the venue line after its name: a dot
|
|
150
|
+
* (`open` in the success green, `closed` muted) and the caller's words, e.g.
|
|
151
|
+
* "Closes at 23:00" / "Opens at 09:00". Only with `venueName`.
|
|
152
|
+
*/
|
|
153
|
+
venueStatus?: { tone: "open" | "closed"; label: string };
|
|
154
|
+
/**
|
|
155
|
+
* A short detail about the venue, muted after its name and grade: its
|
|
156
|
+
* neighbourhood, say. Only with `venueName`.
|
|
157
|
+
*/
|
|
158
|
+
venueDetail?: string;
|
|
159
|
+
/**
|
|
160
|
+
* The venue's grade, drawn as `<GradeAtom size="2xs">` (inline, 18px) beside its name. Only
|
|
161
|
+
* a grade with a number shows: the xs atom draws nothing for the soft
|
|
162
|
+
* "worth exploring" state (see `showsGradeAtom`).
|
|
163
|
+
*/
|
|
164
|
+
venueGrade?: { grade: PlaceGrade; labels: GradeAtomLabels };
|
|
165
|
+
/**
|
|
166
|
+
* A grade beside the row's NAME, for rows where the name is the place (the
|
|
167
|
+
* map's Drinks list leads with the bar, and its venue line says what the
|
|
168
|
+
* bar is praised for). Use it instead of `venueGrade` there, so the grade
|
|
169
|
+
* never sits beside "Praised for cocktails". Same rule: only with a number.
|
|
170
|
+
*/
|
|
171
|
+
nameGrade?: { grade: PlaceGrade; labels: GradeAtomLabels };
|
|
145
172
|
/**
|
|
146
173
|
* Explicit subtitle, in italic, in place of the description. Used where the
|
|
147
174
|
* reviewer's reason IS the row's content rather than the payoff for tapping.
|
|
@@ -272,6 +299,10 @@ export const MenuItem = memo(function MenuItem({
|
|
|
272
299
|
formatRating = (score) => score.toFixed(1),
|
|
273
300
|
markGradient,
|
|
274
301
|
venueName,
|
|
302
|
+
venueStatus,
|
|
303
|
+
venueDetail,
|
|
304
|
+
venueGrade,
|
|
305
|
+
nameGrade,
|
|
275
306
|
subtitle,
|
|
276
307
|
href,
|
|
277
308
|
linkLabel,
|
|
@@ -399,6 +430,15 @@ export const MenuItem = memo(function MenuItem({
|
|
|
399
430
|
<div className="min-w-0 flex-1">
|
|
400
431
|
<div className="flex items-start gap-1.5">
|
|
401
432
|
<h3 className="text-[15px] font-semibold leading-snug text-foreground">{item.name}</h3>
|
|
433
|
+
{nameGrade && showsGradeAtom(nameGrade.grade, "2xs") && (
|
|
434
|
+
<GradeAtom
|
|
435
|
+
grade={nameGrade.grade}
|
|
436
|
+
labels={nameGrade.labels}
|
|
437
|
+
size="2xs"
|
|
438
|
+
hideDescriptor
|
|
439
|
+
className="shrink-0 self-center"
|
|
440
|
+
/>
|
|
441
|
+
)}
|
|
402
442
|
{showSignatureBadge && (
|
|
403
443
|
<SignatureChip
|
|
404
444
|
label={signatureTier === "weak" ? labels.mentioned : labels.signature}
|
|
@@ -408,13 +448,56 @@ export const MenuItem = memo(function MenuItem({
|
|
|
408
448
|
)}
|
|
409
449
|
</div>
|
|
410
450
|
|
|
411
|
-
{/* Venue attribution — cross-venue surfaces only.
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
451
|
+
{/* Venue attribution — cross-venue surfaces only. The place in full
|
|
452
|
+
ink, semibold at 12px, is a different KIND of fact from the muted
|
|
453
|
+
description below, and outranks its own details: the grade beside
|
|
454
|
+
it, then the neighbourhood and the hours, muted and regular. Before
|
|
455
|
+
0.8.56 the line was one grey string, and the place weighed what its
|
|
456
|
+
details did. No pin glyph: repeated down a list it is visual lint,
|
|
457
|
+
and a pin reads "address" when the fact is "kitchen". */}
|
|
416
458
|
{venueName && (
|
|
417
|
-
<p className="mt-0.5
|
|
459
|
+
<p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[12px]">
|
|
460
|
+
<span className="min-w-0 truncate font-semibold text-foreground">{venueName}</span>
|
|
461
|
+
{venueGrade && showsGradeAtom(venueGrade.grade, "2xs") && (
|
|
462
|
+
<GradeAtom
|
|
463
|
+
grade={venueGrade.grade}
|
|
464
|
+
labels={venueGrade.labels}
|
|
465
|
+
size="2xs"
|
|
466
|
+
hideDescriptor
|
|
467
|
+
className="shrink-0"
|
|
468
|
+
/>
|
|
469
|
+
)}
|
|
470
|
+
{venueDetail && (
|
|
471
|
+
<>
|
|
472
|
+
<span aria-hidden className="text-muted-foreground">
|
|
473
|
+
·
|
|
474
|
+
</span>
|
|
475
|
+
{/* Short by nature (a neighbourhood): it keeps its width, and
|
|
476
|
+
a long place name gives way instead of leaving "Z…". */}
|
|
477
|
+
<span className="shrink-0 font-normal text-muted-foreground">{venueDetail}</span>
|
|
478
|
+
</>
|
|
479
|
+
)}
|
|
480
|
+
{/* Whether the place is open: a dot for the glance, the words for
|
|
481
|
+
the meaning. Green is the success token, closed stays muted:
|
|
482
|
+
at 10 a.m. most bars are closed, and a list of red rows would
|
|
483
|
+
read as an alarm. A long venue gives way; the status never. */}
|
|
484
|
+
{venueStatus && (
|
|
485
|
+
<span
|
|
486
|
+
data-slot="venue-status"
|
|
487
|
+
data-tone={venueStatus.tone}
|
|
488
|
+
className="inline-flex shrink-0 items-center gap-1 font-normal text-muted-foreground"
|
|
489
|
+
>
|
|
490
|
+
<span
|
|
491
|
+
aria-hidden
|
|
492
|
+
className={cn(
|
|
493
|
+
"size-1.5 shrink-0 rounded-full",
|
|
494
|
+
venueStatus.tone === "open" ? "bg-success" : "bg-muted-foreground"
|
|
495
|
+
)}
|
|
496
|
+
/>
|
|
497
|
+
{venueStatus.label}
|
|
498
|
+
</span>
|
|
499
|
+
)}
|
|
500
|
+
</p>
|
|
418
501
|
)}
|
|
419
502
|
|
|
420
503
|
{/* The description gives up a line to the venue rather than the row
|
|
@@ -22,6 +22,10 @@ type Story = StoryObj<typeof WeekdayChip>;
|
|
|
22
22
|
export const Default: Story = { args: { weekday: "Mon", day: "10" } };
|
|
23
23
|
export const Selected: Story = { args: { weekday: "Fri", day: "14", selected: true } };
|
|
24
24
|
export const Unavailable: Story = { args: { weekday: "Tue", day: "11", unavailable: true } };
|
|
25
|
+
/** The events map's week picker: the day's event count under the date. */
|
|
26
|
+
export const WithDetail: Story = {
|
|
27
|
+
args: { weekday: "Fri", day: "13", detail: "12", "aria-label": "Friday 13 November, 12 events" },
|
|
28
|
+
};
|
|
25
29
|
|
|
26
30
|
const WEEK = [
|
|
27
31
|
["Mon", "10"],
|
|
@@ -12,11 +12,19 @@ import { cn } from "../lib/utils";
|
|
|
12
12
|
* over the date, no count — and the rail shares the width equally.
|
|
13
13
|
*
|
|
14
14
|
* Measured from the booking design (`.dchip`): flexes to an equal share, 1px
|
|
15
|
-
* hairline, 11px radius, 7px vertical inset, centred. The weekday at
|
|
15
|
+
* hairline, 11px radius, 7px vertical inset, centred. The weekday at 11px
|
|
16
16
|
* uppercase, 0.05em tracking, muted; the date at 15px bold, tabular, 2px
|
|
17
17
|
* below. Selected fills with `--gradient-primary`, white type, the weekday at
|
|
18
18
|
* 75%. A day that cannot be booked (closed, past the window) fades to 40% and
|
|
19
19
|
* is disabled.
|
|
20
|
+
*
|
|
21
|
+
* The weekday was 10px, under the HIG's 11pt floor for iPhone text; measured
|
|
22
|
+
* when the events map took this chip for its week picker (bearmenu-fe,
|
|
23
|
+
* 2026-11-10). 11px still holds a three-letter weekday in a 46px seventh of a
|
|
24
|
+
* phone's width.
|
|
25
|
+
*
|
|
26
|
+
* `detail` is an optional third line under the date: the events map prints
|
|
27
|
+
* that day's event count there. A chip without it keeps its booking height.
|
|
20
28
|
*/
|
|
21
29
|
|
|
22
30
|
export type WeekdayChipProps = {
|
|
@@ -24,6 +32,9 @@ export type WeekdayChipProps = {
|
|
|
24
32
|
weekday: string;
|
|
25
33
|
/** Day of month as displayed — "14". */
|
|
26
34
|
day: string;
|
|
35
|
+
/** A short line under the date — "12", that day's events. Give the button
|
|
36
|
+
* an `aria-label` that says it with its unit; a bare number is unclear. */
|
|
37
|
+
detail?: string;
|
|
27
38
|
selected?: boolean;
|
|
28
39
|
/** Closed that day or outside the booking window. */
|
|
29
40
|
unavailable?: boolean;
|
|
@@ -36,6 +47,7 @@ export type WeekdayChipProps = {
|
|
|
36
47
|
export function WeekdayChip({
|
|
37
48
|
weekday,
|
|
38
49
|
day,
|
|
50
|
+
detail,
|
|
39
51
|
selected,
|
|
40
52
|
unavailable,
|
|
41
53
|
onSelect,
|
|
@@ -63,13 +75,24 @@ export function WeekdayChip({
|
|
|
63
75
|
>
|
|
64
76
|
<span
|
|
65
77
|
className={cn(
|
|
66
|
-
"block text-[
|
|
78
|
+
"block text-[11px] tracking-[0.05em] uppercase",
|
|
67
79
|
selected ? "text-white/75" : "text-muted-foreground"
|
|
68
80
|
)}
|
|
69
81
|
>
|
|
70
82
|
{weekday}
|
|
71
83
|
</span>
|
|
72
84
|
<span className="mt-0.5 block text-[15px] font-bold tabular-nums">{day}</span>
|
|
85
|
+
{detail !== undefined && (
|
|
86
|
+
<span
|
|
87
|
+
data-slot="weekday-chip-detail"
|
|
88
|
+
className={cn(
|
|
89
|
+
"mt-0.5 block font-mono text-[11px] tabular-nums",
|
|
90
|
+
selected ? "text-white/85" : "text-muted-foreground"
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
{detail}
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
73
96
|
</button>
|
|
74
97
|
);
|
|
75
98
|
}
|
package/src/lib/grade.ts
CHANGED
|
@@ -58,10 +58,10 @@ export function shouldShowGradeNumber(grade: { show_number?: boolean }): boolean
|
|
|
58
58
|
*/
|
|
59
59
|
export function showsGradeAtom<T extends { overall?: number | null; show_number?: boolean }>(
|
|
60
60
|
grade: T | null | undefined,
|
|
61
|
-
size: "xs" | "sm" | "md" | "lg"
|
|
61
|
+
size: "2xs" | "xs" | "sm" | "md" | "lg"
|
|
62
62
|
): grade is T {
|
|
63
63
|
if (grade?.overall == null) return false;
|
|
64
|
-
return size !== "xs" || shouldShowGradeNumber(grade);
|
|
64
|
+
return (size !== "xs" && size !== "2xs") || shouldShowGradeNumber(grade);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** Tone for a single 0–100 dimension score, driving the page dimension-bar
|