@bearmenu/ui 0.8.24 → 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-I4RUOAU7.js → chunk-AHL45DD2.js} +11 -38
- 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/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/concept-convergence.js +2 -2
- package/dist/components/drift-rail.d.ts +2 -29
- package/dist/components/drift-rail.js +1 -3
- 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/poster-tape.d.ts +2 -15
- package/dist/components/poster-tape.js +3 -12
- 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/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/drift-rail.stories.tsx +1 -29
- package/src/components/drift-rail.tsx +4 -71
- 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/poster-tape.stories.tsx +0 -29
- package/src/components/poster-tape.tsx +5 -21
- 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
- package/src/components/drift-rail.test.tsx +0 -125
- package/src/components/poster-tape.test.tsx +0 -42
|
@@ -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
|
-
};
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import type { ReactNode } from "react";
|
|
5
5
|
import { cn } from "../lib/utils";
|
|
6
|
-
import { DriftRail
|
|
6
|
+
import { DriftRail } from "./drift-rail";
|
|
7
7
|
import { EventPosterFallback } from "./event-poster-fallback";
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -17,18 +17,8 @@ import { EventPosterFallback } from "./event-poster-fallback";
|
|
|
17
17
|
export type PosterTapeItem = {
|
|
18
18
|
id: string;
|
|
19
19
|
title: string;
|
|
20
|
-
/**
|
|
21
|
-
* Caller-composed, e.g. "Sat 5" — or "Sat 5 · 20:00" when `time` is not
|
|
22
|
-
* given. Rendered as mono, small, and truncated at the tape's width.
|
|
23
|
-
*/
|
|
20
|
+
/** Caller-composed, e.g. "Sat 5 · 20:00". Rendered as mono, small. */
|
|
24
21
|
meta: string;
|
|
25
|
-
/**
|
|
26
|
-
* The time, e.g. "20:00", rendered after `meta` and never truncated. The
|
|
27
|
-
* tape is 132px wide and a long localised date ("sâm., 5 sept. · 17:00")
|
|
28
|
-
* used to clip to "17:…" — the one figure the reader came for. Split it out
|
|
29
|
-
* and the date is what gives way.
|
|
30
|
-
*/
|
|
31
|
-
time?: string;
|
|
32
22
|
posterUrl?: string;
|
|
33
23
|
href?: string;
|
|
34
24
|
/** Rendered instead of the poster when it's absent or failed to load. Defaults to `<EventPosterFallback />`. */
|
|
@@ -50,8 +40,6 @@ export type PosterTapeProps = {
|
|
|
50
40
|
/** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
|
|
51
41
|
linkComponent?: React.ComponentType<PosterTapeLinkProps>;
|
|
52
42
|
durationSeconds?: number;
|
|
53
|
-
/** Forwarded to `<DriftRail>`: names for its pause/play toggle, which renders only when given. */
|
|
54
|
-
labels?: DriftRailLabels;
|
|
55
43
|
className?: string;
|
|
56
44
|
};
|
|
57
45
|
|
|
@@ -93,11 +81,8 @@ function PosterTapeItemCard({
|
|
|
93
81
|
<div className="mt-1.5 text-[11px] font-semibold leading-[1.3] sm:mt-[7px] sm:text-[11.5px]">
|
|
94
82
|
{item.title}
|
|
95
83
|
</div>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
<div className="mt-0.5 flex gap-1 font-mono text-[10px] text-muted-foreground sm:text-[10.5px]">
|
|
99
|
-
<span className="min-w-0 truncate">{item.meta}</span>
|
|
100
|
-
{item.time && <span className="shrink-0">· {item.time}</span>}
|
|
84
|
+
<div className="mt-0.5 truncate font-mono text-[10px] text-muted-foreground sm:text-[10.5px]">
|
|
85
|
+
{item.meta}
|
|
101
86
|
</div>
|
|
102
87
|
</>
|
|
103
88
|
);
|
|
@@ -125,11 +110,10 @@ export function PosterTape({
|
|
|
125
110
|
items,
|
|
126
111
|
linkComponent: LinkComponent = DefaultLink,
|
|
127
112
|
durationSeconds = POSTER_TAPE_DEFAULT_DURATION_SECONDS,
|
|
128
|
-
labels,
|
|
129
113
|
className,
|
|
130
114
|
}: PosterTapeProps) {
|
|
131
115
|
return (
|
|
132
|
-
<DriftRail durationSeconds={durationSeconds}
|
|
116
|
+
<DriftRail durationSeconds={durationSeconds} className={className}>
|
|
133
117
|
{items.map((item) => (
|
|
134
118
|
<PosterTapeItemCard key={item.id} item={item} LinkComponent={LinkComponent} />
|
|
135
119
|
))}
|
|
@@ -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}
|
|
@@ -22,6 +22,22 @@ const toggleVariants = cva(
|
|
|
22
22
|
*/
|
|
23
23
|
segmented:
|
|
24
24
|
"h-10 w-11 rounded-full text-muted-foreground hover:bg-transparent hover:text-foreground data-[state=on]:bg-card data-[state=on]:text-foreground data-[state=on]:shadow-[var(--shadow-card)]",
|
|
25
|
+
/**
|
|
26
|
+
* A choice among a few named options that sit in a wrapping row —
|
|
27
|
+
* the booking flow's seating preference ("No preference", "Terrace",
|
|
28
|
+
* "By the window"). Off: a card pill with a hairline. On: the accent
|
|
29
|
+
* hairline over an 8% accent wash and accent type — a selection, not
|
|
30
|
+
* a button, so it never fills.
|
|
31
|
+
*/
|
|
32
|
+
chip: "h-auto min-w-0 rounded-full border border-border bg-card px-[13px] py-[7px] text-[12.5px] leading-[1.2] font-normal text-foreground hover:border-accent/40 hover:bg-card hover:text-foreground data-[state=on]:border-accent data-[state=on]:bg-accent/[0.08] data-[state=on]:font-semibold data-[state=on]:text-accent",
|
|
33
|
+
/**
|
|
34
|
+
* One tab of a two-or-three-way switch that shares the width — My
|
|
35
|
+
* bookings' Upcoming / Past. The group (`ToggleGroup variant="tab"`)
|
|
36
|
+
* supplies the muted capsule; the ON tab is a raised card. The type
|
|
37
|
+
* does not change colour between on and off — as drawn, the raised
|
|
38
|
+
* card alone says which tab is current.
|
|
39
|
+
*/
|
|
40
|
+
tab: "h-auto min-w-0 flex-1 rounded-full px-3 py-[7px] text-[12.5px] leading-[1.2] font-semibold text-ink-secondary hover:bg-transparent hover:text-foreground data-[state=on]:bg-card data-[state=on]:text-ink-secondary data-[state=on]:shadow-[0_1px_2px_oklch(0.5_0_0/0.12)] dark:data-[state=on]:bg-surface-2 dark:data-[state=on]:text-foreground",
|
|
25
41
|
},
|
|
26
42
|
size: {
|
|
27
43
|
default: "h-9 px-2 min-w-9",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { WeekdayChip } from "./weekday-chip";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof WeekdayChip> = {
|
|
6
|
+
title: "Booking/WeekdayChip",
|
|
7
|
+
component: WeekdayChip,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"One day in the booking flow's week rail on mobile. Weekday over date, sharing the rail's width equally. Not DayRailChip, which is the events timeline's day with a count.",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof WeekdayChip>;
|
|
21
|
+
|
|
22
|
+
export const Default: Story = { args: { weekday: "Mon", day: "10" } };
|
|
23
|
+
export const Selected: Story = { args: { weekday: "Fri", day: "14", selected: true } };
|
|
24
|
+
export const Unavailable: Story = { args: { weekday: "Tue", day: "11", unavailable: true } };
|
|
25
|
+
|
|
26
|
+
const WEEK = [
|
|
27
|
+
["Mon", "10"],
|
|
28
|
+
["Tue", "11"],
|
|
29
|
+
["Wed", "12"],
|
|
30
|
+
["Thu", "13"],
|
|
31
|
+
["Fri", "14"],
|
|
32
|
+
["Sat", "15"],
|
|
33
|
+
["Sun", "16"],
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
/** B1 mobile: the week, Friday chosen. */
|
|
37
|
+
export const Week: Story = {
|
|
38
|
+
render: () => {
|
|
39
|
+
const [selected, setSelected] = React.useState("14");
|
|
40
|
+
return (
|
|
41
|
+
<div className="flex w-[335px] gap-1.5">
|
|
42
|
+
{WEEK.map(([weekday, day]) => (
|
|
43
|
+
<WeekdayChip
|
|
44
|
+
key={day}
|
|
45
|
+
weekday={weekday}
|
|
46
|
+
day={day}
|
|
47
|
+
selected={selected === day}
|
|
48
|
+
onSelect={() => setSelected(day)}
|
|
49
|
+
/>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* WeekdayChip — one day in the booking flow's week rail on mobile
|
|
8
|
+
* ("MON 10 · TUE 11 · … · SUN 16" in "When are you coming?").
|
|
9
|
+
*
|
|
10
|
+
* Not `DayRailChip`: that one is the events timeline's day, with a single-line
|
|
11
|
+
* label and an event count. This one is a date picker's day — weekday stacked
|
|
12
|
+
* over the date, no count — and the rail shares the width equally.
|
|
13
|
+
*
|
|
14
|
+
* Measured from the booking design (`.dchip`): flexes to an equal share, 1px
|
|
15
|
+
* hairline, 11px radius, 7px vertical inset, centred. The weekday at 10px
|
|
16
|
+
* uppercase, 0.05em tracking, muted; the date at 15px bold, tabular, 2px
|
|
17
|
+
* below. Selected fills with `--gradient-primary`, white type, the weekday at
|
|
18
|
+
* 75%. A day that cannot be booked (closed, past the window) fades to 40% and
|
|
19
|
+
* is disabled.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type WeekdayChipProps = {
|
|
23
|
+
/** "Mon", "Tue" — already localised; rendered uppercase. */
|
|
24
|
+
weekday: string;
|
|
25
|
+
/** Day of month as displayed — "14". */
|
|
26
|
+
day: string;
|
|
27
|
+
selected?: boolean;
|
|
28
|
+
/** Closed that day or outside the booking window. */
|
|
29
|
+
unavailable?: boolean;
|
|
30
|
+
onSelect?: () => void;
|
|
31
|
+
/** The full date for assistive tech — "Friday 14 August". */
|
|
32
|
+
"aria-label"?: string;
|
|
33
|
+
className?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function WeekdayChip({
|
|
37
|
+
weekday,
|
|
38
|
+
day,
|
|
39
|
+
selected,
|
|
40
|
+
unavailable,
|
|
41
|
+
onSelect,
|
|
42
|
+
"aria-label": ariaLabel,
|
|
43
|
+
className,
|
|
44
|
+
}: WeekdayChipProps) {
|
|
45
|
+
return (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
data-slot="weekday-chip"
|
|
49
|
+
aria-pressed={selected ? true : false}
|
|
50
|
+
aria-label={ariaLabel}
|
|
51
|
+
disabled={unavailable}
|
|
52
|
+
onClick={onSelect}
|
|
53
|
+
className={cn(
|
|
54
|
+
"min-w-0 flex-1 cursor-pointer rounded-[11px] border py-[7px] text-center leading-[1.2]",
|
|
55
|
+
"transition-[border-color,filter] duration-150 motion-reduce:transition-none",
|
|
56
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
57
|
+
selected
|
|
58
|
+
? "border-transparent bg-[image:var(--gradient-primary)] text-white"
|
|
59
|
+
: "border-border bg-card text-foreground hover:border-accent/50",
|
|
60
|
+
unavailable && "cursor-not-allowed opacity-40 hover:border-border",
|
|
61
|
+
className
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
<span
|
|
65
|
+
className={cn(
|
|
66
|
+
"block text-[10px] tracking-[0.05em] uppercase",
|
|
67
|
+
selected ? "text-white/75" : "text-muted-foreground"
|
|
68
|
+
)}
|
|
69
|
+
>
|
|
70
|
+
{weekday}
|
|
71
|
+
</span>
|
|
72
|
+
<span className="mt-0.5 block text-[15px] font-bold tabular-nums">{day}</span>
|
|
73
|
+
</button>
|
|
74
|
+
);
|
|
75
|
+
}
|