@bearmenu/ui 0.8.58 → 0.8.60

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.
@@ -1,5 +1,5 @@
1
- import { DIMENSION_ORDER, VenueGradeDimensions } from '../chunk-ZQ53AGML.js';
2
1
  import { VenueQuotes } from '../chunk-43RC7MHY.js';
2
+ import { DIMENSION_ORDER, VenueGradeDimensions } from '../chunk-ZQ53AGML.js';
3
3
  import { StoryProvider, StorySegments, StoryPanel } from '../chunk-F6FMZJDG.js';
4
4
  import { GlassGround } from '../chunk-ICOU5QHT.js';
5
5
  import { Separator } from '../chunk-NWZGSLPU.js';
@@ -1,6 +1,6 @@
1
+ import { OVERLAY_PILL } from '../chunk-EZG2NELX.js';
1
2
  import { HOVER_IMAGE_MOTION, HOVER_IMAGE_WRAPPER, HOVER_MOTION, HOVER_LIFT_GRID_GROUP } from '../chunk-XCB3F6SF.js';
2
3
  import { NEUTRAL_ICON_FILTER } from '../chunk-LQWGZIYA.js';
3
- import { OVERLAY_PILL } from '../chunk-EZG2NELX.js';
4
4
  import { GradeAtom } from '../chunk-QOXXIYDG.js';
5
5
  import { showsGradeAtom } from '../chunk-TDTLEDX6.js';
6
6
  import { Separator } from '../chunk-NWZGSLPU.js';
@@ -0,0 +1,28 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import * as React from 'react';
3
+
4
+ /**
5
+ * TimeSlotRailGroup — one service period ("Lunch", "Dinner") inside a
6
+ * `TimeSlotRail`. The label sticks to the start edge while the group's chips
7
+ * scroll under it, so the period stays readable when the rail centres a chip.
8
+ *
9
+ * The rail detects groups in the DOM (`data-slot="time-slot-rail-group"`) and
10
+ * reserves room for the sticky label; there is nothing to wire up.
11
+ *
12
+ * Chips stay equal height (`items-stretch`). The accessible name comes from
13
+ * `label` when it is a string; any other node needs an explicit `aria-label`.
14
+ */
15
+ type TimeSlotRailGroupBaseProps = {
16
+ children: React.ReactNode;
17
+ className?: string;
18
+ };
19
+ type TimeSlotRailGroupProps = TimeSlotRailGroupBaseProps & ({
20
+ label: string;
21
+ "aria-label"?: string;
22
+ } | {
23
+ label: React.ReactNode;
24
+ "aria-label": string;
25
+ });
26
+ declare function TimeSlotRailGroup({ label, "aria-label": ariaLabel, children, className, }: TimeSlotRailGroupProps): react_jsx_runtime.JSX.Element;
27
+
28
+ export { TimeSlotRailGroup, type TimeSlotRailGroupProps };
@@ -0,0 +1,33 @@
1
+ import { cn } from '../chunk-FEK5XGZW.js';
2
+ import '../chunk-MMUBH76A.js';
3
+ import { jsxs, jsx } from 'react/jsx-runtime';
4
+
5
+ function TimeSlotRailGroup({
6
+ label,
7
+ "aria-label": ariaLabel,
8
+ children,
9
+ className
10
+ }) {
11
+ return /* @__PURE__ */ jsxs(
12
+ "div",
13
+ {
14
+ "data-slot": "time-slot-rail-group",
15
+ role: "group",
16
+ "aria-label": ariaLabel != null ? ariaLabel : typeof label === "string" ? label : void 0,
17
+ className: cn("flex shrink-0 items-stretch gap-2", className),
18
+ children: [
19
+ /* @__PURE__ */ jsx(
20
+ "span",
21
+ {
22
+ "aria-hidden": true,
23
+ className: "sticky left-0 z-10 max-w-20 self-center truncate bg-background pr-2 text-xs font-medium text-muted-foreground",
24
+ children: label
25
+ }
26
+ ),
27
+ children
28
+ ]
29
+ }
30
+ );
31
+ }
32
+
33
+ export { TimeSlotRailGroup };
@@ -9,6 +9,7 @@ import { ChevronLeft, ChevronRight } from 'lucide-react';
9
9
  import { jsxs, jsx } from 'react/jsx-runtime';
10
10
 
11
11
  var CHIP = '[data-slot="time-slot"]';
