@bearmenu/ui 0.8.23 → 0.8.25

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 (83) 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-5XREWOBB.js → chunk-IEIJP73R.js} +14 -1
  4. package/dist/{chunk-BKKQ7GCZ.js → chunk-QG6I2ILT.js} +1 -1
  5. package/dist/{chunk-TIQNM4XD.js → chunk-VEGFQSZA.js} +17 -1
  6. package/dist/components/alert-dialog.js +1 -1
  7. package/dist/components/alert.d.ts +2 -2
  8. package/dist/components/badge.d.ts +1 -1
  9. package/dist/components/badge.js +1 -1
  10. package/dist/components/booking-row.d.ts +60 -0
  11. package/dist/components/booking-row.js +103 -0
  12. package/dist/components/booking-summary-card.d.ts +38 -0
  13. package/dist/components/booking-summary-card.js +67 -0
  14. package/dist/components/button.d.ts +2 -2
  15. package/dist/components/button.js +1 -1
  16. package/dist/components/calendar.d.ts +17 -3
  17. package/dist/components/calendar.js +100 -12
  18. package/dist/components/carousel.js +1 -1
  19. package/dist/components/coach-marks.js +1 -1
  20. package/dist/components/combobox.js +1 -1
  21. package/dist/components/command.d.ts +7 -7
  22. package/dist/components/concept-convergence.js +2 -2
  23. package/dist/components/empty-state.js +1 -1
  24. package/dist/components/event-card.js +1 -1
  25. package/dist/components/event-compact-row.js +1 -1
  26. package/dist/components/event-detail-panel.js +1 -1
  27. package/dist/components/event-list-row-wide.js +2 -2
  28. package/dist/components/event-list-row.js +1 -1
  29. package/dist/components/filter-category-row.js +1 -1
  30. package/dist/components/hold-countdown.d.ts +43 -0
  31. package/dist/components/hold-countdown.js +90 -0
  32. package/dist/components/kitchen-dossier.js +1 -1
  33. package/dist/components/link-button.js +1 -1
  34. package/dist/components/menu-family-block.js +1 -1
  35. package/dist/components/menu-index-list.js +1 -1
  36. package/dist/components/menu-item.js +3 -3
  37. package/dist/components/motion-icon-set.d.ts +2 -1
  38. package/dist/components/motion-icon-set.js +27 -1
  39. package/dist/components/multi-select-combobox.js +2 -2
  40. package/dist/components/pagination.js +1 -1
  41. package/dist/components/party-counter.d.ts +36 -0
  42. package/dist/components/party-counter.js +61 -0
  43. package/dist/components/place-card.js +2 -2
  44. package/dist/components/place-details-mobile.js +1 -1
  45. package/dist/components/searchable-select.js +1 -1
  46. package/dist/components/signature-chip.js +2 -2
  47. package/dist/components/sliding-panels.js +1 -1
  48. package/dist/components/spinner.d.ts +2 -2
  49. package/dist/components/tag.d.ts +2 -2
  50. package/dist/components/text.d.ts +3 -3
  51. package/dist/components/time-slot.d.ts +73 -0
  52. package/dist/components/time-slot.js +106 -0
  53. package/dist/components/toggle-group.js +15 -2
  54. package/dist/components/toggle.d.ts +1 -1
  55. package/dist/components/toggle.js +1 -1
  56. package/dist/components/top-ranking-entry.js +3 -3
  57. package/dist/components/weekday-chip.d.ts +33 -0
  58. package/dist/components/weekday-chip.js +48 -0
  59. package/package.json +1 -1
  60. package/src/components/badge.stories.tsx +17 -0
  61. package/src/components/badge.tsx +15 -0
  62. package/src/components/booking-primitives.test.tsx +180 -0
  63. package/src/components/booking-row.stories.tsx +66 -0
  64. package/src/components/booking-row.tsx +168 -0
  65. package/src/components/booking-summary-card.stories.tsx +41 -0
  66. package/src/components/booking-summary-card.tsx +109 -0
  67. package/src/components/button.stories.tsx +23 -0
  68. package/src/components/button.tsx +13 -0
  69. package/src/components/calendar.stories.tsx +27 -0
  70. package/src/components/calendar.tsx +110 -3
  71. package/src/components/hold-countdown.stories.tsx +68 -0
  72. package/src/components/hold-countdown.tsx +140 -0
  73. package/src/components/motion-icon-set.stories.tsx +2 -0
  74. package/src/components/motion-icon-set.tsx +42 -0
  75. package/src/components/party-counter.stories.tsx +57 -0
  76. package/src/components/party-counter.tsx +93 -0
  77. package/src/components/time-slot.stories.tsx +85 -0
  78. package/src/components/time-slot.tsx +186 -0
  79. package/src/components/toggle-group.stories.tsx +27 -0
  80. package/src/components/toggle-group.tsx +14 -1
  81. package/src/components/toggle.tsx +16 -0
  82. package/src/components/weekday-chip.stories.tsx +54 -0
  83. package/src/components/weekday-chip.tsx +75 -0
