@bearmenu/ui 0.8.17 → 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.
- package/dist/{chunk-WYTJR2YU.js → chunk-5CPBSNWQ.js} +6 -1
- package/dist/{chunk-54FQQZZJ.js → chunk-EQYB4Q77.js} +1 -1
- package/dist/chunk-LYREUZH6.js +156 -0
- package/dist/chunk-PW247TG2.js +81 -0
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/card.d.ts +2 -2
- package/dist/components/command.d.ts +15 -15
- 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-door.d.ts +41 -0
- package/dist/components/event-door.js +65 -0
- package/dist/components/event-hub-hero.d.ts +10 -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/item.d.ts +2 -2
- package/dist/components/menu-hub-hero.d.ts +7 -1
- package/dist/components/menu-hub-hero.js +2 -1
- package/dist/components/place-card.js +1 -1
- package/dist/components/poster-stack.d.ts +36 -3
- package/dist/components/poster-stack.js +49 -18
- package/dist/components/snap-rail.d.ts +2 -2
- package/dist/components/snap-rail.js +1 -1
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +3 -3
- 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-door.stories.tsx +88 -0
- package/src/components/event-door.tsx +97 -0
- package/src/components/event-hub-hero.tsx +21 -3
- package/src/components/event-tonight-strip.stories.tsx +78 -0
- package/src/components/event-tonight-strip.test.tsx +59 -0
- package/src/components/event-tonight-strip.tsx +134 -0
- 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 +98 -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 +139 -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,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,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-
|
|
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
|