12
+ var GROUPS_FADE = "has-[[data-slot=time-slot-rail-group]]:[&>div:nth-child(2)]:left-20";
12
13
  function chipsOf(scroller) {
13
14
  return Array.from(scroller.querySelectorAll(CHIP)).filter((chip) => !chip.disabled);
14
15
  }
@@ -131,8 +132,8 @@ function TimeSlotRail({
131
132
  "aria-label": ariaLabel,
132
133
  onKeyDown,
133
134
  onFocus,
134
- className,
135
- scrollClassName: "flex snap-x snap-proximity scroll-px-10 gap-1.5 px-1.5 py-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&_[data-slot=time-slot]]:shrink-0",
135
+ className: cn(GROUPS_FADE, className),
136
+ scrollClassName: "flex snap-x snap-proximity scroll-px-10 has-[[data-slot=time-slot-rail-group]]:scroll-ps-20 gap-2 px-1.5 py-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&_[data-slot=time-slot]]:shrink-0",
136
137
  children
137
138
  }
138
139
  ),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.58",
3
+ "version": "0.8.60",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -0,0 +1,114 @@
1
+ import * as React from "react";
2
+ import { describe, it, expect, vi, afterEach } from "vitest";
3
+ import { render, screen, within } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import { TimeSlot } from "./time-slot";
6
+ import { TimeSlotRail } from "./time-slot-rail";
7
+ import { TimeSlotRailGroup } from "./time-slot-rail-group";
8
+
9
+ const LUNCH = ["12:00", "12:30"];
10
+ const DINNER = ["19:00", "19:30"];
11
+
12
+ function Chips({ times, selected, onSelect }: { times: string[]; selected: string; onSelect: (t: string) => void }) {
13
+ return (
14
+ <>
15
+ {times.map((time) => (
16
+ <TimeSlot
17
+ key={time}
18
+ size="touch"
19
+ time={time}
20
+ fit="fits"
21
+ meta="R1"
22
+ selected={selected === time}
23
+ onSelect={() => onSelect(time)}
24
+ />
25
+ ))}
26
+ </>
27
+ );
28
+ }
29
+
30
+ function GroupedRail() {
31
+ const [selected, setSelected] = React.useState("19:00");
32
+ return (
33
+ <TimeSlotRail aria-label="Start time">
34
+ <TimeSlotRailGroup label="Lunch">
35
+ <Chips times={LUNCH} selected={selected} onSelect={setSelected} />
36
+ </TimeSlotRailGroup>
37
+ <TimeSlotRailGroup label="Dinner">
38
+ <Chips times={DINNER} selected={selected} onSelect={setSelected} />
39
+ </TimeSlotRailGroup>
40
+ </TimeSlotRail>
41
+ );
42
+ }
43
+
44
+ function PlainRail() {
45
+ const [selected, setSelected] = React.useState("19:00");
46
+ return (
47
+ <TimeSlotRail aria-label="Start time">
48
+ <Chips times={[...LUNCH, ...DINNER]} selected={selected} onSelect={setSelected} />
49
+ </TimeSlotRail>
50
+ );
51
+ }
52
+
53
+ const chip = (time: string) => screen.getByRole("button", { name: new RegExp(time) });
54
+
55
+ afterEach(() => {
56
+ Reflect.deleteProperty(HTMLElement.prototype, "scrollTo");
57
+ });
58
+
59
+ describe("TimeSlotRailGroup", () => {
60
+ it("is a group named by its string label, and shows the label", () => {
61
+ render(<GroupedRail />);
62
+ expect(screen.getByRole("group", { name: "Lunch" })).toBeInTheDocument();
63
+ expect(screen.getByRole("group", { name: "Dinner" })).toBeInTheDocument();
64
+ expect(screen.getByText("Lunch")).toBeVisible();
65
+ });
66
+
67
+ it("takes an explicit aria-label for a non-string label", () => {
68
+ render(
69
+ <TimeSlotRail aria-label="Start time">
70
+ <TimeSlotRailGroup label={<em>Brunch</em>} aria-label="Brunch service">
71
+ <Chips times={LUNCH} selected="" onSelect={() => {}} />
72
+ </TimeSlotRailGroup>
73
+ </TimeSlotRail>,
74
+ );
75
+ expect(screen.getByRole("group", { name: "Brunch service" })).toBeInTheDocument();
76
+ });
77
+
78
+ it("keeps each chip inside its own group", () => {
79
+ render(<GroupedRail />);
80
+ const lunch = screen.getByRole("group", { name: "Lunch" });
81
+ const dinner = screen.getByRole("group", { name: "Dinner" });
82
+ expect(within(lunch).getAllByRole("button")).toHaveLength(2);
83
+ expect(within(lunch).getByRole("button", { name: /12:30/ })).toBeInTheDocument();
84
+ expect(within(lunch).queryByRole("button", { name: /19:00/ })).toBeNull();
85
+ expect(within(dinner).getByRole("button", { name: /19:30/ })).toBeInTheDocument();
86
+ });
87
+
88
+ it("still centres the selected chip, and keyboard moves across groups", async () => {
89
+ const user = userEvent.setup();
90
+ const scrollTo = vi.fn();
91
+ Object.defineProperty(HTMLElement.prototype, "scrollTo", { configurable: true, value: scrollTo });
92
+ render(<GroupedRail />);
93
+ expect(scrollTo).toHaveBeenCalledTimes(1);
94
+ expect(scrollTo).toHaveBeenLastCalledWith(expect.objectContaining({ behavior: "auto" }));
95
+ await user.click(chip("12:30"));
96
+ expect(scrollTo).toHaveBeenLastCalledWith(expect.objectContaining({ behavior: "smooth" }));
97
+ await user.keyboard("{ArrowRight}");
98
+ expect(chip("19:00")).toHaveFocus();
99
+ });
100
+
101
+ it("reserves room for the sticky label only when groups are present", () => {
102
+ const { unmount } = render(<GroupedRail />);
103
+ const scroller = screen.getByRole("group", { name: "Start time" });
104
+ expect(scroller.className).toContain("has-[[data-slot=time-slot-rail-group]]:scroll-ps-20");
105
+ expect(scroller.parentElement?.className).toContain("has-[[data-slot=time-slot-rail-group]]:[&>div:nth-child(2)]:left-20");
106
+ expect(screen.getByText("Lunch")).toHaveClass("sticky", "left-0", "bg-background");
107
+ unmount();
108
+
109
+ render(<PlainRail />);
110
+ expect(document.querySelector('[data-slot="time-slot-rail-group"]')).toBeNull();
111
+ expect(screen.getAllByRole("group")).toHaveLength(1);
112
+ expect(chip("19:00")).toHaveAttribute("aria-pressed", "true");
113
+ });
114
+ });
@@ -0,0 +1,49 @@
1
+ import * as React from "react";
2
+ import { cn } from "../lib/utils";
3
+
4
+ /**
5
+ * TimeSlotRailGroup — one service period ("Lunch", "Dinner") inside a
6
+ * `TimeSlotRail`. The label sticks to the start edge while the group's chips
7
+ * scroll under it, so the period stays readable when the rail centres a chip.
8
+ *
9
+ * The rail detects groups in the DOM (`data-slot="time-slot-rail-group"`) and
10
+ * reserves room for the sticky label; there is nothing to wire up.
11
+ *
12
+ * Chips stay equal height (`items-stretch`). The accessible name comes from
13
+ * `label` when it is a string; any other node needs an explicit `aria-label`.
14
+ */
15
+
16
+ type TimeSlotRailGroupBaseProps = {
17
+ children: React.ReactNode;
18
+ className?: string;
19
+ };
20
+
21
+ export type TimeSlotRailGroupProps = TimeSlotRailGroupBaseProps &
22
+ (
23
+ | { label: string; "aria-label"?: string }
24
+ | { label: React.ReactNode; "aria-label": string }
25
+ );
26
+
27
+ export function TimeSlotRailGroup({
28
+ label,
29
+ "aria-label": ariaLabel,
30
+ children,
31
+ className,
32
+ }: TimeSlotRailGroupProps) {
33
+ return (
34
+ <div
35
+ data-slot="time-slot-rail-group"
36
+ role="group"
37
+ aria-label={ariaLabel ?? (typeof label === "string" ? label : undefined)}
38
+ className={cn("flex shrink-0 items-stretch gap-2", className)}
39
+ >
40
+ <span
41
+ aria-hidden
42
+ className="sticky left-0 z-10 max-w-20 self-center truncate bg-background pr-2 text-xs font-medium text-muted-foreground"
43
+ >
44
+ {label}
45
+ </span>
46
+ {children}
47
+ </div>
48
+ );
49
+ }
@@ -4,6 +4,7 @@ import { Separator } from "./separator";
4
4
  import { Skeleton } from "./skeleton";
