@bearmenu/ui 0.8.6 → 0.8.8

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.
Files changed (88) hide show
  1. package/dist/{chunk-637L7XQ3.js → chunk-3TNPSN4D.js} +28 -9
  2. package/dist/chunk-AHL45DD2.js +145 -0
  3. package/dist/chunk-IYDH5IU6.js +66 -0
  4. package/dist/chunk-OPVKL4VS.js +18 -0
  5. package/dist/components/alert.d.ts +2 -2
  6. package/dist/components/badge.d.ts +1 -1
  7. package/dist/components/button.d.ts +1 -1
  8. package/dist/components/card.d.ts +2 -2
  9. package/dist/components/category-deck.js +10 -1
  10. package/dist/components/command.d.ts +15 -15
  11. package/dist/components/coverflow.d.ts +11 -3
  12. package/dist/components/coverflow.js +34 -51
  13. package/dist/components/drift-rail.d.ts +24 -10
  14. package/dist/components/drift-rail.js +1 -1
  15. package/dist/components/event-card.d.ts +8 -0
  16. package/dist/components/event-card.js +68 -35
  17. package/dist/components/event-compact-row.d.ts +34 -6
  18. package/dist/components/event-compact-row.js +120 -38
  19. package/dist/components/event-day-header.js +3 -1
  20. package/dist/components/event-day-separator.d.ts +7 -1
  21. package/dist/components/event-day-separator.js +3 -2
  22. package/dist/components/event-hub-hero.d.ts +25 -4
  23. package/dist/components/event-hub-hero.js +89 -19
  24. package/dist/components/event-list-row.d.ts +19 -6
  25. package/dist/components/event-list-row.js +78 -34
  26. package/dist/components/event-types.d.ts +5 -1
  27. package/dist/components/hour-band.d.ts +38 -40
  28. package/dist/components/hour-band.js +55 -27
  29. package/dist/components/hour-group.js +1 -1
  30. package/dist/components/item.d.ts +2 -2
  31. package/dist/components/poster-tape.js +1 -1
  32. package/dist/components/poster-wall.d.ts +3 -3
  33. package/dist/components/poster-wall.js +73 -17
  34. package/dist/components/section-header.d.ts +16 -1
  35. package/dist/components/section-header.js +26 -7
  36. package/dist/components/snap-rail.d.ts +34 -0
  37. package/dist/components/snap-rail.js +51 -0
  38. package/dist/components/spinner.d.ts +2 -2
  39. package/dist/components/tag.d.ts +2 -2
  40. package/dist/components/text.d.ts +3 -3
  41. package/dist/components/timeline-band.d.ts +20 -2
  42. package/dist/components/timeline-band.js +1 -1
  43. package/dist/components/timeline-playhead.d.ts +26 -0
  44. package/dist/components/timeline-playhead.js +25 -0
  45. package/dist/components/weekend-panel.d.ts +5 -4
  46. package/dist/components/weekend-panel.js +85 -71
  47. package/dist/hooks/use-drag-swipe.d.ts +44 -0
  48. package/dist/hooks/use-drag-swipe.js +2 -0
  49. package/dist/hooks/use-media-query.d.ts +17 -0
  50. package/dist/hooks/use-media-query.js +2 -0
  51. package/package.json +1 -1
  52. package/src/components/category-deck.tsx +19 -8
  53. package/src/components/coverflow.test.tsx +28 -1
  54. package/src/components/coverflow.tsx +37 -43
  55. package/src/components/drift-rail.tsx +167 -42
  56. package/src/components/event-card.test.tsx +11 -0
  57. package/src/components/event-card.tsx +67 -14
  58. package/src/components/event-compact-row.stories.tsx +30 -0
  59. package/src/components/event-compact-row.test.tsx +100 -0
  60. package/src/components/event-compact-row.tsx +166 -40
  61. package/src/components/event-day-header.tsx +3 -1
  62. package/src/components/event-day-separator.tsx +8 -1
  63. package/src/components/event-hub-hero.stories.tsx +30 -0
  64. package/src/components/event-hub-hero.tsx +102 -30
  65. package/src/components/event-list-row.stories.tsx +34 -0
  66. package/src/components/event-list-row.test.tsx +53 -0
  67. package/src/components/event-list-row.tsx +135 -87
  68. package/src/components/event-types.ts +5 -1
  69. package/src/components/hour-band.stories.tsx +28 -5
  70. package/src/components/hour-band.tsx +90 -70
  71. package/src/components/poster-wall.test.tsx +28 -0
  72. package/src/components/poster-wall.tsx +96 -23
  73. package/src/components/section-header.stories.tsx +24 -0
  74. package/src/components/section-header.test.tsx +28 -0
  75. package/src/components/section-header.tsx +45 -7
  76. package/src/components/snap-rail.stories.tsx +79 -0
  77. package/src/components/snap-rail.test.tsx +18 -0
  78. package/src/components/snap-rail.tsx +67 -0
  79. package/src/components/timeline-band.tsx +52 -12
  80. package/src/components/timeline-playhead.stories.tsx +30 -0
  81. package/src/components/timeline-playhead.test.tsx +11 -0
  82. package/src/components/timeline-playhead.tsx +46 -0
  83. package/src/components/weekend-panel.test.tsx +43 -0
  84. package/src/components/weekend-panel.tsx +142 -108
  85. package/src/hooks/use-drag-swipe.test.tsx +66 -0
  86. package/src/hooks/use-drag-swipe.ts +105 -0
  87. package/src/hooks/use-media-query.ts +31 -0
  88. package/dist/chunk-GPH6L4DX.js +0 -55
