@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.
@@ -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
- export type TimeSlotProps = {
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
- /** Full accessible name, when the visible text is not enough ("19:30, popular"). */
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
- "flex w-full cursor-pointer flex-col items-center justify-start rounded-[11px] border px-1 py-2 text-center leading-[1.2]",
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
- "transition-[border-color,filter,transform] duration-150 not-disabled:active:scale-[0.98] motion-reduce:transition-none",
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
- ? "border-transparent bg-[image:var(--gradient-primary)] text-primary-foreground hover:brightness-[0.94]"
73
- : "border-border bg-card text-foreground hover:border-accent/50",
74
- unavailable && "cursor-not-allowed opacity-[0.32] hover:border-border"
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 text-[14.5px] leading-none font-bold tabular-nums",
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
- "mt-[3px] block text-[9.5px] leading-[1.2]",
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
- ? "text-primary-foreground/80"
91
- : tone === "waitlist"
92
- ? "text-muted-foreground"
93
- : "text-accent"
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
- {meta}
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 10px
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-[10px] tracking-[0.05em] uppercase",
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
  }