@@ -0,0 +1,73 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import * as React from 'react';
3
+
4
+ /**
5
+ * TimeSlot and TimeSlotGroups — the bookable times in the booking flow's
6
+ * "When are you coming?" screen, the reserve panel on the venue page, and
7
+ * "Change party size".
8
+ *
9
+ * Measured from the booking design (`.tsl`, `.tgrid`, `.grouplbl`):
10
+ * - A slot is a card with a 1px hairline, 11px radius, 8px × 4px inset,
11
+ * centred. The time in mono at 14.5px bold, line-height 1. An optional
12
+ * meta line under it at 9.5px, 3px apart — "Popular", "2 left" in accent,
13
+ * "Waitlist" muted.
14
+ * - Selected fills with `--gradient-primary`, white type, the meta at 80%.
15
+ * - Unavailable fades to 32% with the time struck through.
16
+ * - Groups are "Early / Evening / Late": a mono 10px small-caps label
17
+ * (0.08em tracking, 14px above, 7px below) over a three-column grid with
18
+ * 7px gaps.
19
+ *
20
+ * A slot is a button with `aria-pressed`: one of many times, picked once.
21
+ */
22
+ type TimeSlotTone = "default" | "waitlist";
23
+ type TimeSlotProps = {
24
+ /** The time as displayed, already formatted in the venue's timezone — "19:30". */
25
+ time: string;
26
+ /** "Popular", "2 left", "Waitlist" — already localised. */
27
+ meta?: string;
28
+ /** `waitlist` renders the meta muted: the slot books onto the list, not a table. */
29
+ tone?: TimeSlotTone;
30
+ selected?: boolean;
31
+ /** Taken or past: faded and struck through, not clickable. */
32
+ unavailable?: boolean;
33
+ onSelect?: () => void;
34
+ /** Full accessible name, when the visible text is not enough ("19:30, popular"). */
35
+ "aria-label"?: string;
36
+ className?: string;
37
+ };
38
+ declare function TimeSlot({ time, meta, tone, selected, unavailable, onSelect, "aria-label": ariaLabel, className, }: TimeSlotProps): react_jsx_runtime.JSX.Element;
39
+ type TimeSlotGroup = {
40
+ /** Stable key — "early", "evening", "late". */
41
+ key: string;
42
+ /** "Early", "Evening", "Late" — already localised; rendered small-caps. */
43
+ label: string;
44
+ slots: (TimeSlotProps & {
45
+ key: string;
46
+ })[];
47
+ };
48
+ type TimeSlotGroupsProps = {
49
+ groups: TimeSlotGroup[];
50
+ /** Columns in each group's grid. The design uses 3 everywhere; 2 only in very narrow panels. */
51
+ columns?: 2 | 3;
52
+ className?: string;
53
+ };
54
+ declare function TimeSlotGroupLabel({ children, className, }: {
55
+ children: React.ReactNode;
56
+ className?: string;
57
+ }): react_jsx_runtime.JSX.Element;
58
+ declare function TimeSlotGroups({ groups, columns, className }: TimeSlotGroupsProps): react_jsx_runtime.JSX.Element;
59
+ type TimeSlotGroupsSkeletonProps = {
60
+ /** The label above the grid while times load — "Checking availability for 4…". */
61
+ label: string;
62
+ rows?: number;
63
+ columns?: 2 | 3;
64
+ className?: string;
65
+ };
66
+ /**
67
+ * The grid while times load (S1). Same cells, same gaps, so nothing jumps when
68
+ * the real times land: a slot's footprint with a dashed hairline at 60%, a
69
+ * soft bar in place of the time. The label says what is being checked.
70
+ */
71
+ declare function TimeSlotGroupsSkeleton({ label, rows, columns, className, }: TimeSlotGroupsSkeletonProps): react_jsx_runtime.JSX.Element;
72
+
73
+ export { TimeSlot, type TimeSlotGroup, TimeSlotGroupLabel, TimeSlotGroups, type TimeSlotGroupsProps, TimeSlotGroupsSkeleton, type TimeSlotGroupsSkeletonProps, type TimeSlotProps, type TimeSlotTone };
@@ -0,0 +1,106 @@
1
+ import { Skeleton } from '../chunk-XBBEX4SF.js';
2
+ import { cn } from '../chunk-FEK5XGZW.js';
3
+ import { __objRest, __spreadValues } from '../chunk-MMUBH76A.js';
4
+ import { jsxs, jsx } from 'react/jsx-runtime';
5
+
6
+ function TimeSlot({
7
+ time,
8
+ meta,
9
+ tone = "default",
10
+ selected,
11
+ unavailable,
12
+ onSelect,
13
+ "aria-label": ariaLabel,
14
+ className
15
+ }) {
16
+ return /* @__PURE__ */ jsxs(
17
+ "button",
18
+ {
19
+ type: "button",
20
+ "data-slot": "time-slot",
21
+ "aria-pressed": selected ? true : false,
22
+ "aria-label": ariaLabel,
23
+ disabled: unavailable,
24
+ onClick: onSelect,
25
+ className: cn(
26
+ // Content sits at the top: a slot without a meta line in a row with
27
+ // one is stretched by the grid, and the time must not drop to the middle.
28
+ "flex w-full cursor-pointer flex-col items-center justify-start rounded-[11px] border px-1 py-2 text-center leading-[1.2]",
29
+ "transition-[border-color,filter] duration-150 motion-reduce:transition-none",
30
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
31
+ selected ? "border-transparent bg-[image:var(--gradient-primary)] text-white hover:brightness-[0.94]" : "border-border bg-card text-foreground hover:border-accent/50",
32
+ unavailable && "cursor-not-allowed opacity-[0.32] hover:border-border"
33
+ ),
34
+ children: [
35
+ /* @__PURE__ */ jsx(
36
+ "b",
37
+ {
38
+ className: cn(
39
+ "block font-mono text-[14.5px] leading-none font-bold tabular-nums",
40
+ unavailable && "line-through"
41
+ ),
42
+ children: time
43
+ }
44
+ ),
45
+ meta && /* @__PURE__ */ jsx(
46
+ "span",
47
+ {
48
+ className: cn(
49
+ "mt-[3px] block text-[9.5px] leading-[1.2]",
50
+ selected ? "text-white/80" : tone === "waitlist" ? "text-muted-foreground" : "text-accent"
51
+ ),
52
+ children: meta
53
+ }
54
+ )
55
+ ]
56
+ }
57
+ );
58
+ }
59
+ var GRID_COLUMNS = { 2: "grid-cols-2", 3: "grid-cols-3" };
60
+ function TimeSlotGroupLabel({
61
+ children,
62
+ className
63
+ }) {
64
+ return /* @__PURE__ */ jsx(
65
+ "div",
66
+ {
67
+ className: cn(
68
+ "mb-[7px] font-mono text-[10px] leading-none font-semibold tracking-[0.08em] text-muted-foreground uppercase",
69
+ className
70
+ ),
71
+ children
72
+ }
73
+ );
74
+ }
75
+ function TimeSlotGroups({ groups, columns = 3, className }) {
76
+ return /* @__PURE__ */ jsx("div", { "data-slot": "time-slot-groups", className, children: groups.map((group) => (
77
+ // 14px between groups; the first sits flush with whatever is above.
78
+ /* @__PURE__ */ jsxs("section", { "aria-label": group.label, className: "[&+&]:mt-3.5", children: [
79
+ /* @__PURE__ */ jsx(TimeSlotGroupLabel, { children: group.label }),
80
+ /* @__PURE__ */ jsx("div", { className: cn("grid gap-[7px]", GRID_COLUMNS[columns]), children: group.slots.map((_a) => {
81
+ var _b = _a, { key } = _b, slot = __objRest(_b, ["key"]);
82
+ return /* @__PURE__ */ jsx(TimeSlot, __spreadValues({}, slot), key);
83
+ }) })
84
+ ] }, group.key)
85
+ )) });
86
+ }
87
+ function TimeSlotGroupsSkeleton({
88
+ label,
89
+ rows = 3,
90
+ columns = 3,
91
+ className
92
+ }) {
93
+ return /* @__PURE__ */ jsxs("div", { "data-slot": "time-slot-groups-skeleton", role: "status", "aria-live": "polite", className, children: [
94
+ /* @__PURE__ */ jsx(TimeSlotGroupLabel, { children: label }),
95
+ /* @__PURE__ */ jsx("div", { className: cn("grid gap-[7px]", GRID_COLUMNS[columns]), "aria-hidden": true, children: Array.from({ length: rows * columns }, (_, index) => /* @__PURE__ */ jsx(
96
+ "span",
97
+ {
98
+ className: "block rounded-[11px] border border-dashed border-border bg-card px-1 py-2 opacity-60 dark:opacity-100",
99
+ children: /* @__PURE__ */ jsx(Skeleton, { className: "mx-auto h-[17px] w-[90%] rounded-md" })
100
+ },
101
+ index
102
+ )) })
103
+ ] });
104
+ }
105
+
106
+ export { TimeSlot, TimeSlotGroupLabel, TimeSlotGroups, TimeSlotGroupsSkeleton };
@@ -1,4 +1,4 @@
1
- import { toggleVariants } from '../chunk-TIQNM4XD.js';
1
+ import { toggleVariants } from '../chunk-VEGFQSZA.js';
2
2
  import { cn } from '../chunk-FEK5XGZW.js';