@@ -0,0 +1,79 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { SnapRail } from "./snap-rail";
3
+
4
+ const meta: Meta<typeof SnapRail> = {
5
+ title: "Primitives/SnapRail",
6
+ component: SnapRail,
7
+ tags: ["autodocs"],
8
+ decorators: [
9
+ (Story) => (
10
+ <div className="w-[390px] bg-surface-2 p-3">
11
+ <div className="rounded-[18px] bg-card px-3.5 py-4">
12
+ <Story />
13
+ </div>
14
+ </div>
15
+ ),
16
+ ],
17
+ parameters: {
18
+ docs: {
19
+ description: {
20
+ component:
21
+ "A swipe-driven rail with mandatory snap points. Direct children become the snap targets and take the rail's item width; `bleed` lets the rail run under its host's padding while still snapping to the host's content edge.",
22
+ },
23
+ },
24
+ },
25
+ };
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof SnapRail>;
29
+
30
+ function Poster({ label }: { label: string }) {
31
+ return (
32
+ <div className="flex flex-col gap-2">
33
+ <div className="aspect-[4/3] rounded-xl bg-muted" />
34
+ <div className="text-[13.5px] font-bold">{label}</div>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ const cards = ["Psihotrop Live", "Jazz in the Yard", "Quiz Night", "Techno Basement", "Vinyl Hour"];
40
+
41
+ /** 46% items: two cards in view and a third peeking. The category-deck width. */
42
+ export const Small: Story = {
43
+ args: {
44
+ itemWidth: "sm",
45
+ bleed: "card",
46
+ children: cards.map((c) => <Poster key={c} label={c} />),
47
+ },
48
+ };
49
+
50
+ /** 60% items: one card and a strong peek. The featured-rail width. */
51
+ export const Medium: Story = {
52
+ args: {
53
+ itemWidth: "md",
54
+ bleed: "card",
55
+ children: cards.map((c) => <Poster key={c} label={c} />),
56
+ },
57
+ };
58
+
59
+ /** 80% items: one card at a time with a sliver of the next. */
60
+ export const Large: Story = {
61
+ args: {
62
+ itemWidth: "lg",
63
+ bleed: "card",
64
+ children: cards.map((c) => <Poster key={c} label={c} />),
65
+ },
66
+ };
67
+
68
+ /**
69
+ * Without `bleed` the first card snaps flush to the host's padding edge and
70
+ * the rail's clip sits inside the card's rounded corner. Kept to show the
71
+ * difference; every hub card wants `bleed="card"`.
72
+ */
73
+ export const NoBleed: Story = {
74
+ args: {
75
+ itemWidth: "sm",
76
+ bleed: "none",
77
+ children: cards.map((c) => <Poster key={c} label={c} />),
78
+ },
79
+ };
@@ -0,0 +1,18 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import { SnapRail } from "./snap-rail";
4
+
5
+ describe("SnapRail", () => {
6
+ it("applies the item width and bleed variants to the rail", () => {
7
+ const { container } = render(
8
+ <SnapRail itemWidth="md" bleed="card" data-testid="rail">
9
+ <div>a</div>
10
+ <div>b</div>
11
+ </SnapRail>
12
+ );
13
+ const rail = container.firstElementChild as HTMLElement;
14
+ expect(rail.className).toContain("snap-mandatory");
15
+ expect(rail.className).toContain("[&>*]:w-[60%]");
16
+ expect(rail.className).toContain("scroll-px-3.5");
17
+ });
18
+ });
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cva, type VariantProps } from "class-variance-authority";
5
+ import { cn } from "../lib/utils";
6
+
7
+ /**
8
+ * SnapRail — a horizontal, finger-driven rail with mandatory snap points, the
9
+ * way a native app lays out a row of cards. Not `DriftRail`: that one moves on
10
+ * its own and is for ambience; this one waits to be swiped and is for content
11
+ * the reader is expected to reach.
12
+ *
13
+ * Every direct child becomes a snap target and takes the rail's item width, so
14
+ * callers pass bare cards — no wrapper, no per-item width. Widths are
15
+ * percentages of the rail, so the peek of the next card is what tells the
16
+ * reader there is more; there is no scrollbar and no edge fade.
17
+ *
18
+ * `bleed` cancels the host's horizontal padding and restores it as scroll
19
+ * padding, so the first card lines up with the host's content edge and the
20
+ * rail still snaps to that same edge after a swipe. Without it, the rail
21
+ * snaps flush to the host's rounded corner and loads looking pre-scrolled.
22
+ * `card` is the 14px inset of a hub card; `page` is the 16px page gutter.
23
+ */
24
+ const snapRailVariants = cva(
25
+ cn(
26
+ "flex snap-x snap-mandatory overflow-x-auto pb-0.5 scrollbar-hide",
27
+ "[&>*]:shrink-0 [&>*]:snap-start"
28
+ ),
29
+ {
30
+ variants: {
31
+ itemWidth: {
32
+ sm: "[&>*]:w-[46%]",
33
+ md: "[&>*]:w-[60%]",
34
+ lg: "[&>*]:w-[80%]",
35
+ },
36
+ gap: {
37
+ sm: "gap-2",
38
+ default: "gap-3",
39
+ },
40
+ bleed: {
41
+ none: "",
42
+ card: "-mx-3.5 px-3.5 scroll-px-3.5",
43
+ page: "-mx-4 px-4 scroll-px-4",
44
+ },
45
+ },
46
+ defaultVariants: {
47
+ itemWidth: "sm",
48
+ gap: "default",
49
+ bleed: "none",
50
+ },
51
+ }
52
+ );
53
+
54
+ export type SnapRailProps = React.HTMLAttributes<HTMLDivElement> &
55
+ VariantProps<typeof snapRailVariants>;
56
+
57
+ export const SnapRail = React.forwardRef<HTMLDivElement, SnapRailProps>(
58
+ ({ className, itemWidth, gap, bleed, ...props }, ref) => (
59
+ <div
60
+ ref={ref}
61
+ data-slot="snap-rail"
62
+ className={cn(snapRailVariants({ itemWidth, gap, bleed }), className)}
63
+ {...props}
64
+ />
65
+ )
66
+ );
67
+ SnapRail.displayName = "SnapRail";
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
+ import { Radio } from "lucide-react";
4
5
  import { cn } from "../lib/utils";
5
6
  import { Separator } from "./separator";
6
7
  import { Skeleton } from "./skeleton";
@@ -45,14 +46,23 @@ import { Skeleton } from "./skeleton";
45
46
  */
46
47
 
47
48
  /**
48
- * Ring pulse on the live puck — inherited from the deleted `TimelinePlayhead`,
49
+ * Ring pulse on the live puck — inherited from an earlier `TimelinePlayhead`,
49
50
  * which carried this surface's only motion. Kept at its 2.4s `ease-in-out` and
50
- * its reduced-motion kill switch, but BOX-SHADOW ONLY: the playhead's
51
- * `transform: scale` would make the hairline visibly breathe here, because
52
- * unlike the playhead this puck sits ON the rail.
51
+ * its reduced-motion kill switch, but BOX-SHADOW ONLY: a `transform: scale`
52
+ * would make the hairline visibly breathe here, because this puck sits ON the
53
+ * rail. (Today's `timeline-playhead.tsx` is static and sits between bands.)
53
54
  */
54
55
  const LIVE_PUCK_PULSE_CLASS = "bm-timeline-live-puck-pulse";
55
56
 
57
+ /**
58
+ * The mobile axis gutter, shared by `HourBand` and `TimelinePlayhead` so the
59
+ * hour chips and the current-time label sit in one column. 58px is the width
60
+ * of a five-glyph 24-hour label ("20:00") in the chip's mono type plus its
61
+ * padding; a 12-hour locale's "09:00 AM" overflows it, which is one of the
62
+ * reasons the events surfaces format 24-hour everywhere.
63
+ */
64
+ export const TIMELINE_MOBILE_GUTTER_CLASS = "w-[58px]";
65
+
56
66
  export type TimelineHourPuckProps = {
57
67
  /** The hour, already formatted and localised — or an em dash for a quiet day. */
58
68
  time: string;
@@ -60,6 +70,16 @@ export type TimelineHourPuckProps = {
60
70
  isLive?: boolean;
61
71
  /** Shown under the puck when live, e.g. "NOW ON". Required to render it. */
62
72
  liveLabel?: string;
73
+ /**
74
+ * `station` (default): the bordered puck that sits ON the hairline, for the
75
+ * desktop spine. `chip`: a filled, fixed-width lozenge with the hairline
76
+ * starting BELOW it, for the mobile gutter — an app's time label, not a
77
+ * station on a rail. In `chip` the live label is visually hidden: the tinted
78
+ * chip plus a small on-air glyph are the visible signal (the glyph so the
79
+ * state never rests on colour alone once the pulse is reduced away) and the
80
+ * label stays for screen readers only.
81
+ */
82
+ variant?: "station" | "chip";
63
83
  className?: string;
64
84
  };
65
85
 
@@ -78,7 +98,15 @@ export type TimelineHourPuckProps = {
78
98
  * centre this puck with `-translate-y-1/2`, and a two-item column would centre
79
99
  * the STACK, lifting the puck off the line it exists to sit on.
80
100
  */
81
- export function TimelineHourPuck({ time, isLive, liveLabel, className }: TimelineHourPuckProps) {
101
+ export function TimelineHourPuck({
102
+ time,
103
+ isLive,
104
+ liveLabel,
105
+ variant = "station",
106
+ className,
107
+ }: TimelineHourPuckProps) {
108
+ const isChip = variant === "chip";
109
+
82
110
  return (
83
111
  <span className={cn("relative flex justify-center", className)}>
84
112
  {isLive && (
@@ -97,20 +125,32 @@ export function TimelineHourPuck({ time, isLive, liveLabel, className }: Timelin
97
125
  )}
98
126
  <span
99
127
  className={cn(
100
- "whitespace-nowrap rounded-[7px] border bg-background px-[7px] py-[3px]",
101
- "font-mono text-[12.5px] font-medium tabular-nums",
102
- isLive
103
- ? cn("border-success/40 text-success", LIVE_PUCK_PULSE_CLASS)
104
- : "border-border text-foreground"
128
+ "whitespace-nowrap font-mono tabular-nums",
129
+ isChip
130
+ ? cn(
131
+ "inline-flex h-6 min-w-[58px] items-center justify-center gap-1 rounded-[7px] px-1 text-[12px] font-medium",
132
+ isLive ? cn("bg-success/10 text-success", LIVE_PUCK_PULSE_CLASS) : "bg-muted text-foreground"
133
+ )
134
+ : cn(
135
+ "rounded-[7px] border bg-background px-[7px] py-[3px] text-[12.5px] font-medium",
136
+ isLive
137
+ ? cn("border-success/40 text-success", LIVE_PUCK_PULSE_CLASS)
138
+ : "border-border text-foreground"
139
+ )
105
140
  )}
106
141
  >
142
+ {isChip && isLive && <Radio className="h-2.5 w-2.5 shrink-0" aria-hidden />}
107
143
  {time}
108
144
  </span>
109
145
  {isLive && !!liveLabel && (
110
146
  <span
111
147
  className={cn(
112
- "absolute top-[calc(100%+6px)] whitespace-nowrap bg-background px-1",
113
- "text-[9.5px] font-extrabold tracking-[0.08em] text-success"
148
+ isChip
149
+ ? "sr-only"
150
+ : cn(
151
+ "absolute top-[calc(100%+6px)] whitespace-nowrap bg-background px-1",
152
+ "text-[9.5px] font-extrabold tracking-[0.08em] text-success"
153
+ )
114
154
  )}
115
155
  >
116
156
  {liveLabel}
@@ -0,0 +1,30 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { TimelinePlayhead } from "./timeline-playhead";
3
+
4
+ const meta: Meta<typeof TimelinePlayhead> = {
5
+ title: "Events/TimelinePlayhead",
6
+ component: TimelinePlayhead,
7
+ tags: ["autodocs"],
8
+ decorators: [
9
+ (Story) => (
10
+ <div className="w-[390px] bg-background px-4">
11
+ <Story />
12
+ </div>
13
+ ),
14
+ ],
15
+ parameters: {
16
+ docs: {
17
+ description: {
18
+ component:
19
+ "The current-time mark on the mobile timeline. Rendered between hour bands where the wall clock falls; its time label shares the gutter with `HourBand` so it aligns under the hour chips.",
20
+ },
21
+ },
22
+ },
23
+ };
24
+
25
+ export default meta;
26
+ type Story = StoryObj<typeof TimelinePlayhead>;
27
+
28
+ export const Default: Story = {
29
+ args: { time: "21:38", label: "NOW" },
30
+ };
@@ -0,0 +1,11 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { TimelinePlayhead } from "./timeline-playhead";
4
+
5
+ describe("TimelinePlayhead", () => {
6
+ it("prints the time and the label, both as given", () => {
7
+ render(<TimelinePlayhead time="21:38" label="NOW" />);
8
+ expect(screen.getByText("21:38")).toBeInTheDocument();
9
+ expect(screen.getByText("NOW")).toBeInTheDocument();
10
+ });
11
+ });
@@ -0,0 +1,46 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+ import { TIMELINE_MOBILE_GUTTER_CLASS } from "./timeline-band";
6
+
7
+ /**
8
+ * TimelinePlayhead — the "you are here" mark on the mobile timeline: the
9
+ * current time in the axis gutter, a dashed rule across the content column,
10
+ * and a filled NOW pill at the far edge.
11
+ *
12
+ * It sits BETWEEN hour bands, at the point where the wall clock falls, so the
13
+ * reader can see at a glance which hours are behind them and which are ahead.
14
+ * The live hour band's tinted chip says "this hour is running"; the playhead
15
+ * says exactly how far into it we are. Both are needed: a band spans an hour
16
+ * and the hour is mostly not "now".
17
+ *
18
+ * The time label shares the gutter width with `HourBand` so it lines up under
19
+ * the hour chips. Both strings arrive formatted — the DS holds no clock.
20
+ */
21
+ export type TimelinePlayheadProps = {
22
+ /** The current time, already formatted — "21:38". */
23
+ time: string;
24
+ /** The pill text, e.g. "NOW". */
25
+ label: string;
26
+ className?: string;
27
+ };
28
+
29
+ export function TimelinePlayhead({ time, label, className }: TimelinePlayheadProps) {
30
+ return (
31
+ <div className={cn("flex items-center gap-2.5 pt-3", className)}>
32
+ <span
33
+ className={cn(
34
+ TIMELINE_MOBILE_GUTTER_CLASS,
35
+ "shrink-0 text-center font-mono text-[11px] font-bold tabular-nums text-success"
36
+ )}
37
+ >
38
+ {time}
39
+ </span>
40
+ <span aria-hidden className="h-0 flex-1 border-t-[1.5px] border-dashed border-success/50" />
41
+ <span className="rounded-full bg-success px-2 py-0.5 text-[9.5px] font-extrabold tracking-[0.1em] text-success-foreground">
42
+ {label}
43
+ </span>
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
+ import { WeekendPanel } from "./weekend-panel";
4
+
5
+ const labels = { pause: "Pause", play: "Play" };
6
+
7
+ function renderPanel() {
8
+ return render(
9
+ <WeekendPanel dayLabel="Saturday" countLabel="4 events" actionLabel="See all" labels={labels}>
10
+ <a href="/a">A</a>
11
+ <a href="/b">B</a>
12
+ <a href="/c">C</a>
13
+ <a href="/d">D</a>
14
+ </WeekendPanel>
15
+ );
16
+ }
17
+
18
+ const track = () => screen.getByText("A").parentElement!.parentElement as HTMLElement;
19
+
20
+ describe("WeekendPanel", () => {
21
+ it("has no autoplay control below the desktop breakpoint", () => {
22
+ renderPanel();
23
+ expect(screen.queryByRole("button", { name: "Pause" })).not.toBeInTheDocument();
24
+ });
25
+
26
+ it("starts on the first page and turns on a leftward drag", () => {
27
+ renderPanel();
28
+ expect(track().style.transform).toContain("calc(0 *");
29
+
30
+ const stage = track().parentElement as HTMLElement;
31
+ fireEvent.pointerDown(stage, { clientX: 200, pointerId: 1, button: 0 });
32
+ fireEvent.pointerMove(stage, { clientX: 150, pointerId: 1 });
33
+ fireEvent.pointerMove(stage, { clientX: 100, pointerId: 1 });
34
+ fireEvent.pointerUp(stage, { clientX: 100, pointerId: 1 });
35
+ expect(track().style.transform).toContain("calc(-1 *");
36
+ });
37
+
38
+ it("turns to the page of a card that receives focus", () => {
39
+ renderPanel();
40
+ fireEvent.focus(screen.getByText("D"));
41
+ expect(track().style.transform).toContain("calc(-1 *");
42
+ });
43
+ });