@bearmenu/ui 0.8.24 → 0.8.26

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 (113) hide show
  1. package/dist/{chunk-5LTILG3L.js → chunk-5HHRVJOT.js} +14 -1
  2. package/dist/{chunk-SDWN6L4O.js → chunk-AHHDC7ML.js} +1 -1
  3. package/dist/{chunk-I4RUOAU7.js → chunk-AHL45DD2.js} +11 -38
  4. package/dist/{chunk-TIQNM4XD.js → chunk-F3XK34DV.js} +25 -1
  5. package/dist/{chunk-5XREWOBB.js → chunk-IEIJP73R.js} +14 -1
  6. package/dist/chunk-L3CDXVEP.js +56 -0
  7. package/dist/{chunk-BKKQ7GCZ.js → chunk-QG6I2ILT.js} +1 -1
  8. package/dist/{chunk-CZK2MRS2.js → chunk-VEFKZIK6.js} +13 -3
  9. package/dist/components/alert-dialog.js +1 -1
  10. package/dist/components/badge.d.ts +1 -1
  11. package/dist/components/badge.js +1 -1
  12. package/dist/components/banner.d.ts +36 -0
  13. package/dist/components/banner.js +29 -0
  14. package/dist/components/booking-row.d.ts +60 -0
  15. package/dist/components/booking-row.js +103 -0
  16. package/dist/components/booking-summary-card.d.ts +38 -0
  17. package/dist/components/booking-summary-card.js +67 -0
  18. package/dist/components/button.d.ts +2 -2
  19. package/dist/components/button.js +1 -1
  20. package/dist/components/calendar.d.ts +17 -3
  21. package/dist/components/calendar.js +100 -12
  22. package/dist/components/carousel.js +1 -1
  23. package/dist/components/checkbox.d.ts +9 -2
  24. package/dist/components/checkbox.js +1 -1
  25. package/dist/components/coach-marks.js +1 -1
  26. package/dist/components/combobox.js +1 -1
  27. package/dist/components/concept-convergence.js +2 -2
  28. package/dist/components/currency-input.js +1 -1
  29. package/dist/components/date-input.js +1 -1
  30. package/dist/components/drift-rail.d.ts +2 -29
  31. package/dist/components/drift-rail.js +1 -3
  32. package/dist/components/empty-state.js +1 -1
  33. package/dist/components/event-card.js +1 -1
  34. package/dist/components/event-compact-row.js +1 -1
  35. package/dist/components/event-detail-panel.js +2 -2
  36. package/dist/components/event-index-row.js +1 -1
  37. package/dist/components/event-list-row-wide.js +3 -3
  38. package/dist/components/event-list-row.js +2 -2
  39. package/dist/components/field.d.ts +15 -1
  40. package/dist/components/field.js +62 -21
  41. package/dist/components/filter-category-row.js +1 -1
  42. package/dist/components/hold-countdown.d.ts +43 -0
  43. package/dist/components/hold-countdown.js +90 -0
  44. package/dist/components/input.d.ts +1 -0
  45. package/dist/components/input.js +1 -1
  46. package/dist/components/link-button.js +1 -1
  47. package/dist/components/menu-family-block.js +1 -1
  48. package/dist/components/menu-index-list.js +1 -1
  49. package/dist/components/menu-item.js +3 -3
  50. package/dist/components/motion-icon-set.d.ts +2 -1
  51. package/dist/components/motion-icon-set.js +27 -1
  52. package/dist/components/multi-select-combobox.js +3 -3
  53. package/dist/components/multi-select-menu.js +1 -1
  54. package/dist/components/pagination.js +1 -1
  55. package/dist/components/party-counter.d.ts +36 -0
  56. package/dist/components/party-counter.js +61 -0
  57. package/dist/components/place-card.js +1 -1
  58. package/dist/components/place-details-mobile.js +1 -1
  59. package/dist/components/poster-tape.d.ts +2 -15
  60. package/dist/components/poster-tape.js +3 -12
  61. package/dist/components/searchable-select.js +1 -1
  62. package/dist/components/signature-chip.js +2 -2
  63. package/dist/components/sliding-panels.js +1 -1
  64. package/dist/components/textarea.d.ts +10 -3
  65. package/dist/components/textarea.js +21 -6
  66. package/dist/components/time-slot.d.ts +73 -0
  67. package/dist/components/time-slot.js +106 -0
  68. package/dist/components/toggle-group.js +15 -2
  69. package/dist/components/toggle.d.ts +1 -1
  70. package/dist/components/toggle.js +1 -1
  71. package/dist/components/top-ranking-entry.js +3 -3
  72. package/dist/components/weekday-chip.d.ts +33 -0
  73. package/dist/components/weekday-chip.js +48 -0
  74. package/package.json +207 -23
  75. package/src/components/badge.stories.tsx +17 -0
  76. package/src/components/badge.tsx +15 -0
  77. package/src/components/banner.stories.tsx +36 -0
  78. package/src/components/banner.tsx +58 -0
  79. package/src/components/booking-details-primitives.test.tsx +75 -0
  80. package/src/components/booking-primitives.test.tsx +180 -0
  81. package/src/components/booking-row.stories.tsx +66 -0
  82. package/src/components/booking-row.tsx +168 -0
  83. package/src/components/booking-summary-card.stories.tsx +41 -0
  84. package/src/components/booking-summary-card.tsx +109 -0
  85. package/src/components/button.stories.tsx +23 -0
  86. package/src/components/button.tsx +13 -0
  87. package/src/components/calendar.stories.tsx +27 -0
  88. package/src/components/calendar.tsx +110 -3
  89. package/src/components/checkbox.tsx +49 -23
  90. package/src/components/drift-rail.stories.tsx +1 -29
  91. package/src/components/drift-rail.tsx +4 -71
  92. package/src/components/field.stories.tsx +51 -1
  93. package/src/components/field.tsx +68 -9
  94. package/src/components/hold-countdown.stories.tsx +68 -0
  95. package/src/components/hold-countdown.tsx +140 -0
  96. package/src/components/input.tsx +13 -2
  97. package/src/components/motion-icon-set.stories.tsx +2 -0
  98. package/src/components/motion-icon-set.tsx +42 -0
  99. package/src/components/party-counter.stories.tsx +57 -0
  100. package/src/components/party-counter.tsx +93 -0
  101. package/src/components/poster-tape.stories.tsx +0 -29
  102. package/src/components/poster-tape.tsx +5 -21
  103. package/src/components/textarea.tsx +25 -7
  104. package/src/components/time-slot.stories.tsx +85 -0
  105. package/src/components/time-slot.tsx +186 -0
  106. package/src/components/toggle-group.stories.tsx +27 -0
  107. package/src/components/toggle-group.tsx +14 -1
  108. package/src/components/toggle.tsx +24 -0
  109. package/src/components/weekday-chip.stories.tsx +54 -0
  110. package/src/components/weekday-chip.tsx +75 -0
  111. package/dist/chunk-NMEVJT5Q.js +0 -26
  112. package/src/components/drift-rail.test.tsx +0 -125
  113. package/src/components/poster-tape.test.tsx +0 -42