3
3
  import { __objRest, __spreadProps, __spreadValues } from '../chunk-MMUBH76A.js';
4
4
  import * as React from 'react';
@@ -32,6 +32,12 @@ function ToggleGroup(_a) {
32
32
  // The segmented control's capsule: muted ground, the on cell a raised
33
33
  // card inside it (see `toggleVariants.segmented`). 44px tall.
34
34
  "data-[variant=segmented]:gap-0.5 data-[variant=segmented]:rounded-full data-[variant=segmented]:bg-muted data-[variant=segmented]:p-0.5",
35
+ // Chips wrap onto as many rows as they need, 7px apart.
36
+ "data-[variant=chip]:flex-wrap data-[variant=chip]:gap-[7px] data-[variant=chip]:rounded-none",
37
+ // The tab bar spans its container: a muted capsule, 4px inset.
38
+ // Dark swaps the steps: the track takes the card, the ON tab the
39
+ // lighter `surface-2`, so the tab still reads as raised.
40
+ "data-[variant=tab]:w-full data-[variant=tab]:gap-1 data-[variant=tab]:rounded-full data-[variant=tab]:bg-surface-2 data-[variant=tab]:p-1 dark:data-[variant=tab]:bg-card",
35
41
  className
36
42
  )
37
43
  }, props), {
@@ -65,7 +71,14 @@ function ToggleGroupItem(_a) {
65
71
  size: context.size || size
66
72
  }),
