@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.
- package/dist/{chunk-5LTILG3L.js → chunk-5HHRVJOT.js} +14 -1
- package/dist/{chunk-SDWN6L4O.js → chunk-AHHDC7ML.js} +1 -1
- package/dist/{chunk-5XREWOBB.js → chunk-IEIJP73R.js} +14 -1
- package/dist/{chunk-BKKQ7GCZ.js → chunk-QG6I2ILT.js} +1 -1
- package/dist/{chunk-TIQNM4XD.js → chunk-VEGFQSZA.js} +17 -1
- package/dist/components/alert-dialog.js +1 -1
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/booking-row.d.ts +60 -0
- package/dist/components/booking-row.js +103 -0
- package/dist/components/booking-summary-card.d.ts +38 -0
- package/dist/components/booking-summary-card.js +67 -0
- package/dist/components/button.d.ts +2 -2
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.d.ts +17 -3
- package/dist/components/calendar.js +100 -12
- package/dist/components/carousel.js +1 -1
- package/dist/components/coach-marks.js +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/command.d.ts +7 -7
- package/dist/components/concept-convergence.js +2 -2
- package/dist/components/empty-state.js +1 -1
- package/dist/components/event-card.js +1 -1
- package/dist/components/event-compact-row.js +1 -1
- package/dist/components/event-detail-panel.js +1 -1
- package/dist/components/event-list-row-wide.js +2 -2
- package/dist/components/event-list-row.js +1 -1
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/hold-countdown.d.ts +43 -0
- package/dist/components/hold-countdown.js +90 -0
- package/dist/components/kitchen-dossier.js +1 -1
- package/dist/components/link-button.js +1 -1
- package/dist/components/menu-family-block.js +1 -1
- package/dist/components/menu-index-list.js +1 -1
- package/dist/components/menu-item.js +3 -3
- package/dist/components/motion-icon-set.d.ts +2 -1
- package/dist/components/motion-icon-set.js +27 -1
- package/dist/components/multi-select-combobox.js +2 -2
- package/dist/components/pagination.js +1 -1
- package/dist/components/party-counter.d.ts +36 -0
- package/dist/components/party-counter.js +61 -0
- package/dist/components/place-card.js +2 -2
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/searchable-select.js +1 -1
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/sliding-panels.js +1 -1
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +3 -3
- package/dist/components/time-slot.d.ts +73 -0
- package/dist/components/time-slot.js +106 -0
- package/dist/components/toggle-group.js +15 -2
- package/dist/components/toggle.d.ts +1 -1
- package/dist/components/toggle.js +1 -1
- package/dist/components/top-ranking-entry.js +3 -3
- package/dist/components/weekday-chip.d.ts +33 -0
- package/dist/components/weekday-chip.js +48 -0
- package/package.json +1 -1
- package/src/components/badge.stories.tsx +17 -0
- package/src/components/badge.tsx +15 -0
- package/src/components/booking-primitives.test.tsx +180 -0
- package/src/components/booking-row.stories.tsx +66 -0
- package/src/components/booking-row.tsx +168 -0
- package/src/components/booking-summary-card.stories.tsx +41 -0
- package/src/components/booking-summary-card.tsx +109 -0
- package/src/components/button.stories.tsx +23 -0
- package/src/components/button.tsx +13 -0
- package/src/components/calendar.stories.tsx +27 -0
- package/src/components/calendar.tsx +110 -3
- package/src/components/hold-countdown.stories.tsx +68 -0
- package/src/components/hold-countdown.tsx +140 -0
- package/src/components/motion-icon-set.stories.tsx +2 -0
- package/src/components/motion-icon-set.tsx +42 -0
- package/src/components/party-counter.stories.tsx +57 -0
- package/src/components/party-counter.tsx +93 -0
- package/src/components/time-slot.stories.tsx +85 -0
- package/src/components/time-slot.tsx +186 -0
- package/src/components/toggle-group.stories.tsx +27 -0
- package/src/components/toggle-group.tsx +14 -1
- package/src/components/toggle.tsx +16 -0
- package/src/components/weekday-chip.stories.tsx +54 -0
- package/src/components/weekday-chip.tsx +75 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { TimeSlot, TimeSlotGroups, TimeSlotGroupsSkeleton, type TimeSlotGroup } from "./time-slot";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof TimeSlot> = {
|
|
6
|
+
title: "Booking/TimeSlot",
|
|
7
|
+
component: TimeSlot,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
decorators: [
|
|
10
|
+
(Story) => (
|
|
11
|
+
<div className="w-[107px]">
|
|
12
|
+
<Story />
|
|
13
|
+
</div>
|
|
14
|
+
),
|
|
15
|
+
],
|
|
16
|
+
parameters: {
|
|
17
|
+
docs: {
|
|
18
|
+
description: {
|
|
19
|
+
component:
|
|
20
|
+
"A bookable time. Mono, tabular, with an optional meta line — Popular and \"2 left\" in accent, Waitlist muted. Selected fills with the brand gradient; unavailable fades and strikes through.",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj<typeof TimeSlot>;
|
|
28
|
+
|
|
29
|
+
export const Plain: Story = { args: { time: "17:00" } };
|
|
30
|
+
export const Popular: Story = { args: { time: "19:30", meta: "Popular" } };
|
|
31
|
+
export const FewLeft: Story = { args: { time: "20:00", meta: "2 left" } };
|
|
32
|
+
export const Waitlist: Story = { args: { time: "22:00", meta: "Waitlist", tone: "waitlist" } };
|
|
33
|
+
export const Selected: Story = { args: { time: "19:30", meta: "Popular", selected: true } };
|
|
34
|
+
export const SelectedPlain: Story = { args: { time: "20:30", selected: true } };
|
|
35
|
+
export const Unavailable: Story = { args: { time: "18:00", unavailable: true } };
|
|
36
|
+
|
|
37
|
+
function groupsFor(selected: string, onSelect: (time: string) => void): TimeSlotGroup[] {
|
|
38
|
+
const slot = (time: string, meta?: string, tone?: "waitlist") => ({
|
|
39
|
+
key: time,
|
|
40
|
+
time,
|
|
41
|
+
...(meta ? { meta } : {}),
|
|
42
|
+
...(tone ? { tone } : {}),
|
|
43
|
+
selected: selected === time,
|
|
44
|
+
onSelect: () => onSelect(time),
|
|
45
|
+
});
|
|
46
|
+
return [
|
|
47
|
+
{ key: "early", label: "Early", slots: [slot("17:00"), slot("17:30"), slot("18:00")] },
|
|
48
|
+
{
|
|
49
|
+
key: "evening",
|
|
50
|
+
label: "Evening",
|
|
51
|
+
slots: [
|
|
52
|
+
slot("18:30"),
|
|
53
|
+
slot("19:00"),
|
|
54
|
+
slot("19:30", "Popular"),
|
|
55
|
+
slot("20:00", "2 left"),
|
|
56
|
+
slot("20:30"),
|
|
57
|
+
slot("21:00"),
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{ key: "late", label: "Late", slots: [slot("21:30"), slot("22:00", "Waitlist", "waitlist")] },
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** B1: the three groups, 19:30 chosen. */
|
|
65
|
+
export const Groups: StoryObj<typeof TimeSlotGroups> = {
|
|
66
|
+
render: () => {
|
|
67
|
+
const [selected, setSelected] = React.useState("19:30");
|
|
68
|
+
return (
|
|
69
|
+
<div className="w-[335px]">
|
|
70
|
+
<TimeSlotGroups groups={groupsFor(selected, setSelected)} />
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
},
|
|
74
|
+
decorators: [(Story) => <Story />],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** S1: checking availability — the grid keeps its shape. */
|
|
78
|
+
export const Loading: StoryObj<typeof TimeSlotGroupsSkeleton> = {
|
|
79
|
+
render: () => (
|
|
80
|
+
<div className="w-[335px]">
|
|
81
|
+
<TimeSlotGroupsSkeleton label="Checking availability for 4…" />
|
|
82
|
+
</div>
|
|
83
|
+
),
|
|
84
|
+
decorators: [(Story) => <Story />],
|
|
85
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { Skeleton } from "./skeleton";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* TimeSlot and TimeSlotGroups — the bookable times in the booking flow's
|
|
9
|
+
* "When are you coming?" screen, the reserve panel on the venue page, and
|
|
10
|
+
* "Change party size".
|
|
11
|
+
*
|
|
12
|
+
* Measured from the booking design (`.tsl`, `.tgrid`, `.grouplbl`):
|
|
13
|
+
* - A slot is a card with a 1px hairline, 11px radius, 8px × 4px inset,
|
|
14
|
+
* centred. The time in mono at 14.5px bold, line-height 1. An optional
|
|
15
|
+
* meta line under it at 9.5px, 3px apart — "Popular", "2 left" in accent,
|
|
16
|
+
* "Waitlist" muted.
|
|
17
|
+
* - Selected fills with `--gradient-primary`, white type, the meta at 80%.
|
|
18
|
+
* - Unavailable fades to 32% with the time struck through.
|
|
19
|
+
* - Groups are "Early / Evening / Late": a mono 10px small-caps label
|
|
20
|
+
* (0.08em tracking, 14px above, 7px below) over a three-column grid with
|
|
21
|
+
* 7px gaps.
|
|
22
|
+
*
|
|
23
|
+
* A slot is a button with `aria-pressed`: one of many times, picked once.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type TimeSlotTone = "default" | "waitlist";
|
|
27
|
+
|
|
28
|
+
export type TimeSlotProps = {
|
|
29
|
+
/** The time as displayed, already formatted in the venue's timezone — "19:30". */
|
|
30
|
+
time: string;
|
|
31
|
+
/** "Popular", "2 left", "Waitlist" — already localised. */
|
|
32
|
+
meta?: string;
|
|
33
|
+
/** `waitlist` renders the meta muted: the slot books onto the list, not a table. */
|
|
34
|
+
tone?: TimeSlotTone;
|
|
35
|
+
selected?: boolean;
|
|
36
|
+
/** Taken or past: faded and struck through, not clickable. */
|
|
37
|
+
unavailable?: boolean;
|
|
38
|
+
onSelect?: () => void;
|
|
39
|
+
/** Full accessible name, when the visible text is not enough ("19:30, popular"). */
|
|
40
|
+
"aria-label"?: string;
|
|
41
|
+
className?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function TimeSlot({
|
|
45
|
+
time,
|
|
46
|
+
meta,
|
|
47
|
+
tone = "default",
|
|
48
|
+
selected,
|
|
49
|
+
unavailable,
|
|
50
|
+
onSelect,
|
|
51
|
+
"aria-label": ariaLabel,
|
|
52
|
+
className,
|
|
53
|
+
}: TimeSlotProps) {
|
|
54
|
+
return (
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
data-slot="time-slot"
|
|
58
|
+
aria-pressed={selected ? true : false}
|
|
59
|
+
aria-label={ariaLabel}
|
|
60
|
+
disabled={unavailable}
|
|
61
|
+
onClick={onSelect}
|
|
62
|
+
className={cn(
|
|
63
|
+
// Content sits at the top: a slot without a meta line in a row with
|
|
64
|
+
// one is stretched by the grid, and the time must not drop to the middle.
|
|
65
|
+
"flex w-full cursor-pointer flex-col items-center justify-start rounded-[11px] border px-1 py-2 text-center leading-[1.2]",
|
|
66
|
+
"transition-[border-color,filter] duration-150 motion-reduce:transition-none",
|
|
67
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
68
|
+
selected
|
|
69
|
+
? "border-transparent bg-[image:var(--gradient-primary)] text-white hover:brightness-[0.94]"
|
|
70
|
+
: "border-border bg-card text-foreground hover:border-accent/50",
|
|
71
|
+
unavailable && "cursor-not-allowed opacity-[0.32] hover:border-border"
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
<b
|
|
75
|
+
className={cn(
|
|
76
|
+
"block font-mono text-[14.5px] leading-none font-bold tabular-nums",
|
|
77
|
+
unavailable && "line-through"
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{time}
|
|
81
|
+
</b>
|
|
82
|
+
{meta && (
|
|
83
|
+
<span
|
|
84
|
+
className={cn(
|
|
85
|
+
"mt-[3px] block text-[9.5px] leading-[1.2]",
|
|
86
|
+
selected ? "text-white/80" : tone === "waitlist" ? "text-muted-foreground" : "text-accent"
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
{meta}
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
</button>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type TimeSlotGroup = {
|
|
97
|
+
/** Stable key — "early", "evening", "late". */
|
|
98
|
+
key: string;
|
|
99
|
+
/** "Early", "Evening", "Late" — already localised; rendered small-caps. */
|
|
100
|
+
label: string;
|
|
101
|
+
slots: (TimeSlotProps & { key: string })[];
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type TimeSlotGroupsProps = {
|
|
105
|
+
groups: TimeSlotGroup[];
|
|
106
|
+
/** Columns in each group's grid. The design uses 3 everywhere; 2 only in very narrow panels. */
|
|
107
|
+
columns?: 2 | 3;
|
|
108
|
+
className?: string;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const GRID_COLUMNS = { 2: "grid-cols-2", 3: "grid-cols-3" } as const;
|
|
112
|
+
|
|
113
|
+
export function TimeSlotGroupLabel({
|
|
114
|
+
children,
|
|
115
|
+
className,
|
|
116
|
+
}: {
|
|
117
|
+
children: React.ReactNode;
|
|
118
|
+
className?: string;
|
|
119
|
+
}) {
|
|
120
|
+
return (
|
|
121
|
+
<div
|
|
122
|
+
className={cn(
|
|
123
|
+
"mb-[7px] font-mono text-[10px] leading-none font-semibold tracking-[0.08em] text-muted-foreground uppercase",
|
|
124
|
+
className
|
|
125
|
+
)}
|
|
126
|
+
>
|
|
127
|
+
{children}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function TimeSlotGroups({ groups, columns = 3, className }: TimeSlotGroupsProps) {
|
|
133
|
+
return (
|
|
134
|
+
<div data-slot="time-slot-groups" className={className}>
|
|
135
|
+
{groups.map((group) => (
|
|
136
|
+
// 14px between groups; the first sits flush with whatever is above.
|
|
137
|
+
<section key={group.key} aria-label={group.label} className="[&+&]:mt-3.5">
|
|
138
|
+
<TimeSlotGroupLabel>{group.label}</TimeSlotGroupLabel>
|
|
139
|
+
<div className={cn("grid gap-[7px]", GRID_COLUMNS[columns])}>
|
|
140
|
+
{group.slots.map(({ key, ...slot }) => (
|
|
141
|
+
<TimeSlot key={key} {...slot} />
|
|
142
|
+
))}
|
|
143
|
+
</div>
|
|
144
|
+
</section>
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type TimeSlotGroupsSkeletonProps = {
|
|
151
|
+
/** The label above the grid while times load — "Checking availability for 4…". */
|
|
152
|
+
label: string;
|
|
153
|
+
rows?: number;
|
|
154
|
+
columns?: 2 | 3;
|
|
155
|
+
className?: string;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The grid while times load (S1). Same cells, same gaps, so nothing jumps when
|
|
160
|
+
* the real times land: a slot's footprint with a dashed hairline at 60%, a
|
|
161
|
+
* soft bar in place of the time. The label says what is being checked.
|
|
162
|
+
*/
|
|
163
|
+
export function TimeSlotGroupsSkeleton({
|
|
164
|
+
label,
|
|
165
|
+
rows = 3,
|
|
166
|
+
columns = 3,
|
|
167
|
+
className,
|
|
168
|
+
}: TimeSlotGroupsSkeletonProps) {
|
|
169
|
+
return (
|
|
170
|
+
<div data-slot="time-slot-groups-skeleton" role="status" aria-live="polite" className={className}>
|
|
171
|
+
<TimeSlotGroupLabel>{label}</TimeSlotGroupLabel>
|
|
172
|
+
<div className={cn("grid gap-[7px]", GRID_COLUMNS[columns])} aria-hidden>
|
|
173
|
+
{Array.from({ length: rows * columns }, (_, index) => (
|
|
174
|
+
<span
|
|
175
|
+
key={index}
|
|
176
|
+
// Full opacity in dark: at 60% the dashed hairline disappears there.
|
|
177
|
+
className="block rounded-[11px] border border-dashed border-border bg-card px-1 py-2 opacity-60 dark:opacity-100"
|
|
178
|
+
>
|
|
179
|
+
{/* 17px: a plain slot's time line, so the cell is exactly a slot's 35px. */}
|
|
180
|
+
<Skeleton className="mx-auto h-[17px] w-[90%] rounded-md" />
|
|
181
|
+
</span>
|
|
182
|
+
))}
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
@@ -94,3 +94,30 @@ export const Segmented: Story = {
|
|
|
94
94
|
</div>
|
|
95
95
|
),
|
|
96
96
|
};
|
|
97
|
+
|
|
98
|
+
/** Choice chips that wrap: the booking flow's seating preference (B2). */
|
|
99
|
+
export const Chips: Story = {
|
|
100
|
+
render: () => (
|
|
101
|
+
<div className="w-[335px]">
|
|
102
|
+
<ToggleGroup type="single" variant="chip" defaultValue="terrace" aria-label="Seating preference">
|
|
103
|
+
<ToggleGroupItem value="none">No preference</ToggleGroupItem>
|
|
104
|
+
<ToggleGroupItem value="inside">Inside</ToggleGroupItem>
|
|
105
|
+
<ToggleGroupItem value="terrace">Terrace</ToggleGroupItem>
|
|
106
|
+
<ToggleGroupItem value="window">By the window</ToggleGroupItem>
|
|
107
|
+
<ToggleGroupItem value="quiet">Quiet corner</ToggleGroupItem>
|
|
108
|
+
</ToggleGroup>
|
|
109
|
+
</div>
|
|
110
|
+
),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** Tabs that share the width: My bookings' Upcoming / Past (B6). */
|
|
114
|
+
export const Tabs: Story = {
|
|
115
|
+
render: () => (
|
|
116
|
+
<div className="w-[343px]">
|
|
117
|
+
<ToggleGroup type="single" variant="tab" defaultValue="upcoming" aria-label="Bookings">
|
|
118
|
+
<ToggleGroupItem value="upcoming">Upcoming</ToggleGroupItem>
|
|
119
|
+
<ToggleGroupItem value="past">Past</ToggleGroupItem>
|
|
120
|
+
</ToggleGroup>
|
|
121
|
+
</div>
|
|
122
|
+
),
|
|
123
|
+
};
|
|
@@ -29,6 +29,12 @@ function ToggleGroup({
|
|
|
29
29
|
// The segmented control's capsule: muted ground, the on cell a raised
|
|
30
30
|
// card inside it (see `toggleVariants.segmented`). 44px tall.
|
|
31
31
|
"data-[variant=segmented]:gap-0.5 data-[variant=segmented]:rounded-full data-[variant=segmented]:bg-muted data-[variant=segmented]:p-0.5",
|
|
32
|
+
// Chips wrap onto as many rows as they need, 7px apart.
|
|
33
|
+
"data-[variant=chip]:flex-wrap data-[variant=chip]:gap-[7px] data-[variant=chip]:rounded-none",
|
|
34
|
+
// The tab bar spans its container: a muted capsule, 4px inset.
|
|
35
|
+
// Dark swaps the steps: the track takes the card, the ON tab the
|
|
36
|
+
// lighter `surface-2`, so the tab still reads as raised.
|
|
37
|
+
"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",
|
|
32
38
|
className
|
|
33
39
|
)}
|
|
34
40
|
{...props}
|
|
@@ -61,7 +67,14 @@ function ToggleGroupItem({
|
|
|
61
67
|
"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",
|
|
62
68
|
resolved === "segmented"
|
|
63
69
|
? "rounded-full px-0 data-[state=on]:shadow-[var(--shadow-card)]"
|
|
64
|
-
:
|
|
70
|
+
: // Tabs and chips size to their type: the size variant's fixed
|
|
71
|
+
// height and min-width would otherwise win the merge.
|
|
72
|
+
resolved === "tab"
|
|
73
|
+
? "h-auto min-w-0 rounded-full px-3"
|
|
74
|
+
: resolved === "chip"
|
|
75
|
+
? // A chip keeps its own width, so the row can wrap.
|
|
76
|
+
"h-auto min-w-0 flex-none rounded-full px-[13px]"
|
|
77
|
+
: "rounded-none first:rounded-l-md last:rounded-r-md",
|
|
65
78
|
className
|
|
66
79
|
)}
|
|
67
80
|
{...props}
|