@bearmenu/ui 0.8.16 → 0.8.18

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 (151) hide show
  1. package/dist/{chunk-WYTJR2YU.js → chunk-5CPBSNWQ.js} +6 -1
  2. package/dist/{chunk-36KEHCET.js → chunk-5LTILG3L.js} +15 -4
  3. package/dist/{chunk-P43B3ONY.js → chunk-5XREWOBB.js} +3 -1
  4. package/dist/{chunk-CLLY6XB3.js → chunk-6VOWT4YR.js} +6 -3
  5. package/dist/{chunk-S33K4RC5.js → chunk-BKKQ7GCZ.js} +1 -1
  6. package/dist/{chunk-ZLPWEHZO.js → chunk-EQYB4Q77.js} +2 -2
  7. package/dist/chunk-LYREUZH6.js +156 -0
  8. package/dist/chunk-PW247TG2.js +81 -0
  9. package/dist/chunk-QY66ME5B.js +207 -0
  10. package/dist/{chunk-T6S2ZBV6.js → chunk-SDWN6L4O.js} +1 -1
  11. package/dist/{chunk-CNJQUHCU.js → chunk-SWD6CJ35.js} +2 -1
  12. package/dist/{chunk-HDR7V6IZ.js → chunk-TIQNM4XD.js} +8 -1
  13. package/dist/components/alert-dialog.js +1 -1
  14. package/dist/components/alert.d.ts +2 -2
  15. package/dist/components/badge.d.ts +1 -1
  16. package/dist/components/badge.js +1 -1
  17. package/dist/components/button.d.ts +1 -1
  18. package/dist/components/button.js +1 -1
  19. package/dist/components/calendar.js +1 -1
  20. package/dist/components/card.d.ts +2 -2
  21. package/dist/components/carousel.js +1 -1
  22. package/dist/components/coach-marks.d.ts +63 -0
  23. package/dist/components/coach-marks.js +162 -0
  24. package/dist/components/combobox.js +1 -1
  25. package/dist/components/command.d.ts +15 -15
  26. package/dist/components/concept-compare-rail.js +3 -3
  27. package/dist/components/concept-compare.js +3 -3
  28. package/dist/components/concept-convergence.js +2 -2
  29. package/dist/components/doorway-tiles.d.ts +11 -0
  30. package/dist/components/doorway-tiles.js +5 -3
  31. package/dist/components/empty-state.js +1 -1
  32. package/dist/components/event-card.d.ts +22 -4
  33. package/dist/components/event-card.js +95 -50
  34. package/dist/components/event-day-header.d.ts +5 -3
  35. package/dist/components/event-day-header.js +12 -4
  36. package/dist/components/event-detail-panel.js +2 -2
  37. package/dist/components/event-door.d.ts +41 -0
  38. package/dist/components/event-door.js +65 -0
  39. package/dist/components/event-hub-hero.d.ts +10 -2
  40. package/dist/components/event-hub-hero.js +11 -5
  41. package/dist/components/event-index-row.js +1 -1
  42. package/dist/components/event-list-row-wide.js +2 -2
  43. package/dist/components/event-list-row.js +1 -1
  44. package/dist/components/event-tonight-strip.d.ts +50 -0
  45. package/dist/components/event-tonight-strip.js +67 -0
  46. package/dist/components/facet-group.d.ts +8 -1
  47. package/dist/components/facet-group.js +3 -0
  48. package/dist/components/filter-category-row.js +1 -1
  49. package/dist/components/hue-chip.js +1 -1
  50. package/dist/components/item.d.ts +2 -2
  51. package/dist/components/item.js +4 -1
  52. package/dist/components/kitchen-dossier.js +1 -1
  53. package/dist/components/link-button.js +1 -1
  54. package/dist/components/menu-family-block.d.ts +8 -1
  55. package/dist/components/menu-family-block.js +101 -68
  56. package/dist/components/menu-family-card.js +2 -2
  57. package/dist/components/menu-filter-chips.d.ts +12 -3
  58. package/dist/components/menu-filter-chips.js +22 -4
  59. package/dist/components/menu-hub-hero.d.ts +7 -1
  60. package/dist/components/menu-hub-hero.js +2 -1
  61. package/dist/components/menu-index-list.js +1 -1
  62. package/dist/components/menu-item.js +3 -3
  63. package/dist/components/menu-kitchen-list.d.ts +14 -2
  64. package/dist/components/menu-kitchen-list.js +1 -1
  65. package/dist/components/multi-select-combobox.js +2 -2
  66. package/dist/components/pagination.js +1 -1
  67. package/dist/components/photo-ground.d.ts +7 -1
  68. package/dist/components/photo-ground.js +1 -1
  69. package/dist/components/place-card.js +2 -2
  70. package/dist/components/place-details-mobile.js +1 -1
  71. package/dist/components/poster-stack.d.ts +36 -3
  72. package/dist/components/poster-stack.js +49 -18
  73. package/dist/components/searchable-select.js +1 -1
  74. package/dist/components/section-header.d.ts +7 -1
  75. package/dist/components/section-header.js +11 -7
  76. package/dist/components/signature-chip.js +2 -2
  77. package/dist/components/sliding-panels.js +1 -1
  78. package/dist/components/snap-rail.d.ts +2 -2
  79. package/dist/components/snap-rail.js +1 -1
  80. package/dist/components/spinner.d.ts +2 -2
  81. package/dist/components/tag.d.ts +2 -2
  82. package/dist/components/text.d.ts +3 -3
  83. package/dist/components/timeline-day.d.ts +54 -0
  84. package/dist/components/timeline-day.js +65 -0
  85. package/dist/components/timeline-hour-puck.d.ts +47 -0
  86. package/dist/components/timeline-hour-puck.js +3 -0
  87. package/dist/components/timeline-hour.d.ts +96 -0
  88. package/dist/components/{timeline-band.js → timeline-hour.js} +2 -1
  89. package/dist/components/timeline-playhead.js +1 -3
  90. package/dist/components/toggle-group.js +9 -4
  91. package/dist/components/toggle.d.ts +1 -1
  92. package/dist/components/toggle.js +1 -1
  93. package/dist/components/top-ranking-entry.js +3 -3
  94. package/package.json +1 -1
  95. package/src/components/badge.stories.tsx +31 -1
  96. package/src/components/badge.tsx +2 -0
  97. package/src/components/button.stories.tsx +5 -4
  98. package/src/components/button.test.tsx +11 -1
  99. package/src/components/button.tsx +16 -5
  100. package/src/components/coach-marks.stories.tsx +79 -0
  101. package/src/components/coach-marks.test.tsx +140 -0
  102. package/src/components/coach-marks.tsx +252 -0
  103. package/src/components/doorway-tiles.stories.tsx +34 -0
  104. package/src/components/doorway-tiles.test.tsx +27 -0
  105. package/src/components/doorway-tiles.tsx +30 -2
  106. package/src/components/event-card.tsx +70 -13
  107. package/src/components/event-day-header.tsx +19 -7
  108. package/src/components/event-door.stories.tsx +88 -0
  109. package/src/components/event-door.tsx +97 -0
  110. package/src/components/event-hub-hero.tsx +21 -3
  111. package/src/components/event-tonight-strip.stories.tsx +78 -0
  112. package/src/components/event-tonight-strip.test.tsx +59 -0
  113. package/src/components/event-tonight-strip.tsx +134 -0
  114. package/src/components/facet-group.tsx +9 -0
  115. package/src/components/hue-chip.test.tsx +6 -3
  116. package/src/components/hue-chip.tsx +5 -2
  117. package/src/components/item.tsx +4 -1
  118. package/src/components/menu-family-block.stories.tsx +12 -0
  119. package/src/components/menu-family-block.tsx +53 -20
  120. package/src/components/menu-filter-chips.tsx +32 -4
  121. package/src/components/menu-hub-hero.test.tsx +13 -0
  122. package/src/components/menu-hub-hero.tsx +9 -2
  123. package/src/components/menu-kitchen-list.tsx +45 -6
  124. package/src/components/photo-ground.tsx +7 -0
  125. package/src/components/poster-stack.stories.tsx +43 -1
  126. package/src/components/poster-stack.tsx +98 -19
  127. package/src/components/section-header.test.tsx +15 -0
  128. package/src/components/section-header.tsx +26 -12
  129. package/src/components/snap-rail.tsx +6 -1
  130. package/src/components/timeline-day.stories.tsx +201 -0
  131. package/src/components/timeline-day.test.tsx +78 -0
  132. package/src/components/timeline-day.tsx +114 -0
  133. package/src/components/timeline-hour-puck.tsx +139 -0
  134. package/src/components/timeline-hour.tsx +266 -0
  135. package/src/components/timeline-playhead.tsx +1 -1
  136. package/src/components/toggle-group.stories.tsx +37 -1
  137. package/src/components/toggle-group.tsx +11 -6
  138. package/src/components/toggle.tsx +8 -0
  139. package/dist/chunk-NSNEANOQ.js +0 -104
  140. package/dist/chunk-SXQZ7DLH.js +0 -167
  141. package/dist/components/hour-band.d.ts +0 -66
  142. package/dist/components/hour-band.js +0 -81
  143. package/dist/components/hour-group.d.ts +0 -67
  144. package/dist/components/hour-group.js +0 -59
  145. package/dist/components/timeline-band.d.ts +0 -64
  146. package/src/components/hour-band.stories.tsx +0 -182
  147. package/src/components/hour-band.tsx +0 -146
  148. package/src/components/hour-group.stories.tsx +0 -100
  149. package/src/components/hour-group.tsx +0 -124
  150. package/src/components/timeline-band.stories.tsx +0 -206
  151. package/src/components/timeline-band.tsx +0 -247