5
5
  import { TimeSlot, type TimeSlotFit } from "./time-slot";
6
6
  import { TimeSlotRail } from "./time-slot-rail";
7
+ import { TimeSlotRailGroup } from "./time-slot-rail-group";
7
8
 
8
9
  const meta: Meta<typeof TimeSlotRail> = {
9
10
  title: "Booking/TimeSlotRail",
@@ -138,11 +139,11 @@ export const Periods: Story = {
138
139
  return (
139
140
  <Inset>
140
141
  <TimeSlotRail aria-label="Start time on Wed 18 Nov" scrollLabels={SCROLL_LABELS}>
141
- <div role="group" aria-label="Lunch" className="flex gap-1.5">
142
+ <div role="group" aria-label="Lunch" className="flex gap-2">
142
143
  {chips(lunch)}
143
144
  </div>
144
145
  <Separator orientation="vertical" className="mx-1 h-8 self-center" />
145
- <div role="group" aria-label="Dinner" className="flex gap-1.5">
146
+ <div role="group" aria-label="Dinner" className="flex gap-2">
146
147
  {chips(dinner)}
147
148
  </div>
148
149
  </TimeSlotRail>
@@ -151,11 +152,39 @@ export const Periods: Story = {
151
152
  },
152
153
  };
153
154
 
155
+ /** Labelled period groups: the label sticks to the start edge while chips scroll under it. */
156
+ export const Groups: Story = {
157
+ render: () => {
158
+ const [selected, setSelected] = React.useState("20:00");
159
+ const chips = (slots: Slot[]) =>
160
+ slots.map((slot) => (
161
+ <TimeSlot
162
+ key={slot.time}
163
+ size="touch"
164
+ time={slot.time}
165
+ fit={slot.fit}
166
+ meta={slot.meta}
167
+ selected={selected === slot.time}
168
+ onSelect={() => setSelected(slot.time)}
169
+ className="w-[84px] shrink-0 snap-center"
170
+ />
171
+ ));
172
+ return (
173
+ <Inset>
174
+ <TimeSlotRail aria-label="Start time on Wed 18 Nov" scrollLabels={SCROLL_LABELS}>
175
+ <TimeSlotRailGroup label="Lunch">{chips(SLOTS.slice(0, 4))}</TimeSlotRailGroup>
176
+ <TimeSlotRailGroup label="Dinner">{chips(SLOTS.slice(4))}</TimeSlotRailGroup>
177
+ </TimeSlotRail>
178
+ </Inset>
179
+ );
180
+ },
181
+ };
182
+
154
183
  /** Loading is the caller's: skeletons of the same footprint, so the inset does not jump. */
155
184
  export const Loading: Story = {
156
185
  render: () => (
157
186
  <Inset>
158
- <div role="status" className="flex gap-1.5 overflow-hidden">
187
+ <div role="status" className="flex gap-2 overflow-hidden">
159
188
  <span className="sr-only">Checking tables for 12…</span>
160
189
  {Array.from({ length: 5 }, (_, i) => (
161
190
  <Skeleton key={i} aria-hidden className="h-[52px] w-[84px] shrink-0 rounded-xl" />
@@ -31,6 +31,11 @@ import { ScrollFade } from "./scroll-fade";
31
31
 
32
32
  const CHIP = '[data-slot="time-slot"]';
33
33
 
34
+ // With period groups, the start fade (the 2nd div of ScrollFade) begins after
35
+ // the 80px sticky label instead of covering it. The scroller reserves the same
36
+ // 80px as scroll-padding-start, so a focused chip never lands under the label.
37
+ const GROUPS_FADE = "has-[[data-slot=time-slot-rail-group]]:[&>div:nth-child(2)]:left-20";
38
+
34
39
  export type TimeSlotRailScrollLabels = {
35
40
  /** "Earlier times" — the chevron at the start edge. */
36
41
  previous: string;
@@ -186,8 +191,8 @@ export function TimeSlotRail({
186
191
  aria-label={ariaLabel}
187
192
  onKeyDown={onKeyDown}
188
193
  onFocus={onFocus}
189
- className={className}
190
- scrollClassName="flex snap-x snap-proximity scroll-px-10 gap-1.5 px-1.5 py-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&_[data-slot=time-slot]]:shrink-0"
194
+ className={cn(GROUPS_FADE, className)}
195
+ scrollClassName="flex snap-x snap-proximity scroll-px-10 has-[[data-slot=time-slot-rail-group]]:scroll-ps-20 gap-2 px-1.5 py-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&_[data-slot=time-slot]]:shrink-0"
191
196
  >
192
197
  {children}
193
198
  </ScrollFade>