@bearmenu/ui 0.8.17 → 0.8.19
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/{chunk-WYTJR2YU.js → chunk-5CPBSNWQ.js} +6 -1
- package/dist/chunk-BRXI6EDO.js +84 -0
- package/dist/{chunk-54FQQZZJ.js → chunk-EQYB4Q77.js} +1 -1
- package/dist/chunk-QRSIJC4X.js +156 -0
- package/dist/components/concept-compare-rail.js +2 -2
- package/dist/components/concept-compare.js +2 -2
- package/dist/components/event-card.d.ts +22 -4
- package/dist/components/event-card.js +95 -50
- package/dist/components/event-day-header.d.ts +5 -3
- package/dist/components/event-day-header.js +12 -4
- package/dist/components/event-hub-hero.d.ts +12 -2
- package/dist/components/event-hub-hero.js +11 -5
- package/dist/components/event-tonight-strip.d.ts +50 -0
- package/dist/components/event-tonight-strip.js +67 -0
- package/dist/components/menu-family-block.js +4 -3
- package/dist/components/menu-hub-hero.d.ts +7 -1
- package/dist/components/menu-hub-hero.js +2 -1
- package/dist/components/poster-stack.d.ts +36 -3
- package/dist/components/poster-stack.js +87 -48
- package/dist/components/snap-rail.d.ts +2 -2
- package/dist/components/snap-rail.js +1 -1
- package/dist/components/timeline-day.d.ts +54 -0
- package/dist/components/timeline-day.js +65 -0
- package/dist/components/timeline-hour-puck.d.ts +47 -0
- package/dist/components/timeline-hour-puck.js +3 -0
- package/dist/components/timeline-hour.d.ts +96 -0
- package/dist/components/{timeline-band.js → timeline-hour.js} +2 -1
- package/dist/components/timeline-playhead.js +1 -3
- package/package.json +1 -1
- package/src/components/event-card.tsx +70 -13
- package/src/components/event-day-header.tsx +19 -7
- package/src/components/event-hub-hero.tsx +25 -3
- package/src/components/event-tonight-strip.stories.tsx +78 -0
- package/src/components/event-tonight-strip.test.tsx +49 -0
- package/src/components/event-tonight-strip.tsx +134 -0
- package/src/components/menu-family-block.tsx +13 -6
- package/src/components/menu-hub-hero.test.tsx +13 -0
- package/src/components/menu-hub-hero.tsx +9 -2
- package/src/components/poster-stack.stories.tsx +43 -1
- package/src/components/poster-stack.tsx +104 -19
- package/src/components/snap-rail.tsx +6 -1
- package/src/components/timeline-day.stories.tsx +201 -0
- package/src/components/timeline-day.test.tsx +78 -0
- package/src/components/timeline-day.tsx +114 -0
- package/src/components/timeline-hour-puck.tsx +142 -0
- package/src/components/timeline-hour.tsx +266 -0
- package/src/components/timeline-playhead.tsx +1 -1
- package/dist/chunk-NSNEANOQ.js +0 -104
- package/dist/components/hour-band.d.ts +0 -66
- package/dist/components/hour-band.js +0 -81
- package/dist/components/hour-group.d.ts +0 -67
- package/dist/components/hour-group.js +0 -59
- package/dist/components/timeline-band.d.ts +0 -64
- package/src/components/hour-band.stories.tsx +0 -182
- package/src/components/hour-band.tsx +0 -146
- package/src/components/hour-group.stories.tsx +0 -100
- package/src/components/hour-group.tsx +0 -124
- package/src/components/timeline-band.stories.tsx +0 -206
- package/src/components/timeline-band.tsx +0 -247
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { TimelineDay, TimelineDaySkeleton } from "./timeline-day";
|
|
3
|
+
import { TimelineHour } from "./timeline-hour";
|
|
4
|
+
import { EventCard } from "./event-card";
|
|
5
|
+
import { EventDayHeader } from "./event-day-header";
|
|
6
|
+
import type { EventCardLabels, EventFormatters, EventPreview } from "./event-types";
|
|
7
|
+
|
|
8
|
+
const labels: EventCardLabels = { free: "Free", liveNow: "Live now" };
|
|
9
|
+
|
|
10
|
+
const formatters: EventFormatters = {
|
|
11
|
+
time: (iso) => iso.slice(11, 16),
|
|
12
|
+
day: () => "Thu, Sep 3",
|
|
13
|
+
price: (amount) => `${amount} RON`,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const POSTER =
|
|
17
|
+
"https://images.unsplash.com/photo-1514525253161-7a46d19cd819?w=640&q=70&auto=format&fit=crop";
|
|
18
|
+
|
|
19
|
+
const event = (
|
|
20
|
+
id: string,
|
|
21
|
+
title: string,
|
|
22
|
+
place: string,
|
|
23
|
+
hour: string,
|
|
24
|
+
fee?: number,
|
|
25
|
+
poster = POSTER
|
|
26
|
+
): EventPreview => ({
|
|
27
|
+
id,
|
|
28
|
+
title,
|
|
29
|
+
location: place,
|
|
30
|
+
place_name: place,
|
|
31
|
+
starts_at: `2026-09-03T${hour}:00`,
|
|
32
|
+
entry_fee: fee,
|
|
33
|
+
poster_url: poster,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const meta: Meta<typeof TimelineDay> = {
|
|
37
|
+
title: "Events/TimelineDay",
|
|
38
|
+
component: TimelineDay,
|
|
39
|
+
tags: ["autodocs"],
|
|
40
|
+
decorators: [
|
|
41
|
+
(Story) => (
|
|
42
|
+
<div className="mx-auto max-w-[1180px] bg-background px-4">
|
|
43
|
+
<Story />
|
|
44
|
+
</div>
|
|
45
|
+
),
|
|
46
|
+
],
|
|
47
|
+
parameters: {
|
|
48
|
+
layout: "fullscreen",
|
|
49
|
+
docs: {
|
|
50
|
+
description: {
|
|
51
|
+
component:
|
|
52
|
+
"One day of the timeline, ONE DOM for both viewports. Below `lg` it is a column of hours, each a `TimelineHour` with a gutter chip and the hour's cards. From `lg` the same nodes become lane │ spine │ content: the hour with `role=\"hero\"` is placed in the lane by the grid and pins, every other hour stations itself on the spine, and cards past the cap paint as rows (`EventCard density=\"row-lg\"`). Resize the canvas across 1024px to watch one tree change shape. `rows` counts the column-3 children.",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export default meta;
|
|
59
|
+
type Story = StoryObj<typeof TimelineDay>;
|
|
60
|
+
|
|
61
|
+
function Card(props: {
|
|
62
|
+
ev: EventPreview;
|
|
63
|
+
emphasis?: boolean | "lg";
|
|
64
|
+
hideWhen?: boolean | "below-lg";
|
|
65
|
+
density?: "card" | "row-lg";
|
|
66
|
+
liveStatus?: string;
|
|
67
|
+
priority?: boolean;
|
|
68
|
+
}) {
|
|
69
|
+
return (
|
|
70
|
+
<EventCard
|
|
71
|
+
event={props.ev}
|
|
72
|
+
labels={labels}
|
|
73
|
+
formatters={formatters}
|
|
74
|
+
emphasis={props.emphasis}
|
|
75
|
+
hideWhen={props.hideWhen ?? true}
|
|
76
|
+
density={props.density}
|
|
77
|
+
liveStatus={props.liveStatus}
|
|
78
|
+
showPrice
|
|
79
|
+
tags={["Live music", "Jazz"]}
|
|
80
|
+
href={`#${props.ev.id}`}
|
|
81
|
+
priority={props.priority}
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A six-hour Friday with the hero in the middle: 19:00 opens, the 20:00 hero
|
|
88
|
+
* is lifted into the lane on desktop while its hour's other event stays as a
|
|
89
|
+
* continuation, later hours demote to rows past the cap, and a group of
|
|
90
|
+
* events the ingest never timed trails the day off the axis.
|
|
91
|
+
*/
|
|
92
|
+
export const FullDay: Story = {
|
|
93
|
+
render: () => (
|
|
94
|
+
<div className="flex flex-col gap-3 py-6">
|
|
95
|
+
<EventDayHeader
|
|
96
|
+
dayNumber="03"
|
|
97
|
+
weekday="Thursday"
|
|
98
|
+
meta="September · 9 events · 1 live"
|
|
99
|
+
size="responsive"
|
|
100
|
+
/>
|
|
101
|
+
<TimelineDay rows={6} laneTop="16px">
|
|
102
|
+
<TimelineHour time="19:00" countLabel="2 events">
|
|
103
|
+
<Card ev={event("a", "Vinyl night at the bar", "Joben Bistro", "19:00", 0)} priority />
|
|
104
|
+
<Card ev={event("b", "Pub quiz: nineties", "Klausen", "19:00", 20)} />
|
|
105
|
+
</TimelineHour>
|
|
106
|
+
<TimelineHour time="20:00" role="hero" isLive liveLabel="NOW ON" countLabel="1 event">
|
|
107
|
+
<Card
|
|
108
|
+
ev={event("c", "Subcarpați live — sold-out tour warm-up", "Form Space", "20:00", 60)}
|
|
109
|
+
emphasis="lg"
|
|
110
|
+
hideWhen="below-lg"
|
|
111
|
+
liveStatus="Live now · ends around 23:00"
|
|
112
|
+
/>
|
|
113
|
+
</TimelineHour>
|
|
114
|
+
<TimelineHour time="20:00" role="continuation" isLive liveLabel="NOW ON" countLabel="1 event">
|
|
115
|
+
<Card ev={event("d", "Wine tasting: Dealu Mare", "Enoteca", "20:00", 90)} />
|
|
116
|
+
</TimelineHour>
|
|
117
|
+
<TimelineHour time="21:00" countLabel="3 events" layout="rows">
|
|
118
|
+
<Card ev={event("e", "Comedy open mic", "Flying Circus", "21:00", 25)} density="row-lg" />
|
|
119
|
+
<Card ev={event("f", "Techno: Berlin calling", "Mansion", "21:00", 40)} density="row-lg" />
|
|
120
|
+
<Card ev={event("g", "Salsa social", "Casa Tranzit", "21:00", 0)} density="row-lg" />
|
|
121
|
+
</TimelineHour>
|
|
122
|
+
<TimelineHour time="23:00" countLabel="1 event" layout="rows">
|
|
123
|
+
<Card ev={event("h", "Late-night jazz jam", "Jazz Club", "23:00")} density="row-lg" />
|
|
124
|
+
</TimelineHour>
|
|
125
|
+
<TimelineHour
|
|
126
|
+
time="Time to be confirmed"
|
|
127
|
+
role="unscheduled"
|
|
128
|
+
countLabel="1 event"
|
|
129
|
+
note="The venue has not published a start time."
|
|
130
|
+
>
|
|
131
|
+
<Card ev={event("i", "Board games afternoon", "Insomnia Café", "00:00", 0)} />
|
|
132
|
+
</TimelineHour>
|
|
133
|
+
</TimelineDay>
|
|
134
|
+
</div>
|
|
135
|
+
),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** A quiet Tuesday: one hour, one event, no hero — no lane on desktop either. */
|
|
139
|
+
export const OneHour: Story = {
|
|
140
|
+
render: () => (
|
|
141
|
+
<div className="flex flex-col gap-3 py-6">
|
|
142
|
+
<EventDayHeader dayNumber="08" weekday="Tuesday" meta="September · 1 event" size="responsive" />
|
|
143
|
+
<TimelineDay rows={1}>
|
|
144
|
+
<TimelineHour time="19:30" countLabel="1 event">
|
|
145
|
+
<Card ev={event("a", "Book club: Cărtărescu", "Cărturești", "19:30", 0)} priority />
|
|
146
|
+
</TimelineHour>
|
|
147
|
+
</TimelineDay>
|
|
148
|
+
</div>
|
|
149
|
+
),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Two full days stacked, so the lane's pin and release can be watched at
|
|
154
|
+
* 1024 and 1440: scroll the canvas and the first day's poster holds until
|
|
155
|
+
* its column runs out, then the second day's takes over.
|
|
156
|
+
*/
|
|
157
|
+
export const StickyLane: Story = {
|
|
158
|
+
render: () => (
|
|
159
|
+
<div className="flex flex-col gap-10 py-6">
|
|
160
|
+
{["03", "04"].map((day) => (
|
|
161
|
+
<div key={day} className="flex flex-col gap-3">
|
|
162
|
+
<EventDayHeader
|
|
163
|
+
dayNumber={day}
|
|
164
|
+
weekday={day === "03" ? "Thursday" : "Friday"}
|
|
165
|
+
meta="September · 7 events"
|
|
166
|
+
size="responsive"
|
|
167
|
+
/>
|
|
168
|
+
<TimelineDay rows={4} laneTop="16px">
|
|
169
|
+
<TimelineHour time="18:00" role="hero" countLabel="1 event">
|
|
170
|
+
<Card
|
|
171
|
+
ev={event(`${day}-hero`, "Headliner of the night", "Form Space", "18:00", 50)}
|
|
172
|
+
emphasis="lg"
|
|
173
|
+
hideWhen="below-lg"
|
|
174
|
+
/>
|
|
175
|
+
</TimelineHour>
|
|
176
|
+
{["19:00", "20:00", "21:00", "22:00"].map((hour) => (
|
|
177
|
+
<TimelineHour key={hour} time={hour} countLabel="2 events" layout="rows">
|
|
178
|
+
<Card
|
|
179
|
+
ev={event(`${day}-${hour}-1`, `Something at ${hour}`, "Klausen", hour, 20)}
|
|
180
|
+
density="row-lg"
|
|
181
|
+
/>
|
|
182
|
+
<Card
|
|
183
|
+
ev={event(`${day}-${hour}-2`, `Something else at ${hour}`, "Joben", hour, 0)}
|
|
184
|
+
density="row-lg"
|
|
185
|
+
/>
|
|
186
|
+
</TimelineHour>
|
|
187
|
+
))}
|
|
188
|
+
</TimelineDay>
|
|
189
|
+
</div>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export const Skeleton: Story = {
|
|
196
|
+
render: () => (
|
|
197
|
+
<div className="py-6">
|
|
198
|
+
<TimelineDaySkeleton hourCount={3} />
|
|
199
|
+
</div>
|
|
200
|
+
),
|
|
201
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { TimelineDay } from "./timeline-day";
|
|
4
|
+
import { TimelineHour } from "./timeline-hour";
|
|
5
|
+
|
|
6
|
+
describe("TimelineDay", () => {
|
|
7
|
+
it("renders every hour in source order, hero included, with one puck per stationed hour", () => {
|
|
8
|
+
const { container } = render(
|
|
9
|
+
<TimelineDay rows={2}>
|
|
10
|
+
<TimelineHour time="19:00" countLabel="1 event">
|
|
11
|
+
<p>Early</p>
|
|
12
|
+
</TimelineHour>
|
|
13
|
+
<TimelineHour time="20:00" role="hero">
|
|
14
|
+
<p>Hero</p>
|
|
15
|
+
</TimelineHour>
|
|
16
|
+
<TimelineHour time="21:00" countLabel="1 event">
|
|
17
|
+
<p>Late</p>
|
|
18
|
+
</TimelineHour>
|
|
19
|
+
</TimelineDay>
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const hours = [...container.querySelectorAll("[data-timeline-hour]")];
|
|
23
|
+
expect(hours.map((hour) => hour.getAttribute("data-timeline-hour"))).toEqual([
|
|
24
|
+
"hour",
|
|
25
|
+
"hero",
|
|
26
|
+
"hour",
|
|
27
|
+
]);
|
|
28
|
+
// Chronological in the DOM — the phone's visual order and the reading order.
|
|
29
|
+
expect(container.textContent).toMatch(/19:00[^]*Early[^]*20:00[^]*Hero[^]*21:00[^]*Late/);
|
|
30
|
+
expect(screen.getAllByText(/^\d\d:00$/)).toHaveLength(3);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("sets the row count the lane and spine span", () => {
|
|
34
|
+
const { container } = render(
|
|
35
|
+
<TimelineDay rows={5} laneTop="120px">
|
|
36
|
+
<TimelineHour time="19:00">
|
|
37
|
+
<p>x</p>
|
|
38
|
+
</TimelineHour>
|
|
39
|
+
</TimelineDay>
|
|
40
|
+
);
|
|
41
|
+
const root = container.firstElementChild as HTMLElement;
|
|
42
|
+
expect(root.style.getPropertyValue("--timeline-rows")).toBe("5");
|
|
43
|
+
expect(root.style.getPropertyValue("--timeline-lane-top")).toBe("120px");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("gives an unscheduled group an inline heading and no station", () => {
|
|
47
|
+
const { container } = render(
|
|
48
|
+
<TimelineDay rows={1}>
|
|
49
|
+
<TimelineHour time="Time to be confirmed" role="unscheduled" countLabel="2 events" note="No hour">
|
|
50
|
+
<p>a</p>
|
|
51
|
+
<p>b</p>
|
|
52
|
+
</TimelineHour>
|
|
53
|
+
</TimelineDay>
|
|
54
|
+
);
|
|
55
|
+
expect(screen.getByText("Time to be confirmed")).toBeInTheDocument();
|
|
56
|
+
expect(screen.getByText("2 events")).toBeInTheDocument();
|
|
57
|
+
expect(screen.getByText("No hour")).toBeInTheDocument();
|
|
58
|
+
expect(container.querySelector(".font-mono.tabular-nums.whitespace-nowrap")).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("rules between rows but not between cards", () => {
|
|
62
|
+
const { container: rows } = render(
|
|
63
|
+
<TimelineHour time="21:00" layout="rows">
|
|
64
|
+
<p>a</p>
|
|
65
|
+
<p>b</p>
|
|
66
|
+
</TimelineHour>
|
|
67
|
+
);
|
|
68
|
+
const { container: cards } = render(
|
|
69
|
+
<TimelineHour time="21:00" layout="cards">
|
|
70
|
+
<p>a</p>
|
|
71
|
+
<p>b</p>
|
|
72
|
+
</TimelineHour>
|
|
73
|
+
);
|
|
74
|
+
// The hour's own hairline (vertical) plus the heading rule plus one between the two rows.
|
|
75
|
+
expect(rows.querySelectorAll('[data-orientation="horizontal"]').length).toBe(2);
|
|
76
|
+
expect(cards.querySelectorAll('[data-orientation="horizontal"]').length).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -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,142 @@
|
|
|
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
|
+
// content-width. Every chip utility above has an `lg:` twin.
|
|
116
|
+
// `min-w-max`, NOT `min-w-0`: the host centres the puck inside
|
|
117
|
+
// a zero-width flex box, and a flex item allowed to shrink to 0
|
|
118
|
+
// collapses to a 24px square behind its own text.
|
|
119
|
+
"lg:h-auto lg:min-w-max lg:shrink-0 lg:gap-0 lg:border-border lg:bg-background lg:px-[7px] lg:py-[3px] lg:text-[12.5px] lg:text-foreground",
|
|
120
|
+
isLive && "lg:border-success/40 lg:bg-background lg:text-success"
|
|
121
|
+
)
|
|
122
|
+
)}
|
|
123
|
+
>
|
|
124
|
+
{isLive && variant !== "station" && (
|
|
125
|
+
<Radio className={cn(glyphClasses, variant === "responsive" && "lg:hidden")} aria-hidden />
|
|
126
|
+
)}
|
|
127
|
+
{time}
|
|
128
|
+
</span>
|
|
129
|
+
{isLive && !!liveLabel && (
|
|
130
|
+
<span
|
|
131
|
+
className={cn(
|
|
132
|
+
variant === "chip" && labelHiddenClasses,
|
|
133
|
+
variant === "station" && labelStationClasses,
|
|
134
|
+
variant === "responsive" && labelResponsiveClasses
|
|
135
|
+
)}
|
|
136
|
+
>
|
|
137
|
+
{liveLabel}
|
|
138
|
+
</span>
|
|
139
|
+
)}
|
|
140
|
+
</span>
|
|
141
|
+
);
|
|
142
|
+
}
|