@@ -0,0 +1,114 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+ import { Separator } from "./separator";
6
+ import { Skeleton } from "./skeleton";
7
+ import { TimelineHourSkeleton } from "./timeline-hour";
8
+
9
+ /**
10
+ * TimelineDay — one day's programme on the timeline: a column of
11
+ * `TimelineHour`s that becomes, from `lg`, the three-column row
12
+ * lane │ spine │ content that `TimelineBand` used to draw.
13
+ *
14
+ * ONE DOM FOR BOTH VIEWPORTS. `TimelineBand` took the lane as a prop — a
15
+ * second parent — which forced the consumer to render two trees and pick one
16
+ * in JavaScript, and a server render cannot pick. Here the lane is grid
17
+ * column 1, and the day's hero is an ordinary child in its chronological
18
+ * slot that the grid PLACES in that column from `lg` (see `TimelineHour`
19
+ * `role="hero"`). Source order is the phone's visual order, which is the
20
+ * primary surface and the one a screen reader gets on both.
21
+ *
22
+ * `rows` is the number of children that are NOT the hero — the column-3
23
+ * items. It sets `--timeline-rows`, which both the lane and the spine span,
24
+ * and the explicit row track count. It has to be a number the caller
25
+ * supplies: the grid needs the lane to cover every row so auto-placement
26
+ * never drops a later hour into the lane column, and this component cannot
27
+ * tell a hero child from an hour child without inspecting props.
28
+ *
29
+ * `laneTop` is the lane's sticky `top` as a CSS value. `position: sticky` on
30
+ * a grid item is bounded by its grid area — column 1, rows 1…N, the day's
31
+ * full height — so the poster holds while column 3 scrolls and releases when
32
+ * the day runs out, with no scroll listener and no measured height.
33
+ *
34
+ * The spine is a vertical `Separator` in column 2 spanning every row, drawn
35
+ * here so consumers never forget it; the pucks that sit on it are
36
+ * `TimelineHour`'s.
37
+ */
38
+
39
+ export type TimelineDayProps = {
40
+ /** The column-3 children count — every child except the hero. */
41
+ rows: number;
42
+ /** Sticky `top` for the lane, as a CSS value. Omitted, the lane scrolls. */
43
+ laneTop?: string;
44
+ /** `TimelineHour`s, in chronological order, the hero among them. */
45
+ children: React.ReactNode;
46
+ className?: string;
47
+ };
48
+
49
+ export function TimelineDay({ rows, laneTop, children, className }: TimelineDayProps) {
50
+ const style = {
51
+ "--timeline-rows": String(Math.max(rows, 1)),
52
+ ...(laneTop !== undefined ? { "--timeline-lane-top": laneTop } : {}),
53
+ } as React.CSSProperties;
54
+
55
+ return (
56
+ <div
57
+ className={cn(
58
+ "flex flex-col",
59
+ "lg:grid lg:grid-cols-[var(--timeline-lane)_var(--timeline-spine)_minmax(0,1fr)]",
60
+ "lg:[grid-template-rows:repeat(var(--timeline-rows),auto)]",
61
+ className
62
+ )}
63
+ style={style}
64
+ >
65
+ <div
66
+ aria-hidden
67
+ className={cn(
68
+ "hidden lg:col-start-2 lg:row-start-1 lg:flex lg:justify-center lg:self-stretch",
69
+ "lg:[grid-row-end:span_var(--timeline-rows)]"
70
+ )}
71
+ >
72
+ <Separator orientation="vertical" className="h-full" />
73
+ </div>
74
+ {children}
75
+ </div>
76
+ );
77
+ }
78
+
79
+ export type TimelineDaySkeletonProps = {
80
+ /** Hours to reserve space for. */
81
+ hourCount?: number;
82
+ className?: string;
83
+ };
84
+
85
+ /**
86
+ * Holds the day's geometry at both widths so nothing jumps when data lands:
87
+ * the phone's stacked hours, the desktop's lane / spine / grid.
88
+ */
89
+ export function TimelineDaySkeleton({ hourCount = 2, className }: TimelineDaySkeletonProps) {
90
+ return (
91
+ <div
92
+ aria-hidden="true"
93
+ className={cn(
94
+ "flex flex-col",
95
+ "lg:grid lg:grid-cols-[var(--timeline-lane)_var(--timeline-spine)_minmax(0,1fr)]",
96
+ "lg:[grid-template-rows:repeat(var(--timeline-rows),auto)]",
97
+ className
98
+ )}
99
+ style={{ "--timeline-rows": String(hourCount) } as React.CSSProperties}
100
+ >
101
+ <div className="hidden lg:col-start-1 lg:row-start-1 lg:flex lg:flex-col lg:gap-3 lg:pt-3.5 lg:[grid-row-end:span_var(--timeline-rows)]">
102
+ <Skeleton className="aspect-[3/4] w-full rounded-xl" />
103
+ <Skeleton className="h-[18px] w-[70%]" />
104
+ <Skeleton className="h-[13px] w-[45%]" />
105
+ </div>
106
+ <div className="hidden lg:col-start-2 lg:row-start-1 lg:flex lg:justify-center lg:self-stretch lg:[grid-row-end:span_var(--timeline-rows)]">
107
+ <Separator orientation="vertical" className="h-full" />
108
+ </div>
109
+ {Array.from({ length: hourCount }, (_, index) => (
110
+ <TimelineHourSkeleton key={index} cardCount={index === 0 ? 3 : 2} />
111
+ ))}
112
+ </div>
113
+ );
114
+ }
@@ -0,0 +1,139 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Radio } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+
7
+ /**
8
+ * Ring pulse on the live puck — inherited from an earlier `TimelinePlayhead`,
9
+ * which carried this surface's only motion. Kept at its 2.4s `ease-in-out` and
10
+ * its reduced-motion kill switch, but BOX-SHADOW ONLY: a `transform: scale`
11
+ * would make the hairline visibly breathe here, because this puck sits ON the
12
+ * rail. (Today's `timeline-playhead.tsx` is static and sits between bands.)
13
+ */
14
+ const LIVE_PUCK_PULSE_CLASS = "bm-timeline-live-puck-pulse";
15
+
16
+ /**
17
+ * The phone axis gutter, shared by `TimelineHour` and `TimelinePlayhead` so
18
+ * the hour chips and the current-time label sit in one column. 58px is the
19
+ * width of a five-glyph 24-hour label ("20:00") in the chip's mono type plus
20
+ * its padding; a 12-hour locale's "09:00 AM" overflows it, which is one of the
21
+ * reasons the events surfaces format 24-hour everywhere.
22
+ */
23
+ export const TIMELINE_MOBILE_GUTTER_CLASS = "w-[58px]";
24
+
25
+ export type TimelineHourPuckProps = {
26
+ /** The hour, already formatted and localised — or an em dash for a quiet day. */
27
+ time: string;
28
+ /** Marks this as the hour currently in progress. */
29
+ isLive?: boolean;
30
+ /** Shown under the puck when live, e.g. "NOW ON". Required to render it. */
31
+ liveLabel?: string;
32
+ /**
33
+ * `station`: the bordered puck that sits ON the hairline, for the desktop
34
+ * spine. `chip`: a filled, fixed-width lozenge with the hairline starting
35
+ * BELOW it, for the phone gutter — an app's time label, not a station on a
36
+ * rail. `responsive` (the default): the chip below `lg`, the station from
37
+ * `lg` — ONE node that changes its look with the viewport, so a
38
+ * server-rendered timeline carries a single DOM for both widths. In the
39
+ * chip look the live label is visually hidden: the tinted chip plus a small
40
+ * on-air glyph are the visible signal (the glyph so the state never rests on
41
+ * colour alone once the pulse is reduced away) and the label stays for
42
+ * screen readers only.
43
+ */
44
+ variant?: "station" | "chip" | "responsive";
45
+ className?: string;
46
+ };
47
+
48
+ /**
49
+ * The station on the timeline's axis — ONE object, positioned by its host
50
+ * (`TimelineHour`): pinned to the top of the phone gutter, centred on the
51
+ * hour's heading rule out on the desktop spine.
52
+ *
53
+ * The `bg-background` fill punches through the hairline so the rail reads as
54
+ * continuous behind the label rather than butting into it.
55
+ *
56
+ * The live label is absolutely positioned rather than a flex sibling: hosts
57
+ * centre this puck with `-translate-y-1/2`, and a two-item column would centre
58
+ * the STACK, lifting the puck off the line it exists to sit on.
59
+ */
60
+ export function TimelineHourPuck({
61
+ time,
62
+ isLive,
63
+ liveLabel,
64
+ variant = "responsive",
65
+ className,
66
+ }: TimelineHourPuckProps) {
67
+ const chipClasses = cn(
68
+ "inline-flex h-6 min-w-[58px] items-center justify-center gap-1 rounded-[7px] border border-transparent px-1 text-[12px] font-medium",
69
+ isLive ? cn("bg-success/10 text-success", LIVE_PUCK_PULSE_CLASS) : "bg-muted text-foreground"
70
+ );
71
+ const stationClasses = cn(
72
+ "rounded-[7px] border bg-background px-[7px] py-[3px] text-[12.5px] font-medium",
73
+ isLive ? cn("border-success/40 text-success", LIVE_PUCK_PULSE_CLASS) : "border-border text-foreground"
74
+ );
75
+ const glyphClasses = "h-2.5 w-2.5 shrink-0";
76
+ const labelHiddenClasses = "sr-only";
77
+ const labelStationClasses = cn(
78
+ "absolute top-[calc(100%+6px)] whitespace-nowrap bg-background px-1",
79
+ "text-[9.5px] font-extrabold tracking-[0.08em] text-success"
80
+ );
81
+ // `sr-only` spelled out property by property so every one has an `lg:`
82
+ // twin — this project's Tailwind emits no `max-*` queries, and `lg:not-sr-only`
83
+ // would reset `position` under the `absolute` the station needs.
84
+ const labelResponsiveClasses = cn(
85
+ "absolute -m-px h-px w-px overflow-hidden whitespace-nowrap p-0 [clip:rect(0,0,0,0)]",
86
+ "lg:m-0 lg:h-auto lg:w-auto lg:overflow-visible lg:px-1 lg:[clip:auto]",
87
+ "lg:top-[calc(100%+6px)] lg:bg-background lg:text-[9.5px] lg:font-extrabold lg:tracking-[0.08em] lg:text-success"
88
+ );
89
+
90
+ return (
91
+ <span className={cn("relative flex justify-center", className)}>
92
+ {isLive && (
93
+ <style>{`
94
+ @keyframes ${LIVE_PUCK_PULSE_CLASS} {
95
+ 0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklch, var(--success) 40%, transparent); }
96
+ 50% { box-shadow: 0 0 0 4px color-mix(in oklch, var(--success) 0%, transparent); }
97
+ }
98
+ .${LIVE_PUCK_PULSE_CLASS} {
99
+ animation: ${LIVE_PUCK_PULSE_CLASS} 2.4s ease-in-out infinite;
100
+ }
101
+ @media (prefers-reduced-motion: reduce) {
102
+ .${LIVE_PUCK_PULSE_CLASS} { animation: none; }
103
+ }
104
+ `}</style>
105
+ )}
106
+ <span
107
+ className={cn(
108
+ "whitespace-nowrap font-mono tabular-nums",
109
+ variant === "chip" && chipClasses,
110
+ variant === "station" && stationClasses,
111
+ variant === "responsive" &&
112
+ cn(
113
+ chipClasses,
114
+ // The station look from `lg`: bordered, background-filled,
115
+ // no fixed width. Every chip utility above has an `lg:` twin.
116
+ "lg:h-auto lg:min-w-0 lg:gap-0 lg:border-border lg:bg-background lg:px-[7px] lg:py-[3px] lg:text-[12.5px] lg:text-foreground",
117
+ isLive && "lg:border-success/40 lg:bg-background lg:text-success"
118
+ )
119
+ )}
120
+ >
121
+ {isLive && variant !== "station" && (
122
+ <Radio className={cn(glyphClasses, variant === "responsive" && "lg:hidden")} aria-hidden />
123
+ )}
124
+ {time}
125
+ </span>
126
+ {isLive && !!liveLabel && (
127
+ <span
128
+ className={cn(
129
+ variant === "chip" && labelHiddenClasses,
130
+ variant === "station" && labelStationClasses,
131
+ variant === "responsive" && labelResponsiveClasses
132
+ )}
133
+ >
134
+ {liveLabel}
135
+ </span>
136
+ )}
137
+ </span>
138
+ );
139
+ }
@@ -0,0 +1,266 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+ import { Separator } from "./separator";
6
+ import { Skeleton } from "./skeleton";
7
+ import { TIMELINE_MOBILE_GUTTER_CLASS, TimelineHourPuck } from "./timeline-hour-puck";
8
+
9
+ /**
10
+ * TimelineHour — one hour of a day's programme, ONE DOM for both viewports.
11
+ *
12
+ * Replaces `HourBand` (the phone band: a 58px gutter with a filled chip and
13
+ * a hairline dropping from it, the hour's cards beside) and `HourGroup` (the
14
+ * desktop heading: a station out on `TimelineDay`'s spine, a rule and a
15
+ * count). They were two components because the timeline picked one per
16
+ * viewport in JavaScript, and a server render cannot know the viewport — so
17
+ * the server rendered nothing. This is both, with CSS deciding:
18
+ *
19
+ * below `lg` the section carries a 70px left inset; the puck sits at the
20
+ * top-left of that gutter as a chip, a hairline drops beneath
21
+ * it, and the heading row is hidden — the chip IS the heading.
22
+ * from `lg` the inset goes, the heading row shows (rule + count), and the
23
+ * puck moves out to the spine, centred on that rule, as a
24
+ * station. Its vertical position is a constant (`--timeline-
25
+ * heading` below), which is why the heading row has a fixed
26
+ * height: "top-1/2 of the row" needs the puck INSIDE the row,
27
+ * and inside the row it cannot also be the phone's gutter chip.
28
+ *
29
+ * The invariant `TimelineBand` used to state still holds: AN HOUR APPEARS
30
+ * EXACTLY ONCE, AND ALWAYS ON THE SPINE. Roles are how a day keeps it while
31
+ * one of its events is lifted into the desktop lane:
32
+ *
33
+ * hour the default — a station, the hour's events.
34
+ * hero the day's feature. Below `lg` a normal hour holding one
35
+ * card, in its chronological slot. From `lg` the node moves
36
+ * to `TimelineDay`'s lane column (grid placement, not source
37
+ * order), pins, and loses its puck and heading — the lane
38
+ * card carries its own start time, see `EventCard` `hideWhen`
39
+ * `"below-lg"`.
40
+ * continuation what is left of the hero's hour. Below `lg` its chip is
41
+ * `invisible` (the gutter and hairline stay, so the reader sees
42
+ * ONE band for that hour, hero first); from `lg` it shows its
43
+ * station, because the hero is in the lane now and the hour
44
+ * needs its puck back. When the hero was its hour's only
45
+ * event there is no continuation, and the hour simply leaves
46
+ * the desktop column — exactly what `HourGroup`'s
47
+ * `excludeEventId` used to do.
48
+ * unscheduled events the ingest dated but never timed. No station at any
49
+ * width — the axis encodes clock position and these have none
50
+ * — and no gutter inset: an inline heading (the caller's
51
+ * label, a rule, the count) at both widths, after the day's
52
+ * real programme.
53
+ *
54
+ * `layout`: `cards` stacks posters with a gap (a card is its own frame, a
55
+ * hairline between two posters reads as a third object) and from `lg` lays
56
+ * them on a two/three-up grid; `rows` rules between children on both widths.
57
+ * Children are read with `Children.toArray`, which drops `null`/`false`, so a
58
+ * conditional first row never inherits a rule meant for a second; pass rows
59
+ * as siblings, not inside a fragment.
60
+ */
61
+
62
+ export type TimelineHourRole = "hour" | "hero" | "continuation" | "unscheduled";
63
+
64
+ export type TimelineHourProps = {
65
+ /** The hour, already formatted and localised — "20:00". For `unscheduled`,
66
+ * the caller's label for the group ("Time to be confirmed"). */
67
+ time: string;
68
+ role?: TimelineHourRole;
69
+ /** Marks this as the hour currently in progress. */
70
+ isLive?: boolean;
71
+ /** Under the station when live, e.g. "NOW ON"; screen-reader-only in the chip. */
72
+ liveLabel?: string;
73
+ /** Desktop heading count, e.g. "3 events" — mirrors `EventDayHeader`'s `meta`.
74
+ * Also the phone heading for `unscheduled`. */
75
+ countLabel?: string;
76
+ /** Trailing note after the children, e.g. "No start time was published". */
77
+ note?: string;
78
+ layout?: "cards" | "rows";
79
+ /** The hour's events — each direct child one card or row. */
80
+ children: React.ReactNode;
81
+ /**
82
+ * Events past the hour's card cap, after `children`: cards on the phone
83
+ * (the same stack, same gap), rows from `lg` (ruled, under the grid) —
84
+ * pass `EventCard density="row-lg"`. The desktop's density ladder inside
85
+ * one hour: a few posters, then rows.
86
+ */
87
+ overflow?: React.ReactNode;
88
+ className?: string;
89
+ };
90
+
91
+ /** Where the desktop puck's centre sits: the section's `lg:pt-[22px]` plus
92
+ * half the fixed 16px heading row. */
93
+ const HEADING_CENTRE_LG = "lg:top-[30px]";
94
+
95
+ export function TimelineHour({
96
+ time,
97
+ role = "hour",
98
+ isLive,
99
+ liveLabel,
100
+ countLabel,
101
+ note,
102
+ layout = "cards",
103
+ children,
104
+ overflow,
105
+ className,
106
+ }: TimelineHourProps) {
107
+ const isHero = role === "hero";
108
+ const isContinuation = role === "continuation";
109
+ const isUnscheduled = role === "unscheduled";
110
+ const hasStation = !isUnscheduled;
111
+ const isCards = layout === "cards";
112
+ const items = React.Children.toArray(children);
113
+ const overflowItems = React.Children.toArray(overflow);
114
+
115
+ return (
116
+ <section
117
+ data-timeline-hour={role}
118
+ className={cn(
119
+ "relative flex flex-col pt-2.5",
120
+ // The phone gutter: the puck's column plus the 12px gap `HourBand` had.
121
+ hasStation && "pl-[70px] lg:pl-0",
122
+ "lg:pt-[22px]",
123
+ isHero
124
+ ? cn(
125
+ // The lane. Placed by the grid, not by source order; `self-start`
126
+ // is load-bearing — a stretched item would be the day's full
127
+ // height and never stick.
128
+ // Longhands, not `grid-row`: a `/` inside an arbitrary value
129
+ // collides with the opacity-modifier parse.
130
+ "lg:col-start-1 lg:row-start-1 lg:[grid-row-end:span_var(--timeline-rows)] lg:self-start",
131
+ "lg:sticky lg:top-[var(--timeline-lane-top)] lg:pt-3.5"
132
+ )
133
+ : "lg:col-start-3",
134
+ className
135
+ )}
136
+ >
137
+ {hasStation && (
138
+ <>
139
+ <TimelineHourPuck
140
+ time={time}
141
+ isLive={isLive}
142
+ liveLabel={liveLabel}
143
+ className={cn(
144
+ "absolute left-0 top-2.5",
145
+ TIMELINE_MOBILE_GUTTER_CLASS,
146
+ // Out on the spine, centred on the heading rule.
147
+ "lg:left-[calc(var(--timeline-spine)*-0.5)] lg:w-0 lg:-translate-y-1/2",
148
+ HEADING_CENTRE_LG,
149
+ isContinuation && "invisible lg:visible",
150
+ isHero && "lg:hidden"
151
+ )}
152
+ />
153
+ {/* The phone hairline, from under the chip to the next hour's chip;
154
+ the desktop spine is `TimelineDay`'s. */}
155
+ <Separator
156
+ orientation="vertical"
157
+ className={cn(
158
+ "absolute -bottom-2.5 left-[29px] top-10 h-auto -translate-x-1/2 lg:hidden",
159
+ isLive && "bg-success/30"
160
+ )}
161
+ />
162
+ </>
163
+ )}
164
+
165
+ {/* The heading row: hidden on the phone for a stationed hour (the chip
166
+ says it), the hero's goes with its station from `lg`. Fixed height
167
+ so the puck's centre is a constant. */}
168
+ <div
169
+ className={cn(
170
+ "items-center gap-2.5",
171
+ isUnscheduled ? "flex h-4" : "hidden lg:flex lg:h-4",
172
+ isHero && "lg:hidden"
173
+ )}
174
+ >
175
+ {isUnscheduled && (
176
+ <span className="font-mono text-[11.5px] text-muted-foreground tabular-nums lg:text-xs">
177
+ {time}
178
+ </span>
179
+ )}
180
+ <Separator className="flex-1" />
181
+ {countLabel && (
182
+ <span className="text-[10.5px] text-muted-foreground/70 lg:text-[11px]">
183
+ {countLabel}
184
+ </span>
185
+ )}
186
+ </div>
187
+
188
+ <div
189
+ className={cn(
190
+ "flex min-w-0 flex-col",
191
+ isCards && "gap-4 pb-2 lg:grid lg:grid-cols-2 lg:gap-5 lg:pb-0 lg:pt-3.5 xl:grid-cols-3",
192
+ isCards && isHero && "lg:flex lg:pt-0",
193
+ isUnscheduled && isCards && "pt-3.5"
194
+ )}
195
+ >
196
+ {isCards
197
+ ? items
198
+ : items.map((child, index) => (
199
+ <div key={index}>
200
+ {index > 0 && <Separator />}
201
+ {child}
202
+ </div>
203
+ ))}
204
+ </div>
205
+
206
+ {overflowItems.length > 0 && (
207
+ <div className="flex flex-col gap-4 pb-2 lg:gap-0 lg:pb-0 lg:pt-2">
208
+ {overflowItems.map((child, index) => (
209
+ <div key={index}>
210
+ {index > 0 && <Separator className="hidden lg:block" />}
211
+ {child}
212
+ </div>
213
+ ))}
214
+ </div>
215
+ )}
216
+
217
+ {note && <p className="pt-[9px] text-[11.5px] text-muted-foreground">{note}</p>}
218
+ </section>
219
+ );
220
+ }
221
+
222
+ export type TimelineHourSkeletonProps = {
223
+ /** Cards to reserve space for. */
224
+ cardCount?: number;
225
+ className?: string;
226
+ };
227
+
228
+ /**
229
+ * Mirrors `TimelineHour` at both widths — the phone's gutter chip and
230
+ * hairline, the desktop's grid of poster placeholders — so nothing jumps when
231
+ * data lands.
232
+ */
233
+ export function TimelineHourSkeleton({ cardCount = 2, className }: TimelineHourSkeletonProps) {
234
+ return (
235
+ <div
236
+ aria-hidden="true"
237
+ className={cn("relative flex flex-col pl-[70px] pt-2.5 lg:col-start-3 lg:pl-0 lg:pt-[22px]", className)}
238
+ >
239
+ <Skeleton
240
+ className={cn(
241
+ "absolute left-0 top-2.5 h-6 rounded-[7px]",
242
+ TIMELINE_MOBILE_GUTTER_CLASS,
243
+ "lg:left-[calc(var(--timeline-spine)*-0.5)] lg:h-[26px] lg:w-14 lg:-translate-x-1/2 lg:-translate-y-1/2",
244
+ HEADING_CENTRE_LG
245
+ )}
246
+ />
247
+ <Separator
248
+ orientation="vertical"
249
+ className="absolute -bottom-2.5 left-[29px] top-10 h-auto -translate-x-1/2 lg:hidden"
250
+ />
251
+ <div className="hidden h-4 items-center gap-2.5 lg:flex">
252
+ <Separator className="flex-1" />
253
+ <Skeleton className="h-3 w-12" />
254
+ </div>
255
+ <div className="flex flex-col gap-4 pb-2 lg:grid lg:grid-cols-2 lg:gap-5 lg:pb-0 lg:pt-3.5 xl:grid-cols-3">
256
+ {Array.from({ length: cardCount }, (_, index) => (
257
+ <div key={index} className="flex flex-col gap-2">
258
+ <Skeleton className="aspect-[4/3] w-full rounded-xl" />
259
+ <Skeleton className="h-3.5 w-4/5" />
260
+ <Skeleton className="h-3 w-1/2" />
261
+ </div>
262
+ ))}
263
+ </div>
264
+ </div>
265
+ );
266
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import { cn } from "../lib/utils";
5
- import { TIMELINE_MOBILE_GUTTER_CLASS } from "./timeline-band";
5
+ import { TIMELINE_MOBILE_GUTTER_CLASS } from "./timeline-hour-puck";
6
6
 
7
7
  /**
8
8
  * TimelinePlayhead — the "you are here" mark on the mobile timeline: the
@@ -1,6 +1,16 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { ToggleGroup, ToggleGroupItem } from "./toggle-group";
3
- import { Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight } from "lucide-react";
3
+ import {
4
+ Bold,
5
+ Italic,
6
+ Underline,
7
+ AlignLeft,
8
+ AlignCenter,
9
+ AlignRight,
10
+ LayoutGrid,
11
+ List,
12
+ } from "lucide-react";
13
+ import { Button } from "./button";
4
14
 
5
15
  const meta = {
6
16
  title: "Components/ToggleGroup",
@@ -58,3 +68,29 @@ export const Outline: Story = {
58
68
  </ToggleGroup>
59
69
  ),
60
70
  };
71
+
72
+ /**
73
+ * The segmented control: one 44px muted capsule, the on cell a raised card.
74
+ * Sits beside pill buttons at the same height — the browse toolbar's view
75
+ * switch. Single, always one on.
76
+ */
77
+ export const Segmented: Story = {
78
+ render: () => (
79
+ <div className="flex items-center gap-2">
80
+ <Button variant="outline" size="pill">
81
+ Filters
82
+ </Button>
83
+ <Button variant="outline" size="pill">
84
+ Sort
85
+ </Button>
86
+ <ToggleGroup type="single" variant="segmented" defaultValue="list" aria-label="View">
87
+ <ToggleGroupItem value="list" aria-label="Rows">
88
+ <List />
89
+ </ToggleGroupItem>
90
+ <ToggleGroupItem value="grid" aria-label="Cards">
91
+ <LayoutGrid />
92
+ </ToggleGroupItem>
93
+ </ToggleGroup>
94
+ </div>
95
+ ),
96
+ };
@@ -26,13 +26,14 @@ function ToggleGroup({
26
26
  data-size={size}
27
27
  className={cn(
28
28
  "group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
29
+ // The segmented control's capsule: muted ground, the on cell a raised
30
+ // card inside it (see `toggleVariants.segmented`). 44px tall.
31
+ "data-[variant=segmented]:gap-0.5 data-[variant=segmented]:rounded-full data-[variant=segmented]:bg-muted data-[variant=segmented]:p-0.5",
29
32
  className
30
33
  )}
31
34
  {...props}
32
35
  >
33
- <ToggleGroupContext.Provider value={{ variant, size }}>
34
- {children}
35
- </ToggleGroupContext.Provider>
36
+ <ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
36
37
  </ToggleGroupPrimitive.Root>
37
38
  );
38
39
  }
@@ -45,18 +46,22 @@ function ToggleGroupItem({
45
46
  ...props
46
47
  }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
47
48
  const context = React.useContext(ToggleGroupContext);
49
+ const resolved = context.variant || variant;
48
50
 
49
51
  return (
50
52
  <ToggleGroupPrimitive.Item
51
53
  data-slot="toggle-group-item"
52
- data-variant={context.variant || variant}
54
+ data-variant={resolved}
53
55
  data-size={context.size || size}
54
56
  className={cn(
55
57
  toggleVariants({
56
- variant: context.variant || variant,
58
+ variant: resolved,
57
59
  size: context.size || size,
58
60
  }),
59
- "min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
61
+ "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
+ resolved === "segmented"
63
+ ? "rounded-full px-0 data-[state=on]:shadow-[var(--shadow-card)]"
64
+ : "rounded-none first:rounded-l-md last:rounded-r-md",
60
65
  className
61
66
  )}
62
67
  {...props}
@@ -14,6 +14,14 @@ const toggleVariants = cva(
14
14
  default: "bg-transparent",
15
15
  outline:
16
16
  "border border-border bg-transparent hover:bg-accent/50 hover:text-accent-foreground data-[state=on]:border-primary",
17
+ /**
18
+ * One cell of a segmented control: a raised card on the group's
19
+ * muted capsule when on, quiet type when off. The group
20
+ * (`ToggleGroup variant="segmented"`) supplies the capsule; the cell
21
+ * never fills with the accent, which would read as a primary button.
22
+ */
23
+ segmented:
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)]",
17
25
  },
18
26
  size: {
19
27
  default: "h-9 px-2 min-w-9",