@@ -68,6 +68,23 @@ export const OnPrimary: Story = {
68
68
  ),
69
69
  };
70
70
 
71
+ /** A booking's status and its code, as in My bookings (B6). */
72
+ export const BookingStatus: Story = {
73
+ render: () => (
74
+ <div className="flex flex-wrap items-center gap-2">
75
+ <Badge as="span" variant="confirmed">
76
+ Confirmed
77
+ </Badge>
78
+ <Badge as="span" variant="pending">
79
+ Pending
80
+ </Badge>
81
+ <Badge as="span" variant="code">
82
+ BM-8421
83
+ </Badge>
84
+ </div>
85
+ ),
86
+ };
87
+
71
88
  export const AllVariants: Story = {
72
89
  render: () => (
73
90
  <div className="flex flex-wrap items-center gap-3">
@@ -23,6 +23,21 @@ const badgeVariants = cva(
23
23
  brown: "border-transparent bg-brown text-brown-foreground",
24
24
  /** A count sitting INSIDE a primary-filled control: its own ink, lifted a notch. */
25
25
  onPrimary: "border-transparent bg-primary-foreground/20 text-primary-foreground",
26
+ /**
27
+ * Status pills of a booking — "Confirmed", "Pending" — and its code
28
+ * chip ("BM-8421"). A full pill at 10.5px bold, as the booking design
29
+ * draws them. The status type is darker than the solid tokens: `success`
30
+ * and `warning` sit at 0.48 / 0.72 lightness, which on their own 15–20%
31
+ * wash fall short of AA at this size, so light mode uses the design's
32
+ * 0.38 / 0.42 inks and dark mode the lifted tokens.
33
+ */
34
+ // `border-0`: the base's transparent hairline would add 2px to each
35
+ // side the design does not have.
36
+ confirmed:
37
+ "rounded-full border-0 bg-success/15 px-[9px] py-[3px] text-[10.5px] leading-[1.2] font-bold text-[oklch(0.38_0.1_155)] dark:text-success",
38
+ pending:
39
+ "rounded-full border-0 bg-warning/20 px-[9px] py-[3px] text-[10.5px] leading-[1.2] font-bold text-[oklch(0.42_0.1_70)] dark:text-warning",
40
+ code: "rounded-full border-0 bg-surface-2 px-[9px] py-[3px] font-mono text-[10.5px] leading-[1.2] font-bold tabular-nums text-ink-secondary",
26
41
  },
27
42
  // v1.0 motion: "live" surface signal — applies `live-pulse` archetype.
28
43
  // Pair with a colored variant (e.g. variant="destructive" live).
@@ -0,0 +1,36 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { TriangleAlert } from "lucide-react";
3
+ import { Banner } from "./banner";
4
+
5
+ const meta: Meta<typeof Banner> = {
6
+ title: "Booking/Banner",
7
+ component: Banner,
8
+ tags: ["autodocs"],
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "A short inline note inside a flow: the booking flow's 'time just went', 'no table was booked', 'this venue does not take bookings'. Borderless, unlike Alert.",
14
+ },
15
+ },
16
+ },
17
+ decorators: [(Story) => <div className="w-[335px]">{Story()}</div>],
18
+ };
19
+
20
+ export default meta;
21
+ type Story = StoryObj<typeof Banner>;
22
+
23
+ /** S5: the time went while the guest was typing. */
24
+ export const Warning: Story = {
25
+ args: {
26
+ tone: "warning",
27
+ icon: <TriangleAlert aria-hidden />,
28
+ title: "19:30 just went",
29
+ children: "Someone booked it while you were filling this in. Your details are kept — pick another time.",
30
+ },
31
+ };
32
+
33
+ /** B0c: a venue that takes no bookings. */
34
+ export const Info: Story = {
35
+ args: { tone: "info", children: "This venue does not take bookings. Walk-ins only." },
36
+ };
@@ -0,0 +1,58 @@
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import { cn } from "../lib/utils";
4
+
5
+ /**
6
+ * Banner — a short inline note inside a flow, not a page-level alert: the
7
+ * booking flow's "19:30 just went" when a time is taken mid-form, "No table
8
+ * was booked" when a confirm fails, "This venue does not take bookings"
9
+ * (booking flow.html, `.bnr`).
10
+ *
11
+ * Not `Alert`: that one is a bordered card for page-level status with a
12
+ * pinned icon column. This is borderless, sits in the flow of a form, and
13
+ * reads as part of the screen — a soft ground, an optional 16px icon, an
14
+ * optional bold title over the body.
15
+ *
16
+ * Measured from the design: 12px radius, 12px × 14px inset, 10px between
17
+ * icon and text; the title at 13px bold, the body at 12.5px (90% in the
18
+ * warning tone), both at 1.5 leading.
19
+ * - `info`: the soft surface and the secondary ink.
20
+ * - `warning`: a 16% amber wash with the design's amber ink — darker than
21
+ * the solid `--warning`, which falls short of AA on its own wash at this
22
+ * size; dark mode uses the lifted token.
23
+ */
24
+ const bannerVariants = cva(
25
+ "flex gap-2.5 rounded-xl px-3.5 py-3 text-[12.5px] leading-normal [&>svg]:mt-0.5 [&>svg]:size-4 [&>svg]:shrink-0",
26
+ {
27
+ variants: {
28
+ tone: {
29
+ info: "bg-surface-2 text-ink-secondary",
30
+ warning:
31
+ "bg-[oklch(0.62_0.15_70/0.16)] text-[oklch(0.42_0.1_70)] dark:bg-warning/15 dark:text-warning",
32
+ },
33
+ },
34
+ defaultVariants: { tone: "info" },
35
+ }
36
+ );
37
+
38
+ export type BannerProps = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> &
39
+ VariantProps<typeof bannerVariants> & {
40
+ /** A lucide icon, drawn at 16px beside the text. */
41
+ icon?: React.ReactNode;
42
+ /** The bold first line — "19:30 just went". */
43
+ title?: React.ReactNode;
44
+ };
45
+
46
+ function Banner({ tone, icon, title, className, children, ...props }: BannerProps) {
47
+ return (
48
+ <div data-slot="banner" className={cn(bannerVariants({ tone }), className)} {...props}>
49
+ {icon}
50
+ <div className="min-w-0">
51
+ {title ? <b className="block text-[13px] font-bold">{title}</b> : null}
52
+ <div className={cn(tone === "warning" && title ? "opacity-90" : undefined)}>{children}</div>
53
+ </div>
54
+ </div>
55
+ );
56
+ }
57
+
58
+ export { Banner, bannerVariants };
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { TriangleAlert } from "lucide-react";
4
+ import { cn } from "../lib/utils";
5
+ import { Banner } from "./banner";
6
+ import { Checkbox } from "./checkbox";
7
+ import { Field, FieldControl, FieldError, FieldLabel, FieldOptional } from "./field";
8
+ import { Input } from "./input";
9
+ import { Textarea } from "./textarea";
10
+ import { toggleVariants } from "./toggle";
11
+
12
+ describe("toggleVariants chip and tab", () => {
13
+ it("keeps the chip's own sizing when called bare, with the default size", () => {
14
+ const classes = cn(toggleVariants({ variant: "chip" })).split(" ");
15
+ expect(classes).toContain("h-auto");
16
+ expect(classes).toContain("px-[13px]");
17
+ expect(classes).not.toContain("h-9");
18
+ expect(classes).not.toContain("px-2");
19
+ });
20
+
21
+ it("keeps the tab's own sizing too", () => {
22
+ const classes = cn(toggleVariants({ variant: "tab", size: "lg" })).split(" ");
23
+ expect(classes).toContain("h-auto");
24
+ expect(classes).toContain("px-3");
25
+ expect(classes).not.toContain("h-10");
26
+ });
27
+ });
28
+
29
+ describe("booking field", () => {
30
+ it("labels its control, marks it invalid and describes it by the error", () => {
31
+ render(
32
+ <Field appearance="booking" invalid>
33
+ <FieldLabel>
34
+ Phone <FieldOptional>(optional)</FieldOptional>
35
+ </FieldLabel>
36
+ <FieldControl>
37
+ <Input appearance="booking" defaultValue="0712 34" />
38
+ </FieldControl>
39
+ <FieldError>That number looks too short.</FieldError>
40
+ </Field>
41
+ );
42
+ const input = screen.getByLabelText(/Phone/);
43
+ expect(input).toHaveAttribute("aria-invalid", "true");
44
+ expect(input).toHaveAccessibleDescription(/That number looks too short\./);
45
+ expect(input.className).toContain("h-[46px]");
46
+ // The booking label stays muted when the field is invalid.
47
+ expect(screen.getByText(/Phone/).className).not.toContain("text-destructive");
48
+ });
49
+
50
+ it("gives the note field and the opt-in their booking shapes", () => {
51
+ render(
52
+ <>
53
+ <Textarea appearance="booking" aria-label="Anything we should know" />
54
+ <Checkbox appearance="booking" aria-label="Save these details" defaultChecked />
55
+ </>
56
+ );
57
+ expect(screen.getByRole("textbox").className).toContain("min-h-[66px]");
58
+ const box = screen.getByRole("checkbox");
59
+ expect(box).toHaveAttribute("data-state", "checked");
60
+ expect(box.className).toContain("size-[17px]");
61
+ });
62
+ });
63
+
64
+ describe("Banner", () => {
65
+ it("reads as its title over its body, with the icon beside", () => {
66
+ render(
67
+ <Banner tone="warning" icon={<TriangleAlert aria-hidden />} title="19:30 just went">
68
+ Someone booked it while you were filling this in.
69
+ </Banner>
70
+ );
71
+ const banner = screen.getByText("19:30 just went").closest("[data-slot=banner]");
72
+ expect(banner).toHaveTextContent("19:30 just wentSomeone booked it");
73
+ expect(banner?.querySelector("svg")).not.toBeNull();
74
+ });
75
+ });
@@ -0,0 +1,180 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { act, render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { PartyCounter } from "./party-counter";
5
+ import { TimeSlot, TimeSlotGroups, TimeSlotGroupsSkeleton } from "./time-slot";
6
+ import { WeekdayChip } from "./weekday-chip";
7
+ import { HoldCountdown } from "./hold-countdown";
8
+ import { BookingSummaryCard } from "./booking-summary-card";
9
+ import { BookingRow, BookingRowList } from "./booking-row";
10
+ import { Calendar } from "./calendar";
11
+
12
+ const counterLabels = {
13
+ decrease: "Fewer people",
14
+ increase: "More people",
15
+ unit: (count: number) => (count === 1 ? "person" : "people"),
16
+ };
17
+
18
+ describe("PartyCounter", () => {
19
+ it("steps the count within its range", async () => {
20
+ const user = userEvent.setup();
21
+ const onValueChange = vi.fn();
22
+ render(<PartyCounter value={2} min={1} max={8} onValueChange={onValueChange} labels={counterLabels} />);
23
+ await user.click(screen.getByRole("button", { name: "More people" }));
24
+ expect(onValueChange).toHaveBeenLastCalledWith(3);
25
+ await user.click(screen.getByRole("button", { name: "Fewer people" }));
26
+ expect(onValueChange).toHaveBeenLastCalledWith(1);
27
+ });
28
+
29
+ it("disables the end it has reached, keeping the value in place", () => {
30
+ render(<PartyCounter value={1} min={1} max={8} onValueChange={() => {}} labels={counterLabels} />);
31
+ expect(screen.getByRole("button", { name: "Fewer people" })).toBeDisabled();
32
+ expect(screen.getByRole("button", { name: "More people" })).toBeEnabled();
33
+ expect(screen.getByRole("status")).toHaveTextContent("1person");
34
+ });
35
+ });
36
+
37
+ describe("TimeSlot", () => {
38
+ it("is a pressed button when selected and reports the pick", async () => {
39
+ const user = userEvent.setup();
40
+ const onSelect = vi.fn();
41
+ render(<TimeSlot time="19:30" meta="Popular" onSelect={onSelect} />);
42
+ const slot = screen.getByRole("button", { name: /19:30/ });
43
+ expect(slot).toHaveAttribute("aria-pressed", "false");
44
+ await user.click(slot);
45
+ expect(onSelect).toHaveBeenCalledOnce();
46
+ });
47
+
48
+ it("cannot be picked when unavailable", async () => {
49
+ const user = userEvent.setup();
50
+ const onSelect = vi.fn();
51
+ render(<TimeSlot time="18:00" unavailable onSelect={onSelect} />);
52
+ await user.click(screen.getByRole("button", { name: "18:00" }));
53
+ expect(onSelect).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it("labels each group so the grid reads as sections", () => {
57
+ render(
58
+ <TimeSlotGroups
59
+ groups={[
60
+ { key: "early", label: "Early", slots: [{ key: "17:00", time: "17:00" }] },
61
+ { key: "late", label: "Late", slots: [{ key: "21:30", time: "21:30", selected: true }] },
62
+ ]}
63
+ />
64
+ );
65
+ expect(screen.getByRole("region", { name: "Early" })).toBeInTheDocument();
66
+ expect(screen.getByRole("button", { name: "21:30" })).toHaveAttribute("aria-pressed", "true");
67
+ });
68
+
69
+ it("announces what is being checked while times load", () => {
70
+ render(<TimeSlotGroupsSkeleton label="Checking availability for 4…" />);
71
+ expect(screen.getByRole("status")).toHaveTextContent("Checking availability for 4…");
72
+ });
73
+ });
74
+
75
+ describe("WeekdayChip", () => {
76
+ it("carries the full date for assistive tech and its pressed state", () => {
77
+ render(<WeekdayChip weekday="Fri" day="14" selected aria-label="Friday 14 August" />);
78
+ expect(screen.getByRole("button", { name: "Friday 14 August" })).toHaveAttribute("aria-pressed", "true");
79
+ });
80
+
81
+ it("cannot be picked when the venue is closed", () => {
82
+ render(<WeekdayChip weekday="Tue" day="11" unavailable />);
83
+ expect(screen.getByRole("button")).toBeDisabled();
84
+ });
85
+ });
86
+
87
+ describe("HoldCountdown", () => {
88
+ afterEach(() => vi.useRealTimers());
89
+
90
+ const labels = {
91
+ held: "Held for you",
92
+ explanation: "then it goes to the next person",
93
+ remaining: (m: number, s: number) => `${m} minutes ${s} seconds left`,
94
+ };
95
+
96
+ it("counts down and calls onExpire once at zero", () => {
97
+ vi.useFakeTimers();
98
+ let now = 0;
99
+ const onExpire = vi.fn();
100
+ render(<HoldCountdown expiresAt={2000} now={() => now} labels={labels} onExpire={onExpire} />);
101
+ expect(screen.getByRole("timer")).toHaveTextContent("00:02");
102
+ act(() => {
103
+ now = 2500;
104
+ vi.advanceTimersByTime(1000);
105
+ });
106
+ expect(screen.getByRole("timer")).toHaveTextContent("00:00");
107
+ act(() => {
108
+ now = 4000;
109
+ vi.advanceTimersByTime(2000);
110
+ });
111
+ expect(onExpire).toHaveBeenCalledOnce();
112
+ });
113
+
114
+ it("never shows a negative time", () => {
115
+ render(<HoldCountdown expiresAt={0} now={() => 60_000} labels={labels} />);
116
+ expect(screen.getByRole("timer")).toHaveTextContent("00:00");
117
+ });
118
+ });
119
+
120
+ describe("BookingSummaryCard", () => {
121
+ it("leaves out rows it has nothing for", () => {
122
+ render(
123
+ <BookingSummaryCard
124
+ venueName="Sălcii Bistro"
125
+ date="Fri 14 August"
126
+ timeAndParty="19:30 · 2 people"
127
+ code="BM-8421"
128
+ labels={{ code: "Booking code" }}
129
+ />
130
+ );
131
+ expect(screen.getByText("BM-8421")).toBeInTheDocument();
132
+ expect(screen.queryByText(/requested/)).not.toBeInTheDocument();
133
+ });
134
+ });
135
+
136
+ describe("BookingRow", () => {
137
+ it("is one link to the booking, with its status and code", () => {
138
+ render(
139
+ <BookingRowList aria-label="Upcoming bookings">
140
+ <BookingRow
141
+ venueName="Kane"
142
+ venueType="Bistro"
143
+ when="Sun 30 Aug · 18:30"
144
+ party="3 people"
145
+ status="pending"
146
+ statusLabel="Pending"
147
+ code="BM-6403"
148
+ href="/bookings/BM-6403"
149
+ />
150
+ </BookingRowList>
151
+ );
152
+ const list = screen.getByRole("list", { name: "Upcoming bookings" });
153
+ const link = screen.getByRole("link");
154
+ expect(list).toContainElement(link);
155
+ expect(link).toHaveAttribute("href", "/bookings/BM-6403");
156
+ expect(screen.getAllByText("Pending").length).toBeGreaterThan(0);
157
+ });
158
+ });
159
+
160
+ describe("Calendar booking appearance", () => {
161
+ it("marks a closed day and does not let it be picked", async () => {
162
+ const user = userEvent.setup();
163
+ const onSelect = vi.fn();
164
+ const closed = new Date(2026, 7, 25);
165
+ render(
166
+ <Calendar
167
+ appearance="booking"
168
+ mode="single"
169
+ defaultMonth={new Date(2026, 7, 1)}
170
+ onSelect={onSelect}
171
+ disabled={[closed]}
172
+ modifiers={{ closed: [closed] }}
173
+ />
174
+ );
175
+ const day = screen.getByRole("button", { name: /August 25/ });
176
+ expect(day).toHaveAttribute("data-closed", "true");
177
+ await user.click(day);
178
+ expect(onSelect).not.toHaveBeenCalled();
179
+ });
180
+ });
@@ -0,0 +1,66 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { BookingRow, BookingRowList } from "./booking-row";
3
+
4
+ const meta: Meta<typeof BookingRow> = {
5
+ title: "Booking/BookingRow",
6
+ component: BookingRow,
7
+ tags: ["autodocs"],
8
+ parameters: {
9
+ docs: {
10
+ description: {
11
+ component:
12
+ "A booking in My bookings. Three stacked lines on mobile; one line with the status, the code and a chevron on desktop, where the next booking up is tinted. The whole row is one link.",
13
+ },
14
+ },
15
+ },
16
+ };
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof BookingRow>;
20
+
21
+ const rows = (
22
+ <BookingRowList aria-label="Upcoming bookings">
23
+ <BookingRow
24
+ venueName="Sălcii Bistro"
25
+ venueType="Bistro"
26
+ when="Fri 14 Aug · 19:30"
27
+ party="2 people"
28
+ status="confirmed"
29
+ statusLabel="Confirmed"
30
+ code="BM-8421"
31
+ href="#bm-8421"
32
+ highlighted
33
+ />
34
+ <BookingRow
35
+ venueName="Vatra"
36
+ venueType="Cocktail bar"
37
+ when="Thu 20 Aug · 20:00"
38
+ party="4 people"
39
+ status="confirmed"
40
+ statusLabel="Confirmed"
41
+ code="BM-7712"
42
+ href="#bm-7712"
43
+ />
44
+ <BookingRow
45
+ venueName="Kane"
46
+ venueType="Bistro"
47
+ when="Sun 30 Aug · 18:30"
48
+ party="3 people"
49
+ status="pending"
50
+ statusLabel="Pending"
51
+ code="BM-6403"
52
+ href="#bm-6403"
53
+ />
54
+ </BookingRowList>
55
+ );
56
+
57
+ /** B6 mobile. */
58
+ export const Mobile: Story = {
59
+ render: () => <div className="w-[343px]">{rows}</div>,
60
+ parameters: { viewport: { defaultViewport: "mobile1" } },
61
+ };
62
+
63
+ /** B6 desktop. */
64
+ export const Desktop: Story = {
65
+ render: () => <div className="w-[467px]">{rows}</div>,
66
+ };
@@ -0,0 +1,168 @@
1
+ import * as React from "react";
2
+ import { ChevronRight } from "lucide-react";
3
+ import { cn } from "../lib/utils";
4
+ import { Badge } from "./badge";
5
+
6
+ /**
7
+ * BookingRow and BookingRowList — the guest's bookings in "My bookings".
8
+ *
9
+ * Measured from the booking design (`.bkrow`, and the desktop list):
10
+ *
11
+ * Mobile (below `md`): rows 14px tall-padded, 12px gap, divided by a
12
+ * `line-soft` hairline. A 50px thumb with an 11px radius. Line one: the venue
13
+ * at 14px bold, the code chip pushed right. Line two: the venue type at 11.5px
14
+ * muted. Line three, 7px gaps: when at 12.5px medium, the party at 12px muted,
15
+ * then the status pill.
16
+ *
17
+ * Desktop (`md` and up): one card (14px radius, no inset), rows 14px × 18px
18
+ * with 14px gaps, divided by the same hairline. A 46px thumb, then the venue
19
+ * (bold) with its type inline and muted over "when · party" in the secondary
20
+ * ink, then the status pill, the code chip and a muted chevron. The row that
21
+ * is next up gets a 5% accent wash.
22
+ *
23
+ * The whole row is one link. Strings arrive formatted and localised; the thumb
24
+ * arrives rendered, so the app picks its own image component.
25
+ */
26
+
27
+ export type BookingRowStatus = "confirmed" | "pending";
28
+
29
+ export type BookingRowLinkProps = {
30
+ href: string;
31
+ className?: string;
32
+ children: React.ReactNode;
33
+ };
34
+
35
+ function DefaultLink({ href, ...props }: BookingRowLinkProps) {
36
+ return <a href={href} {...props} />;
37
+ }
38
+
39
+ export type BookingRowProps = {
40
+ venueName: string;
41
+ /** "Bistro", "Cocktail bar" */
42
+ venueType?: string;
43
+ /** "Fri 14 Aug · 19:30" */
44
+ when: string;
45
+ /** "2 people" */
46
+ party: string;
47
+ status: BookingRowStatus;
48
+ /** "Confirmed", "Pending" — already localised. */
49
+ statusLabel: string;
50
+ code: string;
51
+ href: string;
52
+ /** The venue photo, rendered by the app; a quiet placeholder when absent. */
53
+ thumbnail?: React.ReactNode;
54
+ /** The next booking up — tinted on desktop. */
55
+ highlighted?: boolean;
56
+ linkComponent?: React.ComponentType<BookingRowLinkProps>;
57
+ className?: string;
58
+ };
59
+
60
+ function Thumb({ children, className }: { children?: React.ReactNode; className?: string }) {
61
+ return (
62
+ <span
63
+ aria-hidden
64
+ className={cn(
65
+ // Dark: `surface-2` sits a notch from the card and vanishes; the
66
+ // accent-tinted step reads as a photo slot.
67
+ "block shrink-0 overflow-hidden rounded-[11px] bg-surface-2 dark:bg-accent-subtle [&_img]:size-full [&_img]:object-cover",
68
+ className
69
+ )}
70
+ >
71
+ {children}
72
+ </span>
73
+ );
74
+ }
75
+
76
+ export function BookingRow({
77
+ venueName,
78
+ venueType,
79
+ when,
80
+ party,
81
+ status,
82
+ statusLabel,
83
+ code,
84
+ href,
85
+ thumbnail,
86
+ highlighted,
87
+ linkComponent: LinkComponent = DefaultLink,
88
+ className,
89
+ }: BookingRowProps) {
90
+ const statusPill = (
91
+ <Badge as="span" variant={status}>
92
+ {statusLabel}
93
+ </Badge>
94
+ );
95
+ const codeChip = (
96
+ <Badge as="span" variant="code">
97
+ {code}
98
+ </Badge>
99
+ );
100
+
101
+ return (
102
+ <LinkComponent
103
+ href={href}
104
+ className={cn(
105
+ "flex gap-3 py-3.5 leading-[1.2] text-foreground",
106
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
107
+ "md:items-center md:gap-3.5 md:px-[18px] md:hover:bg-accent/[0.03]",
108
+ highlighted && "md:bg-accent/5 md:hover:bg-accent/5",
109
+ className
110
+ )}
111
+ >
112
+ <Thumb className="size-[50px] md:size-[46px]">{thumbnail}</Thumb>
113
+
114
+ {/* Mobile: three stacked lines. */}
115
+ <div className="min-w-0 flex-1 md:hidden">
116
+ <div className="flex items-center justify-between gap-2">
117
+ <b className="truncate text-sm font-bold">{venueName}</b>
118
+ {codeChip}
119
+ </div>
120
+ {venueType && <div className="text-[11.5px] text-muted-foreground">{venueType}</div>}
121
+ <div className="mt-2 flex flex-wrap items-center gap-[7px]">
122
+ <span className="text-[12.5px] font-medium">{when}</span>
123
+ <span className="text-xs text-muted-foreground">{party}</span>
124
+ {statusPill}
125
+ </div>
126
+ </div>
127
+
128
+ {/* Desktop: one line. */}
129
+ <div className="hidden min-w-0 flex-1 md:block">
130
+ <div className="truncate">
131
+ <b className="text-sm font-bold">{venueName}</b>
132
+ {venueType && <span className="ml-1.5 text-xs text-muted-foreground">{venueType}</span>}
133
+ </div>
134
+ <div className="mt-0.5 truncate text-[12.5px] text-ink-secondary">
135
+ {when} · {party}
136
+ </div>
137
+ </div>
138
+ <span className="hidden md:contents">
139
+ {statusPill}
140
+ {codeChip}
141
+ <ChevronRight aria-hidden className="size-[15px] shrink-0 text-muted-foreground" />
142
+ </span>
143
+ </LinkComponent>
144
+ );
145
+ }
146
+
147
+ export type BookingRowListProps = {
148
+ children: React.ReactNode;
149
+ /** "Upcoming bookings" — names the list for assistive tech. */
150
+ "aria-label"?: string;
151
+ className?: string;
152
+ };
153
+
154
+ /** The list around the rows: hairlines between them on mobile, one card on desktop. */
155
+ export function BookingRowList({ children, "aria-label": ariaLabel, className }: BookingRowListProps) {
156
+ return (
157
+ <ul
158
+ aria-label={ariaLabel}
159
+ className={cn(
160
+ "divide-y divide-line-soft",
161
+ "md:overflow-hidden md:rounded-[14px] md:border md:border-border md:bg-card",
162
+ className
163
+ )}
164
+ >
165
+ {React.Children.map(children, (child) => (child ? <li>{child}</li> : null))}
166
+ </ul>
167
+ );
168
+ }
@@ -0,0 +1,41 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { BookingSummaryCard } from "./booking-summary-card";
3
+
4
+ const meta: Meta<typeof BookingSummaryCard> = {
5
+ title: "Booking/BookingSummaryCard",
6
+ component: BookingSummaryCard,
7
+ tags: ["autodocs"],
8
+ args: {
9
+ venueName: "Sălcii Bistro",
10
+ date: "Fri 14 August",
11
+ timeAndParty: "19:30 · 2 people",
12
+ seating: "Terrace (requested)",
13
+ address: "Strada Iuliu Maniu 12",
14
+ code: "BM-8421",
15
+ labels: { code: "Booking code" },
16
+ },
17
+ decorators: [
18
+ (Story) => (
19
+ <div className="w-[343px]">
20
+ <Story />
21
+ </div>
22
+ ),
23
+ ],
24
+ parameters: {
25
+ docs: {
26
+ description: {
27
+ component:
28
+ "The booking at a glance on the confirmation screen: venue, when, party, seating asked for, address, and the code to give at the door.",
29
+ },
30
+ },
31
+ },
32
+ };
33
+
34
+ export default meta;
35
+ type Story = StoryObj<typeof BookingSummaryCard>;
36
+
37
+ /** B3. */
38
+ export const Confirmed: Story = {};
39
+
40
+ /** No seating preference: the row is left out, not shown empty. */
41
+ export const NoPreference: Story = { args: { seating: undefined } };