@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
@@ -5,12 +5,21 @@ import { Slot } from "@radix-ui/react-slot";
5
5
  import { cn } from "../lib/utils";
6
6
  import { Label } from "./label";
7
7
 
8
+ /**
9
+ * `booking` — the booking flow's fields (booking flow.html, `.fld`): the
10
+ * label a 10.5px uppercase caption in the muted ink that stays muted when
11
+ * the field is invalid (the hairline and the line under it carry the error),
12
+ * the error at 11.5px, 5px between label, control and error.
13
+ */
14
+ type FieldAppearance = "default" | "booking";
15
+
8
16
  const FieldContext = React.createContext<{
9
17
  id: string;
10
18
  descriptionId: string;
11
19
  errorId: string;
12
20
  hasError: boolean;
13
- }>({ id: "", descriptionId: "", errorId: "", hasError: false });
21
+ appearance: FieldAppearance;
22
+ }>({ id: "", descriptionId: "", errorId: "", hasError: false, appearance: "default" });
14
23
 
15
24
  type FieldProps = React.HTMLAttributes<HTMLDivElement> & {
16
25
  /** Field id. Auto-generated if omitted. */
@@ -19,23 +28,38 @@ type FieldProps = React.HTMLAttributes<HTMLDivElement> & {
19
28
  invalid?: boolean;
20
29
  /** Lay out content horizontally instead of stacked. */
21
30
  orientation?: "vertical" | "horizontal";
31
+ appearance?: FieldAppearance;
22
32
  };
23
33
 
24
34
  const Field = React.forwardRef<HTMLDivElement, FieldProps>(
25
- ({ id: providedId, invalid, orientation = "vertical", className, children, ...props }, ref) => {
35
+ (
36
+ {
37
+ id: providedId,
38
+ invalid,
39
+ orientation = "vertical",
40
+ appearance = "default",
41
+ className,
42
+ children,
43
+ ...props
44
+ },
45
+ ref
46
+ ) => {
26
47
  const generatedId = React.useId();
27
48
  const id = providedId ?? generatedId;
28
49
  const descriptionId = `${id}-description`;
29
50
  const errorId = `${id}-error`;
30
51
 
31
52
  return (
32
- <FieldContext.Provider value={{ id, descriptionId, errorId, hasError: !!invalid }}>
53
+ <FieldContext.Provider
54
+ value={{ id, descriptionId, errorId, hasError: !!invalid, appearance }}
55
+ >
33
56
  <div
34
57
  ref={ref}
35
58
  data-slot="field"
36
59
  data-invalid={invalid || undefined}
37
60
  className={cn(
38
- "flex gap-2",
61
+ "flex",
62
+ appearance === "booking" ? "gap-[5px]" : "gap-2",
39
63
  orientation === "vertical" ? "flex-col" : "flex-row items-center",
40
64
  className
41
65
  )}
@@ -53,19 +77,38 @@ const FieldLabel = React.forwardRef<
53
77
  HTMLLabelElement,
54
78
  React.ComponentPropsWithoutRef<typeof Label>
55
79
  >(({ className, ...props }, ref) => {
56
- const { id, hasError } = React.useContext(FieldContext);
80
+ const { id, hasError, appearance } = React.useContext(FieldContext);
57
81
  return (
58
82
  <Label
59
83
  ref={ref}
60
84
  data-slot="field-label"
61
85
  htmlFor={id}
62
- className={cn(hasError && "text-destructive", className)}
86
+ className={cn(
87
+ appearance === "booking"
88
+ ? "text-[10.5px] leading-[1.2] font-semibold tracking-[0.04em] text-muted-foreground uppercase"
89
+ : hasError && "text-destructive",
90
+ className
91
+ )}
63
92
  {...props}
64
93
  />
65
94
  );
66
95
  });
67
96
  FieldLabel.displayName = "FieldLabel";
68
97
 
98
+ /**
99
+ * "(optional)" after a label's text — sentence case, regular weight, a
100
+ * shade quieter than the label (booking flow.html's EMAIL (optional)).
101
+ */
102
+ function FieldOptional({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
103
+ return (
104
+ <span
105
+ data-slot="field-optional"
106
+ className={cn("font-normal normal-case opacity-70", className)}
107
+ {...props}
108
+ />
109
+ );
110
+ }
111
+
69
112
  const FieldControl = React.forwardRef<HTMLElement, React.ComponentPropsWithoutRef<typeof Slot>>(
70
113
  ({ ...props }, ref) => {
71
114
  const { id, descriptionId, errorId, hasError } = React.useContext(FieldContext);
@@ -101,7 +144,7 @@ FieldDescription.displayName = "FieldDescription";
101
144
 
102
145
  const FieldError = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
103
146
  ({ className, children, ...props }, ref) => {
104
- const { errorId, hasError } = React.useContext(FieldContext);
147
+ const { errorId, hasError, appearance } = React.useContext(FieldContext);
105
148
  if (!hasError || !children) return null;
106
149
  return (
107
150
  <p
@@ -109,7 +152,14 @@ const FieldError = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<H
109
152
  id={errorId}
110
153
  role="alert"
111
154
  data-slot="field-error"
112
- className={cn("text-sm text-destructive", className)}
155
+ className={cn(
156
+ appearance === "booking"
157
+ ? // The design's error ink: the solid token is too bright for AA
158
+ // at 11.5px on white; dark mode's lifted token already clears it.
159
+ "m-0 text-[11.5px] leading-[1.2] text-[oklch(0.52_0.16_25)] dark:text-destructive"
160
+ : "text-sm text-destructive",
161
+ className
162
+ )}
113
163
  {...props}
114
164
  >
115
165
  {children}
@@ -143,4 +193,13 @@ const FieldLegend = React.forwardRef<HTMLLegendElement, React.HTMLAttributes<HTM
143
193
  );
144
194
  FieldLegend.displayName = "FieldLegend";
145
195
 
146
- export { Field, FieldLabel, FieldControl, FieldDescription, FieldError, FieldSet, FieldLegend };
196
+ export {
197
+ Field,
198
+ FieldLabel,
199
+ FieldOptional,
200
+ FieldControl,
201
+ FieldDescription,
202
+ FieldError,
203
+ FieldSet,
204
+ FieldLegend,
205
+ };
@@ -0,0 +1,68 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Button } from "./button";
3
+ import { HoldCountdown, type HoldCountdownLabels } from "./hold-countdown";
4
+
5
+ // A fixed clock so the story always reads 09:41 on first paint.
6
+ const NOW = Date.UTC(2026, 7, 14, 16, 0, 0);
7
+ const expiresAt = NOW + (9 * 60 + 41) * 1000;
8
+
9
+ const stackedLabels: HoldCountdownLabels = {
10
+ held: "Held for you",
11
+ explanation: "then it goes to the next person",
12
+ remaining: (m, s) => `${m} minutes ${s} seconds left`,
13
+ };
14
+
15
+ const inlineLabels: HoldCountdownLabels = {
16
+ ...stackedLabels,
17
+ explanation: "After the countdown the table goes to the next person on the list.",
18
+ };
19
+
20
+ const meta: Meta<typeof HoldCountdown> = {
21
+ title: "Booking/HoldCountdown",
22
+ component: HoldCountdown,
23
+ tags: ["autodocs"],
24
+ args: { expiresAt, now: () => NOW },
25
+ parameters: {
26
+ docs: {
27
+ description: {
28
+ component:
29
+ "How long a table offered from the waitlist stays held (\"A table opened up\"). Stacked on mobile; inline with its action on desktop. Ticks once a second and calls onExpire once, at zero.",
30
+ },
31
+ },
32
+ },
33
+ };
34
+
35
+ export default meta;
36
+ type Story = StoryObj<typeof HoldCountdown>;
37
+
38
+ /** B5a mobile. */
39
+ export const Stacked: Story = {
40
+ args: { labels: stackedLabels, layout: "stacked" },
41
+ decorators: [
42
+ (Story) => (
43
+ <div className="w-[343px]">
44
+ <Story />
45
+ </div>
46
+ ),
47
+ ],
48
+ };
49
+
50
+ /** B5a desktop. */
51
+ export const Inline: Story = {
52
+ args: {
53
+ labels: inlineLabels,
54
+ layout: "inline",
55
+ action: (
56
+ <Button variant="cta" size="cta-sm">
57
+ Confirm the table
58
+ </Button>
59
+ ),
60
+ },
61
+ decorators: [
62
+ (Story) => (
63
+ <div className="w-[520px]">
64
+ <Story />
65
+ </div>
66
+ ),
67
+ ],
68
+ };
@@ -0,0 +1,140 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+
6
+ /**
7
+ * HoldCountdown — "HELD FOR YOU 09:41": how long a table offered from the
8
+ * waitlist stays reserved for this guest (the booking flow's "A table opened
9
+ * up").
10
+ *
11
+ * Measured from the booking design: a card with a warning-colour hairline,
12
+ * 14px radius, 15px inset.
13
+ * - `stacked` (mobile): centred. The label at 10.5px uppercase with 0.06em
14
+ * tracking, muted; the remaining time in mono at 34px bold; a muted
15
+ * 11.5px line under it ("then it goes to the next person").
16
+ * - `inline` (desktop): one row, 18px gaps. The label over the time at
17
+ * 30px, then the explanation at 12.5px muted taking the free width, then
18
+ * the action.
19
+ *
20
+ * Counts down from `expiresAt` once a second and calls `onExpire` once, at
21
+ * zero. It never shows a negative time.
22
+ */
23
+
24
+ export type HoldCountdownLabels = {
25
+ /** "Held for you" */
26
+ held: string;
27
+ /** Stacked: "then it goes to the next person". Inline: "After the countdown the table goes to the next person on the list." */
28
+ explanation: string;
29
+ /** Spoken form for assistive tech — "9 minutes 41 seconds left". */
30
+ remaining: (minutes: number, seconds: number) => string;
31
+ };
32
+
33
+ export type HoldCountdownProps = {
34
+ /** When the hold ends — ISO string or epoch milliseconds. */
35
+ expiresAt: string | number;
36
+ labels: HoldCountdownLabels;
37
+ layout?: "stacked" | "inline";
38
+ /** The inline layout's action, typically a `Button variant="cta" size="cta-sm"`. */
39
+ action?: React.ReactNode;
40
+ onExpire?: () => void;
41
+ /** Clock override for stories and tests. */
42
+ now?: () => number;
43
+ className?: string;
44
+ };
45
+
46
+ /**
47
+ * The design's hold amber (`--warnc`, oklch 0.62 0.15 70), deeper than the
48
+ * `--warning` token (0.72): on white the token reads as a yellow alert, the
49
+ * design's as a timer. Dark mode takes the lifted token, which already has
50
+ * the contrast against the dark card.
51
+ */
52
+ const HOLD_BORDER = "border-[oklch(0.62_0.15_70)] dark:border-warning";
53
+
54
+ function remainingSeconds(expiresAt: string | number, now: number): number {
55
+ const end = typeof expiresAt === "number" ? expiresAt : Date.parse(expiresAt);
56
+ return Math.max(0, Math.ceil((end - now) / 1000));
57
+ }
58
+
59
+ export function HoldCountdown({
60
+ expiresAt,
61
+ labels,
62
+ layout = "stacked",
63
+ action,
64
+ onExpire,
65
+ now = Date.now,
66
+ className,
67
+ }: HoldCountdownProps) {
68
+ const [left, setLeft] = React.useState(() => remainingSeconds(expiresAt, now()));
69
+ const expired = React.useRef(false);
70
+ const onExpireRef = React.useRef(onExpire);
71
+ onExpireRef.current = onExpire;
72
+
73
+ React.useEffect(() => {
74
+ expired.current = false;
75
+ const tick = () => {
76
+ const next = remainingSeconds(expiresAt, now());
77
+ setLeft(next);
78
+ if (next === 0 && !expired.current) {
79
+ expired.current = true;
80
+ onExpireRef.current?.();
81
+ }
82
+ };
83
+ tick();
84
+ const id = window.setInterval(tick, 1000);
85
+ return () => window.clearInterval(id);
86
+ }, [expiresAt, now]);
87
+
88
+ const minutes = Math.floor(left / 60);
89
+ const seconds = left % 60;
90
+ const display = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
91
+
92
+ const label = (
93
+ <div className="text-[10.5px] leading-[13px] tracking-[0.06em] text-muted-foreground uppercase">
94
+ {labels.held}
95
+ </div>
96
+ );
97
+ const time = (
98
+ <div
99
+ role="timer"
100
+ aria-label={labels.remaining(minutes, seconds)}
101
+ className={cn(
102
+ "font-mono font-bold tabular-nums text-foreground",
103
+ layout === "stacked" ? "mt-[5px] text-[34px] leading-[40px]" : "text-[30px] leading-[35px]"
104
+ )}
105
+ >
106
+ {display}
107
+ </div>
108
+ );
109
+
110
+ if (layout === "inline") {
111
+ return (
112
+ <div
113
+ data-slot="hold-countdown"
114
+ className={cn(
115
+ "flex items-center gap-[18px] rounded-[14px] border bg-card p-[15px] text-left",
116
+ HOLD_BORDER,
117
+ className
118
+ )}
119
+ >
120
+ <div className="shrink-0">
121
+ {label}
122
+ {time}
123
+ </div>
124
+ <p className="flex-1 text-[12.5px] leading-[15px] text-muted-foreground">{labels.explanation}</p>
125
+ {action}
126
+ </div>
127
+ );
128
+ }
129
+
130
+ return (
131
+ <div
132
+ data-slot="hold-countdown"
133
+ className={cn("rounded-[14px] border bg-card p-[15px] text-center", HOLD_BORDER, className)}
134
+ >
135
+ {label}
136
+ {time}
137
+ <p className="mt-1 text-[11.5px] leading-[14px] text-muted-foreground">{labels.explanation}</p>
138
+ </div>
139
+ );
140
+ }
@@ -15,10 +15,21 @@ const inputVariants = cva(
15
15
  rounded: "rounded-xl focus-visible:ring-ring",
16
16
  pill: "rounded-full bg-background shadow-[var(--shadow-card)] focus-visible:ring-accent/30",
17
17
  },
18
+ /**
19
+ * The booking flow's field (booking flow.html, `.in`): 46px on the card
20
+ * ground rather than the input wash, an 11px radius, 13px inset, 14px
21
+ * type. Last, so it wins over the size and shape it restates.
22
+ */
23
+ appearance: {
24
+ default: "",
25
+ booking:
26
+ "h-[46px] rounded-[11px] bg-card px-[13px] py-0 text-[14px] leading-[1.2] focus-visible:bg-card focus-visible:ring-ring aria-invalid:border-[oklch(0.52_0.16_25)] dark:aria-invalid:border-destructive",
27
+ },
18
28
  },
19
29
  defaultVariants: {
20
30
  size: "default",
21
31
  shape: "rounded",
32
+ appearance: "default",
22
33
  },
23
34
  }
24
35
  );
@@ -28,12 +39,12 @@ export interface InputProps
28
39
  VariantProps<typeof inputVariants> {}
29
40
 
30
41
  const Input = React.forwardRef<HTMLInputElement, InputProps>(
31
- ({ className, type, size, shape, ...props }, ref) => {
42
+ ({ className, type, size, shape, appearance, ...props }, ref) => {
32
43
  return (
33
44
  <input
34
45
  type={type}
35
46
  data-slot="input"
36
- className={cn(inputVariants({ size, shape }), className)}
47
+ className={cn(inputVariants({ size, shape, appearance }), className)}
37
48
  ref={ref}
38
49
  {...props}
39
50
  />
@@ -4,6 +4,7 @@ import type { Meta, StoryObj } from "@storybook/react";
4
4
  import {
5
5
  MotionHouse,
6
6
  MotionUtensils,
7
+ MotionCompass,
7
8
  MotionMapPin,
8
9
  MotionCalendar,
9
10
  MotionLayoutGrid,
@@ -62,6 +63,7 @@ type Glyph = { name: string; Icon: React.ComponentType<MotionIconProps>; gesture
62
63
  const OBJECTS: Glyph[] = [
63
64
  { name: "House", Icon: MotionHouse, gesture: "roof jumps, walls take the impact" },
64
65
  { name: "Utensils", Icon: MotionUtensils, gesture: "uncrosses into a place setting · holds" },
66
+ { name: "Compass", Icon: MotionCompass, gesture: "needle spins a turn, overshoots, hunts, settles · fills held" },
65
67
  { name: "MapPin", Icon: MotionMapPin, gesture: "jumps, fills on the descent · holds" },
66
68
  { name: "Calendar", Icon: MotionCalendar, gesture: "rolls four pages" },
67
69
  { name: "LayoutGrid", Icon: MotionLayoutGrid, gesture: "tiles shuffle one slot round the board" },
@@ -149,6 +149,48 @@ export function MotionUtensils(props: MotionIconProps) {
149
149
  );
150
150
  }
151
151
 
152
+ // ─── Compass — the needle spins hard, overshoots, wobbles, settles (cycle content)
153
+
154
+ const COMPASS_TIMES = [0, 0.14, 0.58, 0.76, 0.9, 1];
155
+
156
+ /**
157
+ * The dial holds still; the needle is the content that moves through it.
158
+ * It pulls back against the spin first (anticipation), goes round once and
159
+ * 20° past its bearing, swings back short of it, and settles — the way a
160
+ * real needle hunts for north. One full turn is what changes the silhouette
161
+ * at 22px: the diamond reads reversed at the half-way frame. Pivot at the
162
+ * dial's centre.
163
+ *
164
+ * The held poses sit at 360°, not 0°: the performance ends a full turn on
165
+ * from where it started, and a held pose at 0° would make the settle spin
166
+ * the needle backwards. 360° and 0° draw the same needle.
167
+ */
168
+ const compassNeedle: Variants = {
169
+ rest: { rotate: 360, fillOpacity: 0, transition: returnTransition },
170
+ active: { rotate: 360, fillOpacity: 1, transition: returnTransition },
171
+ play: {
172
+ rotate: [0, -24, 380, 352, 364, 360],
173
+ // The fill lands with the needle: it takes as the swing dies.
174
+ fillOpacity: [0, 0, 0, 0.6, 1, 1],
175
+ transition: beats(COMPASS_TIMES, 0.7),
176
+ },
177
+ };
178
+
179
+ export function MotionCompass(props: MotionIconProps) {
180
+ return (
181
+ <MotionIcon {...props}>
182
+ <circle cx="12" cy="12" r="10" />
183
+ <motion.path
184
+ variants={compassNeedle}
185
+ style={pivot(12, 12)}
186
+ fill="currentColor"
187
+ fillOpacity={0}
188
+ d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"
189
+ />
190
+ </MotionIcon>
191
+ );
192
+ }
193
+
152
194
  // ─── Map pin — jumps, and fills on the way down (fall and land) ───────────────
153
195
 
154
196
  const PIN_TIMES = [0, 0.16, 0.44, 0.72, 1];
@@ -0,0 +1,57 @@
1
+ import * as React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { PartyCounter, type PartyCounterLabels } from "./party-counter";
4
+
5
+ const labels: PartyCounterLabels = {
6
+ decrease: "Fewer people",
7
+ increase: "More people",
8
+ unit: (count) => (count === 1 ? "person" : "people"),
9
+ };
10
+
11
+ const meta: Meta<typeof PartyCounter> = {
12
+ title: "Booking/PartyCounter",
13
+ component: PartyCounter,
14
+ tags: ["autodocs"],
15
+ args: { labels, min: 1, max: 8 },
16
+ decorators: [
17
+ (Story) => (
18
+ <div className="w-[335px]">
19
+ <Story />
20
+ </div>
21
+ ),
22
+ ],
23
+ parameters: {
24
+ docs: {
25
+ description: {
26
+ component:
27
+ "How many people, in \"When are you coming?\" and \"Change party size\". The plus is filled: it is the direction the screen is asking about. At an end the button disables rather than hides, so the value never moves.",
28
+ },
29
+ },
30
+ },
31
+ };
32
+
33
+ export default meta;
34
+ type Story = StoryObj<typeof PartyCounter>;
35
+
36
+ function Controlled(args: React.ComponentProps<typeof PartyCounter>) {
37
+ const [value, setValue] = React.useState(args.value);
38
+ return <PartyCounter {...args} value={value} onValueChange={setValue} />;
39
+ }
40
+
41
+ /** B1: "2 people". */
42
+ export const Default: Story = {
43
+ args: { value: 2 },
44
+ render: (args) => <Controlled {...args} />,
45
+ };
46
+
47
+ /** At the minimum the minus disables; the value stays put. */
48
+ export const AtMinimum: Story = {
49
+ args: { value: 1 },
50
+ render: (args) => <Controlled {...args} />,
51
+ };
52
+
53
+ /** B9: "4 people". */
54
+ export const FourPeople: Story = {
55
+ args: { value: 4 },
56
+ render: (args) => <Controlled {...args} />,
57
+ };
@@ -0,0 +1,93 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Minus, Plus } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+
7
+ /**
8
+ * PartyCounter — how many people, in the booking flow's "When are you
9
+ * coming?" and "Change party size" screens.
10
+ *
11
+ * Measured from the booking design (`.stepper`): a 52px bordered field on the
12
+ * card ground, 12px radius, 8px side inset. A round 36px minus on the muted
13
+ * surface at the left, a round 36px plus filled with `--gradient-primary` at
14
+ * the right — the plus is the direction the screen is asking about — and the
15
+ * count between them at 19px bold with its unit at 12px muted.
16
+ *
17
+ * At an end the button is disabled, not hidden, so the value never shifts.
18
+ * The value is announced politely on change; the buttons carry their own
19
+ * localised names, because a lone "−" reads as nothing to a screen reader.
20
+ */
21
+
22
+ export type PartyCounterLabels = {
23
+ /** "Fewer people" */
24
+ decrease: string;
25
+ /** "More people" */
26
+ increase: string;
27
+ /** The unit after the number: "people" / "person" — already pluralised by the caller. */
28
+ unit: (count: number) => string;
29
+ };
30
+
31
+ export type PartyCounterProps = {
32
+ value: number;
33
+ onValueChange: (value: number) => void;
34
+ min?: number;
35
+ max?: number;
36
+ labels: PartyCounterLabels;
37
+ disabled?: boolean;
38
+ className?: string;
39
+ };
40
+
41
+ export function PartyCounter({
42
+ value,
43
+ onValueChange,
44
+ min = 1,
45
+ max = 20,
46
+ labels,
47
+ disabled,
48
+ className,
49
+ }: PartyCounterProps) {
50
+ const atMin = value <= min;
51
+ const atMax = value >= max;
52
+
53
+ const roundButton =
54
+ "grid size-9 shrink-0 cursor-pointer place-items-center rounded-full transition-[filter,background-color,opacity] duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card disabled:cursor-not-allowed disabled:opacity-40 [&_svg]:size-4";
55
+
56
+ return (
57
+ <div
58
+ data-slot="party-counter"
59
+ className={cn(
60
+ "flex h-[52px] items-center justify-between gap-2.5 rounded-xl border border-border bg-card px-2",
61
+ className
62
+ )}
63
+ >
64
+ <button
65
+ type="button"
66
+ aria-label={labels.decrease}
67
+ disabled={disabled || atMin}
68
+ onClick={() => onValueChange(Math.max(min, value - 1))}
69
+ className={cn(roundButton, "bg-surface-2 text-foreground hover:bg-muted")}
70
+ >
71
+ <Minus aria-hidden />
72
+ </button>
73
+
74
+ <output aria-live="polite" className="flex-1 text-center">
75
+ <b className="text-[19px] font-bold tabular-nums text-foreground">{value}</b>
76
+ <span className="ml-1 text-xs text-muted-foreground">{labels.unit(value)}</span>
77
+ </output>
78
+
79
+ <button
80
+ type="button"
81
+ aria-label={labels.increase}
82
+ disabled={disabled || atMax}
83
+ onClick={() => onValueChange(Math.min(max, value + 1))}
84
+ className={cn(
85
+ roundButton,
86
+ "bg-[image:var(--gradient-primary)] text-white hover:brightness-[0.94]"
87
+ )}
88
+ >
89
+ <Plus aria-hidden />
90
+ </button>
91
+ </div>
92
+ );
93
+ }
@@ -68,32 +68,3 @@ export const LongTitles: Story = {
68
68
  ],
69
69
  },
70
70
  };
71
-
72
- /**
73
- * A long localised date with the time split out into `time`. The date
74
- * truncates ("sâm., 5 sept." becomes "sâm., 5 se…" if it must); the time is
75
- * `shrink-0` outside the truncating span and is always whole. Before this the
76
- * whole line was one truncating string and clipped to "17:…".
77
- */
78
- export const LocalisedDateAndTime: Story = {
79
- args: {
80
- items: [
81
- { id: "1", title: "Warehouse Rave", meta: "sâmbătă, 5 septembrie", time: "17:00" },
82
- { id: "2", title: "Jazz on the Rooftop", meta: "vineri, 4 septembrie", time: "19:30" },
83
- { id: "3", title: "Open Mic Night", meta: "joi, 3 sept.", time: "21:00" },
84
- { id: "4", title: "Street Food Market", meta: "duminică, 6 septembrie", time: "12:00" },
85
- ],
86
- },
87
- };
88
-
89
- /**
90
- * With `labels`, a pause/play toggle renders under the tape's right edge —
91
- * `<DriftRail>`'s control, forwarded. Pressing it latches the tape still
92
- * until play is pressed again. It does not render under
93
- * `prefers-reduced-motion`, where nothing drifts.
94
- */
95
- export const WithPauseControl: Story = {
96
- args: {
97
- labels: { pause: "Pause the tape", play: "Play the tape" },
98
- },
99
- };