@bearmenu/ui 0.8.56 → 0.8.58
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/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/day-rail-chip.d.ts +8 -1
- package/dist/components/day-rail-chip.js +5 -2
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +1 -1
- package/dist/components/time-slot-rail.d.ts +21 -0
- package/dist/components/time-slot-rail.js +168 -0
- package/dist/components/time-slot.d.ts +45 -6
- package/dist/components/time-slot.js +55 -11
- package/dist/components/toggle.d.ts +1 -1
- package/dist/components/weekday-chip.d.ts +13 -2
- package/dist/components/weekday-chip.js +14 -2
- package/package.json +24 -221
- package/src/components/booking-primitives.test.tsx +7 -0
- package/src/components/day-rail-chip.test.tsx +58 -0
- package/src/components/day-rail-chip.tsx +17 -1
- package/src/components/time-slot-rail.stories.tsx +166 -0
- package/src/components/time-slot-rail.test.tsx +177 -0
- package/src/components/time-slot-rail.tsx +222 -0
- package/src/components/time-slot.stories.tsx +37 -0
- package/src/components/time-slot.test.tsx +113 -0
- package/src/components/time-slot.tsx +120 -17
- package/src/components/weekday-chip.stories.tsx +4 -0
- package/src/components/weekday-chip.tsx +25 -2
|
@@ -21,62 +21,158 @@ import { Skeleton } from "./skeleton";
|
|
|
21
21
|
* 7px gaps.
|
|
22
22
|
*
|
|
23
23
|
* A slot is a button with `aria-pressed`: one of many times, picked once.
|
|
24
|
+
*
|
|
25
|
+
* ── size="touch" ──────────────────────────────────────────────────────────
|
|
26
|
+
* The default slot is 35px tall with a 9.5px meta line: under both the 44px
|
|
27
|
+
* touch floor and the 12px type floor, so a staff tool on a phone cannot use
|
|
28
|
+
* it. `touch` is `min-h-11`, 16px mono time, 12px meta, 12px radius. Selected
|
|
29
|
+
* is a FILL of `--accent` with `accent-foreground` type (the reasoning is
|
|
30
|
+
* DayRailChip's: the gradient's primary-foreground flips dark-on-gradient in
|
|
31
|
+
* dark mode), and the meta stays at full opacity.
|
|
32
|
+
*
|
|
33
|
+
* `fit` (touch only) says how well the slot suits the party, in words AND a
|
|
34
|
+
* dot, so nothing hangs on colour: `fits` (success), `tight` (warning, a join
|
|
35
|
+
* or a squeeze), `none` (dashed, muted — still tappable, the manager can
|
|
36
|
+
* override, so no strike-through and no fading).
|
|
37
|
+
*
|
|
38
|
+
* `disabled` means LOCKED, not taken: half opacity, not-allowed, no strike.
|
|
39
|
+
* It uses `aria-disabled` rather than the `disabled` attribute so a locked
|
|
40
|
+
* rail keeps focus where it was. `unavailable` is still the taken/past state.
|
|
24
41
|
*/
|
|
25
42
|
|
|
26
43
|
export type TimeSlotTone = "default" | "waitlist";
|
|
44
|
+
export type TimeSlotSize = "default" | "touch";
|
|
45
|
+
export type TimeSlotFit = "fits" | "tight" | "none";
|
|
27
46
|
|
|
28
|
-
|
|
47
|
+
/**
|
|
48
|
+
* `fit` needs a `meta`: the meta text says the fit in words, and the dot is
|
|
49
|
+
* aria-hidden, so a fit without one would hang on colour alone.
|
|
50
|
+
*/
|
|
51
|
+
export type TimeSlotFitProps =
|
|
52
|
+
| {
|
|
53
|
+
/** "Popular", "2 left", "Waitlist" — already localised. */
|
|
54
|
+
meta?: string;
|
|
55
|
+
fit?: undefined;
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
/** Says the fit in words — "R1+R2", "Join R3+R4", "Full". */
|
|
59
|
+
meta: string;
|
|
60
|
+
/** Touch only. How well the slot suits the party. Requires `meta`. */
|
|
61
|
+
fit: TimeSlotFit;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type TimeSlotProps = TimeSlotFitProps & {
|
|
29
65
|
/** The time as displayed, already formatted in the venue's timezone — "19:30". */
|
|
30
66
|
time: string;
|
|
31
|
-
/** "Popular", "2 left", "Waitlist" — already localised. */
|
|
32
|
-
meta?: string;
|
|
33
67
|
/** `waitlist` renders the meta muted: the slot books onto the list, not a table. */
|
|
34
68
|
tone?: TimeSlotTone;
|
|
35
69
|
selected?: boolean;
|
|
36
70
|
/** Taken or past: faded and struck through, not clickable. */
|
|
37
71
|
unavailable?: boolean;
|
|
72
|
+
/** `default` is the booking flow's 35px slot; `touch` meets the 44px / 12px floors. */
|
|
73
|
+
size?: TimeSlotSize;
|
|
74
|
+
/** Locked, not taken: dimmed, not-allowed, no strike-through, still focusable. */
|
|
75
|
+
disabled?: boolean;
|
|
38
76
|
onSelect?: () => void;
|
|
39
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Full accessible name, when the visible text is not enough ("19:30, popular").
|
|
79
|
+
* It REPLACES the visible text, so with `fit` it must say the fit in words
|
|
80
|
+
* ("19:30, fits at R1 and R2"): the dot is aria-hidden.
|
|
81
|
+
*/
|
|
40
82
|
"aria-label"?: string;
|
|
41
83
|
className?: string;
|
|
42
84
|
};
|
|
43
85
|
|
|
86
|
+
const FIT_DOT = {
|
|
87
|
+
fits: "bg-success",
|
|
88
|
+
tight: "bg-warning-deep",
|
|
89
|
+
} as const;
|
|
90
|
+
|
|
91
|
+
const FIT_META = {
|
|
92
|
+
fits: "text-success-ink",
|
|
93
|
+
tight: "text-warning-ink",
|
|
94
|
+
none: "text-muted-foreground",
|
|
95
|
+
} as const;
|
|
96
|
+
|
|
44
97
|
export function TimeSlot({
|
|
45
98
|
time,
|
|
46
99
|
meta,
|
|
47
100
|
tone = "default",
|
|
48
101
|
selected,
|
|
49
102
|
unavailable,
|
|
103
|
+
size = "default",
|
|
104
|
+
fit,
|
|
105
|
+
disabled,
|
|
50
106
|
onSelect,
|
|
51
107
|
"aria-label": ariaLabel,
|
|
52
108
|
className,
|
|
53
109
|
}: TimeSlotProps) {
|
|
110
|
+
const touch = size === "touch";
|
|
111
|
+
// `fit` is a touch-only vocabulary; the default slot ignores it.
|
|
112
|
+
const activeFit = touch ? fit : undefined;
|
|
113
|
+
const locked = disabled && !unavailable;
|
|
114
|
+
const interactive = !unavailable && !locked;
|
|
115
|
+
const dashed = activeFit === "none" && !selected;
|
|
116
|
+
|
|
117
|
+
const fitDot =
|
|
118
|
+
activeFit && activeFit !== "none" ? (
|
|
119
|
+
<span
|
|
120
|
+
aria-hidden
|
|
121
|
+
data-slot="time-slot-fit-dot"
|
|
122
|
+
className={cn(
|
|
123
|
+
"size-1.5 shrink-0 rounded-full",
|
|
124
|
+
selected ? "bg-accent-foreground" : FIT_DOT[activeFit]
|
|
125
|
+
)}
|
|
126
|
+
/>
|
|
127
|
+
) : null;
|
|
128
|
+
|
|
54
129
|
return (
|
|
55
130
|
<button
|
|
56
131
|
type="button"
|
|
57
132
|
data-slot="time-slot"
|
|
133
|
+
data-size={size}
|
|
134
|
+
data-fit={activeFit}
|
|
58
135
|
aria-pressed={selected ? true : false}
|
|
59
136
|
aria-label={ariaLabel}
|
|
137
|
+
aria-disabled={locked ? true : undefined}
|
|
60
138
|
disabled={unavailable}
|
|
61
|
-
onClick={onSelect}
|
|
139
|
+
onClick={locked ? undefined : onSelect}
|
|
62
140
|
className={cn(
|
|
141
|
+
"flex w-full flex-col items-center border text-center",
|
|
63
142
|
// Content sits at the top: a slot without a meta line in a row with
|
|
64
143
|
// one is stretched by the grid, and the time must not drop to the middle.
|
|
65
|
-
|
|
144
|
+
touch
|
|
145
|
+
? "min-h-11 justify-center gap-1 rounded-xl px-2 py-2 leading-[1.2]"
|
|
146
|
+
: "justify-start rounded-[11px] px-1 py-2 leading-[1.2]",
|
|
147
|
+
interactive ? "cursor-pointer" : "cursor-not-allowed",
|
|
66
148
|
// A press state, not just hover: this is the most-tapped control in
|
|
67
149
|
// the flow and the primary device has no hover at all, so without it
|
|
68
150
|
// a tap gives nothing back until the fill repaints.
|
|
69
|
-
|
|
151
|
+
touch
|
|
152
|
+
? "transition-[background-color,border-color,color,transform] duration-150 motion-reduce:transition-none"
|
|
153
|
+
: "transition-[border-color,filter,transform] duration-150 motion-reduce:transition-none",
|
|
154
|
+
interactive && "active:scale-[0.98]",
|
|
70
155
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
71
156
|
selected
|
|
72
|
-
?
|
|
73
|
-
|
|
74
|
-
|
|
157
|
+
? touch
|
|
158
|
+
? "border-accent bg-accent text-accent-foreground"
|
|
159
|
+
: cn(
|
|
160
|
+
"border-transparent bg-[image:var(--gradient-primary)] text-primary-foreground",
|
|
161
|
+
interactive && "hover:brightness-[0.94]"
|
|
162
|
+
)
|
|
163
|
+
: dashed
|
|
164
|
+
? "border-dashed border-border bg-transparent text-muted-foreground"
|
|
165
|
+
: "border-border bg-card text-foreground",
|
|
166
|
+
!selected && interactive && "hover:border-accent/50",
|
|
167
|
+
unavailable && "opacity-[0.32]",
|
|
168
|
+
locked && "opacity-50",
|
|
169
|
+
className
|
|
75
170
|
)}
|
|
76
171
|
>
|
|
77
172
|
<b
|
|
78
173
|
className={cn(
|
|
79
|
-
"block font-mono
|
|
174
|
+
"block font-mono leading-none font-bold tabular-nums",
|
|
175
|
+
touch ? "text-base" : "text-[14.5px]",
|
|
80
176
|
unavailable && "line-through"
|
|
81
177
|
)}
|
|
82
178
|
>
|
|
@@ -85,15 +181,22 @@ export function TimeSlot({
|
|
|
85
181
|
{meta && (
|
|
86
182
|
<span
|
|
87
183
|
className={cn(
|
|
88
|
-
|
|
184
|
+
touch
|
|
185
|
+
? "flex max-w-full items-center justify-center gap-1 text-xs leading-4"
|
|
186
|
+
: "mt-[3px] block text-[9.5px] leading-[1.2]",
|
|
89
187
|
selected
|
|
90
|
-
?
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
188
|
+
? touch
|
|
189
|
+
? "text-accent-foreground"
|
|
190
|
+
: "text-primary-foreground/80"
|
|
191
|
+
: activeFit
|
|
192
|
+
? FIT_META[activeFit]
|
|
193
|
+
: tone === "waitlist"
|
|
194
|
+
? "text-muted-foreground"
|
|
195
|
+
: "text-accent"
|
|
94
196
|
)}
|
|
95
197
|
>
|
|
96
|
-
{
|
|
198
|
+
{fitDot}
|
|
199
|
+
{touch ? <span className="line-clamp-2 break-words">{meta}</span> : meta}
|
|
97
200
|
</span>
|
|
98
201
|
)}
|
|
99
202
|
</button>
|
|
@@ -22,6 +22,10 @@ type Story = StoryObj<typeof WeekdayChip>;
|
|
|
22
22
|
export const Default: Story = { args: { weekday: "Mon", day: "10" } };
|
|
23
23
|
export const Selected: Story = { args: { weekday: "Fri", day: "14", selected: true } };
|
|
24
24
|
export const Unavailable: Story = { args: { weekday: "Tue", day: "11", unavailable: true } };
|
|
25
|
+
/** The events map's week picker: the day's event count under the date. */
|
|
26
|
+
export const WithDetail: Story = {
|
|
27
|
+
args: { weekday: "Fri", day: "13", detail: "12", "aria-label": "Friday 13 November, 12 events" },
|
|
28
|
+
};
|
|
25
29
|
|
|
26
30
|
const WEEK = [
|
|
27
31
|
["Mon", "10"],
|
|
@@ -12,11 +12,19 @@ import { cn } from "../lib/utils";
|
|
|
12
12
|
* over the date, no count — and the rail shares the width equally.
|
|
13
13
|
*
|
|
14
14
|
* Measured from the booking design (`.dchip`): flexes to an equal share, 1px
|
|
15
|
-
* hairline, 11px radius, 7px vertical inset, centred. The weekday at
|
|
15
|
+
* hairline, 11px radius, 7px vertical inset, centred. The weekday at 11px
|
|
16
16
|
* uppercase, 0.05em tracking, muted; the date at 15px bold, tabular, 2px
|
|
17
17
|
* below. Selected fills with `--gradient-primary`, white type, the weekday at
|
|
18
18
|
* 75%. A day that cannot be booked (closed, past the window) fades to 40% and
|
|
19
19
|
* is disabled.
|
|
20
|
+
*
|
|
21
|
+
* The weekday was 10px, under the HIG's 11pt floor for iPhone text; measured
|
|
22
|
+
* when the events map took this chip for its week picker (bearmenu-fe,
|
|
23
|
+
* 2026-11-10). 11px still holds a three-letter weekday in a 46px seventh of a
|
|
24
|
+
* phone's width.
|
|
25
|
+
*
|
|
26
|
+
* `detail` is an optional third line under the date: the events map prints
|
|
27
|
+
* that day's event count there. A chip without it keeps its booking height.
|
|
20
28
|
*/
|
|
21
29
|
|
|
22
30
|
export type WeekdayChipProps = {
|
|
@@ -24,6 +32,9 @@ export type WeekdayChipProps = {
|
|
|
24
32
|
weekday: string;
|
|
25
33
|
/** Day of month as displayed — "14". */
|
|
26
34
|
day: string;
|
|
35
|
+
/** A short line under the date — "12", that day's events. Give the button
|
|
36
|
+
* an `aria-label` that says it with its unit; a bare number is unclear. */
|
|
37
|
+
detail?: string;
|
|
27
38
|
selected?: boolean;
|
|
28
39
|
/** Closed that day or outside the booking window. */
|
|
29
40
|
unavailable?: boolean;
|
|
@@ -36,6 +47,7 @@ export type WeekdayChipProps = {
|
|
|
36
47
|
export function WeekdayChip({
|
|
37
48
|
weekday,
|
|
38
49
|
day,
|
|
50
|
+
detail,
|
|
39
51
|
selected,
|
|
40
52
|
unavailable,
|
|
41
53
|
onSelect,
|
|
@@ -63,13 +75,24 @@ export function WeekdayChip({
|
|
|
63
75
|
>
|
|
64
76
|
<span
|
|
65
77
|
className={cn(
|
|
66
|
-
"block text-[
|
|
78
|
+
"block text-[11px] tracking-[0.05em] uppercase",
|
|
67
79
|
selected ? "text-white/75" : "text-muted-foreground"
|
|
68
80
|
)}
|
|
69
81
|
>
|
|
70
82
|
{weekday}
|
|
71
83
|
</span>
|
|
72
84
|
<span className="mt-0.5 block text-[15px] font-bold tabular-nums">{day}</span>
|
|
85
|
+
{detail !== undefined && (
|
|
86
|
+
<span
|
|
87
|
+
data-slot="weekday-chip-detail"
|
|
88
|
+
className={cn(
|
|
89
|
+
"mt-0.5 block font-mono text-[11px] tabular-nums",
|
|
90
|
+
selected ? "text-white/85" : "text-muted-foreground"
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
{detail}
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
73
96
|
</button>
|
|
74
97
|
);
|
|
75
98
|
}
|