67
73
  "min-w-0 flex-1 shrink-0 shadow-none focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
68
- resolved === "segmented" ? "rounded-full px-0 data-[state=on]:shadow-[var(--shadow-card)]" : "rounded-none first:rounded-l-md last:rounded-r-md",
74
+ resolved === "segmented" ? "rounded-full px-0 data-[state=on]:shadow-[var(--shadow-card)]" : (
75
+ // Tabs and chips size to their type: the size variant's fixed
76
+ // height and min-width would otherwise win the merge.
77
+ resolved === "tab" ? "h-auto min-w-0 rounded-full px-3" : resolved === "chip" ? (
78
+ // A chip keeps its own width, so the row can wrap.
79
+ "h-auto min-w-0 flex-none rounded-full px-[13px]"
80
+ ) : "rounded-none first:rounded-l-md last:rounded-r-md"
81
+ ),
69
82
  className
70
83
  )
71
84
  }, props), {
@@ -5,7 +5,7 @@ import * as TogglePrimitive from '@radix-ui/react-toggle';
5
5
  import { VariantProps } from 'class-variance-authority';
6
6
 
7
7
  declare const toggleVariants: (props?: ({
8
- variant?: "default" | "outline" | "segmented" | null | undefined;
8
+ variant?: "tab" | "default" | "outline" | "chip" | "segmented" | null | undefined;
9
9
  size?: "default" | "sm" | "lg" | null | undefined;
10
10
  } & class_variance_authority_types.ClassProp) | undefined) => string;
11
11
  declare function Toggle({ className, variant, size, ...props }: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>): react_jsx_runtime.JSX.Element;
@@ -1,3 +1,3 @@
1
- export { Toggle, toggleVariants } from '../chunk-TIQNM4XD.js';
1
+ export { Toggle, toggleVariants } from '../chunk-VEGFQSZA.js';
2
2
  import '../chunk-FEK5XGZW.js';
3
3
  import '../chunk-MMUBH76A.js';
@@ -1,8 +1,8 @@
1
- import { MenuItem } from '../chunk-SDWN6L4O.js';
2
- import '../chunk-BKKQ7GCZ.js';
1
+ import { MenuItem } from '../chunk-AHHDC7ML.js';
2
+ import '../chunk-QG6I2ILT.js';
3
3
  import { Separator } from '../chunk-NWZGSLPU.js';
4
4
  import { Skeleton } from '../chunk-XBBEX4SF.js';
5
- import '../chunk-5XREWOBB.js';
5
+ import '../chunk-IEIJP73R.js';
6
6
  import { cn } from '../chunk-FEK5XGZW.js';
7
7
  import { __objRest, __spreadValues } from '../chunk-MMUBH76A.js';
8
8
  import { MapPin, Star, MessageSquareQuote } from 'lucide-react';
@@ -0,0 +1,33 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ /**
4
+ * WeekdayChip — one day in the booking flow's week rail on mobile
5
+ * ("MON 10 · TUE 11 · … · SUN 16" in "When are you coming?").
6
+ *
7
+ * Not `DayRailChip`: that one is the events timeline's day, with a single-line
8
+ * label and an event count. This one is a date picker's day — weekday stacked
9
+ * over the date, no count — and the rail shares the width equally.
10
+ *
11
+ * Measured from the booking design (`.dchip`): flexes to an equal share, 1px
12
+ * hairline, 11px radius, 7px vertical inset, centred. The weekday at 10px
13
+ * uppercase, 0.05em tracking, muted; the date at 15px bold, tabular, 2px
14
+ * below. Selected fills with `--gradient-primary`, white type, the weekday at
15
+ * 75%. A day that cannot be booked (closed, past the window) fades to 40% and
16
+ * is disabled.
17
+ */
18
+ type WeekdayChipProps = {
19
+ /** "Mon", "Tue" — already localised; rendered uppercase. */
20
+ weekday: string;
21
+ /** Day of month as displayed — "14". */
22
+ day: string;
23
+ selected?: boolean;
24
+ /** Closed that day or outside the booking window. */
25
+ unavailable?: boolean;
26
+ onSelect?: () => void;
27
+ /** The full date for assistive tech — "Friday 14 August". */
28
+ "aria-label"?: string;
29
+ className?: string;
30
+ };
31
+ declare function WeekdayChip({ weekday, day, selected, unavailable, onSelect, "aria-label": ariaLabel, className, }: WeekdayChipProps): react_jsx_runtime.JSX.Element;
32
+
33
+ export { WeekdayChip, type WeekdayChipProps };
@@ -0,0 +1,48 @@
1
+ import { cn } from '../chunk-FEK5XGZW.js';
2
+ import '../chunk-MMUBH76A.js';
3
+ import { jsxs, jsx } from 'react/jsx-runtime';
4
+
5
+ function WeekdayChip({
6
+ weekday,
7
+ day,
8
+ selected,
9
+ unavailable,
10
+ onSelect,
11
+ "aria-label": ariaLabel,
12
+ className
13
+ }) {
14
+ return /* @__PURE__ */ jsxs(
15
+ "button",
16
+ {
17
+ type: "button",
18
+ "data-slot": "weekday-chip",
19
+ "aria-pressed": selected ? true : false,
20
+ "aria-label": ariaLabel,
21
+ disabled: unavailable,
22
+ onClick: onSelect,
23
+ className: cn(
24
+ "min-w-0 flex-1 cursor-pointer rounded-[11px] border py-[7px] text-center leading-[1.2]",
25
+ "transition-[border-color,filter] duration-150 motion-reduce:transition-none",
26
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
27
+ selected ? "border-transparent bg-[image:var(--gradient-primary)] text-white" : "border-border bg-card text-foreground hover:border-accent/50",
28
+ unavailable && "cursor-not-allowed opacity-40 hover:border-border",
29
+ className
30
+ ),
31
+ children: [
32
+ /* @__PURE__ */ jsx(
33
+ "span",
34
+ {
35
+ className: cn(
36
+ "block text-[10px] tracking-[0.05em] uppercase",
37
+ selected ? "text-white/75" : "text-muted-foreground"
38
+ ),
39
+ children: weekday
40
+ }
41
+ ),
42
+ /* @__PURE__ */ jsx("span", { className: "mt-0.5 block text-[15px] font-bold tabular-nums", children: day })
43
+ ]
44
+ }
45
+ );
46
+ }
47
+
48
+ export { WeekdayChip };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.23",
3
+ "version": "0.8.25",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -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,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
+ };