@bearmenu/ui 0.8.7 → 0.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-637L7XQ3.js → chunk-3TNPSN4D.js} +28 -9
- package/dist/chunk-AHL45DD2.js +145 -0
- package/dist/chunk-IYDH5IU6.js +66 -0
- package/dist/chunk-OPVKL4VS.js +18 -0
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/category-deck.js +10 -1
- package/dist/components/coverflow.d.ts +11 -3
- package/dist/components/coverflow.js +34 -51
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/drift-rail.d.ts +24 -10
- package/dist/components/drift-rail.js +1 -1
- package/dist/components/event-card.d.ts +8 -0
- package/dist/components/event-card.js +63 -33
- package/dist/components/event-compact-row.d.ts +32 -5
- package/dist/components/event-compact-row.js +119 -38
- package/dist/components/event-day-header.js +3 -1
- package/dist/components/event-day-separator.d.ts +7 -1
- package/dist/components/event-day-separator.js +3 -2
- package/dist/components/event-hub-hero.d.ts +25 -4
- package/dist/components/event-hub-hero.js +89 -19
- package/dist/components/event-list-row.d.ts +19 -6
- package/dist/components/event-list-row.js +78 -34
- package/dist/components/hour-band.d.ts +38 -40
- package/dist/components/hour-band.js +55 -27
- package/dist/components/hour-group.js +1 -1
- package/dist/components/poster-tape.js +1 -1
- package/dist/components/poster-wall.d.ts +3 -3
- package/dist/components/poster-wall.js +73 -17
- package/dist/components/section-header.d.ts +16 -1
- package/dist/components/section-header.js +26 -7
- package/dist/components/snap-rail.d.ts +34 -0
- package/dist/components/snap-rail.js +51 -0
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +1 -1
- package/dist/components/timeline-band.d.ts +20 -2
- package/dist/components/timeline-band.js +1 -1
- package/dist/components/timeline-playhead.d.ts +26 -0
- package/dist/components/timeline-playhead.js +25 -0
- package/dist/components/weekend-panel.d.ts +5 -4
- package/dist/components/weekend-panel.js +85 -71
- package/dist/hooks/use-drag-swipe.d.ts +44 -0
- package/dist/hooks/use-drag-swipe.js +2 -0
- package/dist/hooks/use-media-query.d.ts +17 -0
- package/dist/hooks/use-media-query.js +2 -0
- package/package.json +1 -1
- package/src/components/category-deck.tsx +19 -8
- package/src/components/coverflow.test.tsx +28 -1
- package/src/components/coverflow.tsx +37 -43
- package/src/components/drift-rail.tsx +167 -42
- package/src/components/event-card.tsx +55 -10
- package/src/components/event-compact-row.stories.tsx +30 -0
- package/src/components/event-compact-row.test.tsx +49 -0
- package/src/components/event-compact-row.tsx +161 -37
- package/src/components/event-day-header.tsx +3 -1
- package/src/components/event-day-separator.tsx +8 -1
- package/src/components/event-hub-hero.stories.tsx +30 -0
- package/src/components/event-hub-hero.tsx +102 -30
- package/src/components/event-list-row.stories.tsx +34 -0
- package/src/components/event-list-row.test.tsx +53 -0
- package/src/components/event-list-row.tsx +135 -87
- package/src/components/hour-band.stories.tsx +28 -5
- package/src/components/hour-band.tsx +90 -70
- package/src/components/poster-wall.test.tsx +28 -0
- package/src/components/poster-wall.tsx +96 -23
- package/src/components/section-header.stories.tsx +24 -0
- package/src/components/section-header.test.tsx +28 -0
- package/src/components/section-header.tsx +45 -7
- package/src/components/snap-rail.stories.tsx +79 -0
- package/src/components/snap-rail.test.tsx +18 -0
- package/src/components/snap-rail.tsx +67 -0
- package/src/components/timeline-band.tsx +52 -12
- package/src/components/timeline-playhead.stories.tsx +30 -0
- package/src/components/timeline-playhead.test.tsx +11 -0
- package/src/components/timeline-playhead.tsx +46 -0
- package/src/components/weekend-panel.test.tsx +43 -0
- package/src/components/weekend-panel.tsx +142 -108
- package/src/hooks/use-drag-swipe.test.tsx +66 -0
- package/src/hooks/use-drag-swipe.ts +105 -0
- package/src/hooks/use-media-query.ts +31 -0
- package/dist/chunk-GPH6L4DX.js +0 -55
|
@@ -4,41 +4,39 @@ import * as React from "react";
|
|
|
4
4
|
import { cn } from "../lib/utils";
|
|
5
5
|
import { Separator } from "./separator";
|
|
6
6
|
import { Skeleton } from "./skeleton";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
TIMELINE_MOBILE_GUTTER_CLASS,
|
|
9
|
+
TimelineHourPuck,
|
|
10
|
+
} from "./timeline-band";
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* HourBand — one hour on the timeline's axis, with that hour's events beside it.
|
|
11
14
|
*
|
|
12
|
-
* The
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* timestamp, which is why `EventCard` has
|
|
15
|
+
* The mobile timeline is an app list, not a page: a 58px gutter carrying a
|
|
16
|
+
* filled hour chip with a hairline dropping from beneath it, and the hour's
|
|
17
|
+
* rows to the right, separated by hairlines of their own. The rows carry no
|
|
18
|
+
* timestamp — this band IS the timestamp, which is why `EventCard` has
|
|
19
|
+
* `hideWhen` and `EventCompactRow` has `hideTime`.
|
|
16
20
|
*
|
|
17
|
-
* The
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* Sharing the object changes this gutter's pixels: the mobile puck now carries
|
|
23
|
-
* a border, which is what makes it read as clipped onto the rail rather than
|
|
24
|
-
* text floating over it, and matches the station on the desktop spine.
|
|
21
|
+
* The chip is the shared `TimelineHourPuck` in its `chip` variant, so the
|
|
22
|
+
* desktop spine's station and this gutter's label are one object with two
|
|
23
|
+
* looks rather than two objects that drift. The hairline starts BELOW the chip
|
|
24
|
+
* rather than running behind it: a chip is a label at the top of its hour, a
|
|
25
|
+
* station is a tick on a continuous rail, and the two read differently.
|
|
25
26
|
*
|
|
26
|
-
* `isLive`
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* `isLive` tints the chip and the hairline with `--success`; the live label is
|
|
28
|
+
* kept for screen readers (the chip variant hides it visually). The playhead —
|
|
29
|
+
* the exact current time — is a separate `TimelinePlayhead` rendered between
|
|
30
|
+
* bands by the caller, because an hour spans sixty minutes and "now" is one of
|
|
31
|
+
* them.
|
|
29
32
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* with the rows beside it.
|
|
38
|
-
*
|
|
39
|
-
* 24px leaves ~12px of real clearance at the widest label. Do not tighten it
|
|
40
|
-
* back without re-deriving against a 12-hour locale: a 24-hour one ("09:00")
|
|
41
|
-
* is ~30px narrower and hides the problem completely.
|
|
33
|
+
* ROWS ARE SEPARATED HERE, NOT IN THE ROW. `EventCompactRow` deliberately
|
|
34
|
+
* draws no divider (its container does — see its docblock), so this band wraps
|
|
35
|
+
* each child and rules between them. The wrapper, not the child, carries the
|
|
36
|
+
* rule, so the row's own negative-margin hover wash is untouched. Children are
|
|
37
|
+
* read with `Children.toArray`, which drops `null`/`false` so a conditional
|
|
38
|
+
* first row never inherits a rule meant for a second; pass rows as siblings,
|
|
39
|
+
* not inside a fragment — a fragment is one child and gets one wrapper.
|
|
42
40
|
*/
|
|
43
41
|
|
|
44
42
|
export type HourBandProps = {
|
|
@@ -46,80 +44,102 @@ export type HourBandProps = {
|
|
|
46
44
|
time: string;
|
|
47
45
|
/** Marks this as the hour currently in progress. */
|
|
48
46
|
isLive?: boolean;
|
|
49
|
-
/**
|
|
47
|
+
/** Screen-reader text for the live hour, e.g. "Now on". Required to render it. */
|
|
50
48
|
liveLabel?: string;
|
|
51
|
-
/** The hour's events. */
|
|
49
|
+
/** The hour's events. Each direct child is one row or card. */
|
|
52
50
|
children: React.ReactNode;
|
|
51
|
+
/**
|
|
52
|
+
* `rows` (default): compact rows, ruled between with a Separator. `cards`:
|
|
53
|
+
* poster cards, stacked with a 16px gap and no rules — a card is its own
|
|
54
|
+
* frame, and a hairline between two posters reads as a third object.
|
|
55
|
+
*/
|
|
56
|
+
layout?: "rows" | "cards";
|
|
53
57
|
className?: string;
|
|
54
58
|
};
|
|
55
59
|
|
|
56
|
-
export function HourBand({
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
export function HourBand({
|
|
61
|
+
time,
|
|
62
|
+
isLive,
|
|
63
|
+
liveLabel,
|
|
64
|
+
children,
|
|
65
|
+
layout = "rows",
|
|
66
|
+
className,
|
|
67
|
+
}: HourBandProps) {
|
|
68
|
+
const isCards = layout === "cards";
|
|
59
69
|
return (
|
|
60
|
-
<div className={cn("flex gap-
|
|
61
|
-
<div className="relative
|
|
62
|
-
<Separator orientation="vertical" className="h-full" />
|
|
70
|
+
<div className={cn("flex gap-3 pt-2.5", className)}>
|
|
71
|
+
<div className={cn("relative shrink-0", TIMELINE_MOBILE_GUTTER_CLASS)}>
|
|
63
72
|
<TimelineHourPuck
|
|
64
73
|
time={time}
|
|
65
74
|
isLive={isLive}
|
|
66
75
|
liveLabel={liveLabel}
|
|
67
|
-
|
|
76
|
+
variant="chip"
|
|
77
|
+
/>
|
|
78
|
+
<Separator
|
|
79
|
+
orientation="vertical"
|
|
80
|
+
className={cn(
|
|
81
|
+
"absolute bottom-[-10px] left-1/2 top-[30px] h-auto -translate-x-1/2",
|
|
82
|
+
isLive && "bg-success/30",
|
|
83
|
+
)}
|
|
68
84
|
/>
|
|
69
85
|
</div>
|
|
70
86
|
|
|
71
87
|
<div
|
|
72
|
-
className={cn(
|
|
73
|
-
"flex min-w-0 flex-1 flex-col gap-5 pb-2",
|
|
74
|
-
showLiveLabel ? "pt-[34px]" : "pt-1"
|
|
75
|
-
)}
|
|
88
|
+
className={cn("flex min-w-0 flex-1 flex-col", isCards && "gap-4 pb-2")}
|
|
76
89
|
>
|
|
77
|
-
{children
|
|
90
|
+
{React.Children.toArray(children).map((child, index) => (
|
|
91
|
+
<div key={index}>
|
|
92
|
+
{!isCards && index > 0 && <Separator />}
|
|
93
|
+
{child}
|
|
94
|
+
</div>
|
|
95
|
+
))}
|
|
78
96
|
</div>
|
|
79
97
|
</div>
|
|
80
98
|
);
|
|
81
99
|
}
|
|
82
100
|
|
|
83
101
|
export type HourBandSkeletonProps = {
|
|
84
|
-
/**
|
|
102
|
+
/** Rows to reserve space for. */
|
|
85
103
|
cardCount?: number;
|
|
86
104
|
className?: string;
|
|
87
105
|
};
|
|
88
106
|
|
|
89
107
|
/**
|
|
90
|
-
* The mobile timeline's loading state, mirroring `HourBand` exactly — same
|
|
91
|
-
* gutter, same hairline, same
|
|
108
|
+
* The mobile timeline's loading state, mirroring `HourBand` exactly — same
|
|
109
|
+
* gutter, same hairline, same row rhythm — so nothing jumps when data lands.
|
|
92
110
|
*
|
|
93
|
-
* The
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* Card footprints come from the design's own loading frame: a 220px poster with
|
|
98
|
-
* an 16px title bar and a 12px meta bar, then a 170px poster with a 15px title.
|
|
111
|
+
* The chip is blocked out at its real size rather than omitted: it is a fixed
|
|
112
|
+
* 58x24 lozenge, so a grey one reads as "a time goes here" without inventing
|
|
113
|
+
* a time. Row footprints mirror `EventCompactRowSkeleton`.
|
|
99
114
|
*/
|
|
100
|
-
export function HourBandSkeleton({
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
];
|
|
105
|
-
|
|
115
|
+
export function HourBandSkeleton({
|
|
116
|
+
cardCount = 2,
|
|
117
|
+
className,
|
|
118
|
+
}: HourBandSkeletonProps) {
|
|
106
119
|
return (
|
|
107
|
-
<div className={cn("flex gap-
|
|
108
|
-
<div className="relative
|
|
109
|
-
<
|
|
120
|
+
<div className={cn("flex gap-3 pt-2.5", className)} aria-hidden="true">
|
|
121
|
+
<div className={cn("relative shrink-0", TIMELINE_MOBILE_GUTTER_CLASS)}>
|
|
122
|
+
<Skeleton className="h-6 w-[58px] rounded-[7px]" />
|
|
123
|
+
<Separator
|
|
124
|
+
orientation="vertical"
|
|
125
|
+
className="absolute bottom-[-10px] left-1/2 top-[30px] h-auto -translate-x-1/2"
|
|
126
|
+
/>
|
|
110
127
|
</div>
|
|
111
128
|
|
|
112
|
-
<div className="flex min-w-0 flex-1 flex-col
|
|
113
|
-
{Array.from({ length: cardCount }).map((_, index) =>
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
<div
|
|
117
|
-
<Skeleton className="w-
|
|
118
|
-
<
|
|
119
|
-
|
|
129
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
130
|
+
{Array.from({ length: cardCount }).map((_, index) => (
|
|
131
|
+
<div key={index}>
|
|
132
|
+
{index > 0 && <Separator />}
|
|
133
|
+
<div className="flex gap-3 py-[11px]">
|
|
134
|
+
<Skeleton className="aspect-[4/5] w-16 shrink-0 rounded-[10px]" />
|
|
135
|
+
<div className="min-w-0 flex-1">
|
|
136
|
+
<Skeleton className="h-[14px] w-3/4" />
|
|
137
|
+
<Skeleton className="mt-1.5 h-[11.5px] w-1/2" />
|
|
138
|
+
<Skeleton className="mt-2 h-[22px] w-16 rounded-full" />
|
|
139
|
+
</div>
|
|
120
140
|
</div>
|
|
121
|
-
|
|
122
|
-
|
|
141
|
+
</div>
|
|
142
|
+
))}
|
|
123
143
|
</div>
|
|
124
144
|
</div>
|
|
125
145
|
);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render } from "@testing-library/react";
|
|
3
|
+
import { PosterWall } from "./poster-wall";
|
|
4
|
+
|
|
5
|
+
const grid = (container: HTMLElement) =>
|
|
6
|
+
container.querySelector<HTMLElement>("[aria-hidden].grid")!;
|
|
7
|
+
|
|
8
|
+
describe("PosterWall grid", () => {
|
|
9
|
+
it("sizes the grid to the tiles and completes the last row by cycling them", () => {
|
|
10
|
+
const tiles = Array.from({ length: 5 }, (_, i) => ({ src: `p${i}.jpg` }));
|
|
11
|
+
const { container } = render(
|
|
12
|
+
<PosterWall tiles={tiles} variant="compact" />,
|
|
13
|
+
);
|
|
14
|
+
const g = grid(container);
|
|
15
|
+
// compact: width 390, height 330 → cols = round(√(5·1.18)) = 2, rows = 3 → 6 slots
|
|
16
|
+
expect(g.style.gridTemplateColumns).toBe("repeat(2, minmax(0, 1fr))");
|
|
17
|
+
expect(g.querySelectorAll("img")).toHaveLength(6);
|
|
18
|
+
expect(g.querySelectorAll("img")[5].getAttribute("src")).toBe("p0.jpg");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("keeps the placeholder grid when there are no pictures", () => {
|
|
22
|
+
const { container } = render(<PosterWall tiles={[]} variant="compact" />);
|
|
23
|
+
const g = grid(container);
|
|
24
|
+
expect(g.style.gridTemplateColumns).toBe("repeat(6, minmax(0, 1fr))");
|
|
25
|
+
expect(g.children).toHaveLength(36);
|
|
26
|
+
expect(g.querySelectorAll("img")).toHaveLength(0);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -17,6 +17,14 @@ import { Skeleton } from "./skeleton";
|
|
|
17
17
|
* the container's size, alignment and padding for the given `variant`. All
|
|
18
18
|
* overlay typography belongs to the caller.
|
|
19
19
|
*
|
|
20
|
+
* THE GRID FOLLOWS THE TILES. It used to be a fixed 12×4 (48) or 6×6 (36)
|
|
21
|
+
* grid, and a city with 28 posters left twenty empty cells — a dark hole down
|
|
22
|
+
* one side of the masthead. Now the column count comes from the tile count
|
|
23
|
+
* and the wall's measured aspect ratio (`cols ≈ √(n · w/h)`, so tiles stay
|
|
24
|
+
* roughly square), rows follow, and the last row is completed by cycling the
|
|
25
|
+
* tiles. Fewer pictures means fewer, bigger tiles; never a gap. The default
|
|
26
|
+
* grid is kept for the server render and for an empty tile list.
|
|
27
|
+
*
|
|
20
28
|
* The sweep is a genuine `@keyframes` animation (not Framer Motion) because
|
|
21
29
|
* its exact timing is part of the design spec and it needs to keep running
|
|
22
30
|
* indefinitely at zero React cost — the keyframes are scoped to this file via
|
|
@@ -43,16 +51,22 @@ function usePrefersReducedMotion(): boolean {
|
|
|
43
51
|
return reduced;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
function useInViewport<T extends Element>(): [
|
|
54
|
+
function useInViewport<T extends Element>(): [
|
|
55
|
+
React.RefObject<T | null>,
|
|
56
|
+
boolean,
|
|
57
|
+
] {
|
|
47
58
|
const ref = React.useRef<T | null>(null);
|
|
48
59
|
const [inView, setInView] = React.useState(true);
|
|
49
60
|
|
|
50
61
|
React.useEffect(() => {
|
|
51
62
|
const node = ref.current;
|
|
52
63
|
if (!node || typeof IntersectionObserver === "undefined") return;
|
|
53
|
-
const observer = new IntersectionObserver(
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
const observer = new IntersectionObserver(
|
|
65
|
+
([entry]) => setInView(entry.isIntersecting),
|
|
66
|
+
{
|
|
67
|
+
threshold: 0,
|
|
68
|
+
},
|
|
69
|
+
);
|
|
56
70
|
observer.observe(node);
|
|
57
71
|
return () => observer.disconnect();
|
|
58
72
|
}, []);
|
|
@@ -65,7 +79,7 @@ export type PosterWallTile = {
|
|
|
65
79
|
};
|
|
66
80
|
|
|
67
81
|
export type PosterWallProps = {
|
|
68
|
-
/** Background tiles.
|
|
82
|
+
/** Background tiles. The grid sizes itself to how many there are (see above); with none, a placeholder grid renders. */
|
|
69
83
|
tiles?: PosterWallTile[];
|
|
70
84
|
/** `compact` is the bottom-aligned mobile treatment (fewer, larger tiles, a top-fading veil). */
|
|
71
85
|
variant?: "default" | "compact";
|
|
@@ -74,22 +88,64 @@ export type PosterWallProps = {
|
|
|
74
88
|
className?: string;
|
|
75
89
|
};
|
|
76
90
|
|
|
77
|
-
export function PosterWall({
|
|
91
|
+
export function PosterWall({
|
|
92
|
+
tiles,
|
|
93
|
+
variant = "default",
|
|
94
|
+
children,
|
|
95
|
+
className,
|
|
96
|
+
}: PosterWallProps) {
|
|
78
97
|
const isCompact = variant === "compact";
|
|
79
|
-
const
|
|
80
|
-
const
|
|
98
|
+
const maxCols = isCompact ? 6 : 12;
|
|
99
|
+
const maxSlots = isCompact
|
|
100
|
+
? POSTER_WALL_COMPACT_TILE_COUNT
|
|
101
|
+
: POSTER_WALL_DESKTOP_TILE_COUNT;
|
|
102
|
+
const heightPx = isCompact ? 330 : 420;
|
|
103
|
+
const pictures = React.useMemo(
|
|
104
|
+
() => (tiles ?? []).filter((tile) => !!tile.src),
|
|
105
|
+
[tiles],
|
|
106
|
+
);
|
|
81
107
|
|
|
82
108
|
const prefersReducedMotion = usePrefersReducedMotion();
|
|
83
109
|
const [wallRef, inView] = useInViewport<HTMLDivElement>();
|
|
84
110
|
const isSweepPaused = prefersReducedMotion || !inView;
|
|
85
111
|
|
|
112
|
+
// Measured on the client; the server assumes a phone or a wide desktop
|
|
113
|
+
// column, which is what those two variants are for.
|
|
114
|
+
const [width, setWidth] = React.useState(isCompact ? 390 : 1200);
|
|
115
|
+
React.useEffect(() => {
|
|
116
|
+
const node = wallRef.current;
|
|
117
|
+
if (!node || typeof ResizeObserver === "undefined") return;
|
|
118
|
+
const observer = new ResizeObserver(([entry]) =>
|
|
119
|
+
setWidth(entry.contentRect.width),
|
|
120
|
+
);
|
|
121
|
+
observer.observe(node);
|
|
122
|
+
return () => observer.disconnect();
|
|
123
|
+
}, [wallRef]);
|
|
124
|
+
|
|
125
|
+
let cols = maxCols;
|
|
126
|
+
let slots: PosterWallTile[];
|
|
127
|
+
if (pictures.length === 0) {
|
|
128
|
+
slots = Array.from({ length: maxSlots }, (): PosterWallTile => ({}));
|
|
129
|
+
} else {
|
|
130
|
+
const shown = Math.min(pictures.length, maxSlots);
|
|
131
|
+
cols = Math.min(
|
|
132
|
+
maxCols,
|
|
133
|
+
Math.max(2, Math.round(Math.sqrt((shown * width) / heightPx))),
|
|
134
|
+
);
|
|
135
|
+
const rows = Math.max(1, Math.ceil(shown / cols));
|
|
136
|
+
slots = Array.from(
|
|
137
|
+
{ length: cols * rows },
|
|
138
|
+
(_, index) => pictures[index % pictures.length],
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
86
142
|
return (
|
|
87
143
|
<div
|
|
88
144
|
ref={wallRef}
|
|
89
145
|
className={cn(
|
|
90
146
|
"relative overflow-hidden rounded-[18px] bg-[oklch(0.22_0.012_60)]",
|
|
91
147
|
isCompact ? "h-[330px]" : "h-[420px]",
|
|
92
|
-
className
|
|
148
|
+
className,
|
|
93
149
|
)}
|
|
94
150
|
>
|
|
95
151
|
<style>{`
|
|
@@ -100,10 +156,8 @@ export function PosterWall({ tiles, variant = "default", children, className }:
|
|
|
100
156
|
`}</style>
|
|
101
157
|
|
|
102
158
|
<div
|
|
103
|
-
className=
|
|
104
|
-
|
|
105
|
-
isCompact ? "grid-cols-6" : "grid-cols-12"
|
|
106
|
-
)}
|
|
159
|
+
className="absolute inset-0 grid auto-rows-fr gap-[2px] opacity-50"
|
|
160
|
+
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
|
|
107
161
|
aria-hidden
|
|
108
162
|
>
|
|
109
163
|
{slots.map((tile, index) =>
|
|
@@ -122,7 +176,7 @@ export function PosterWall({ tiles, variant = "default", children, className }:
|
|
|
122
176
|
key={index}
|
|
123
177
|
className="h-full w-full bg-[repeating-linear-gradient(45deg,color-mix(in_oklch,white_10%,transparent)_0px,color-mix(in_oklch,white_10%,transparent)_2px,transparent_2px,transparent_8px)]"
|
|
124
178
|
/>
|
|
125
|
-
)
|
|
179
|
+
),
|
|
126
180
|
)}
|
|
127
181
|
</div>
|
|
128
182
|
|
|
@@ -157,7 +211,9 @@ export function PosterWall({ tiles, variant = "default", children, className }:
|
|
|
157
211
|
<div
|
|
158
212
|
className={cn(
|
|
159
213
|
"relative flex h-full min-w-0 flex-col text-white",
|
|
160
|
-
isCompact
|
|
214
|
+
isCompact
|
|
215
|
+
? "justify-end gap-[11px] p-5"
|
|
216
|
+
: "justify-center gap-[15px] px-12",
|
|
161
217
|
)}
|
|
162
218
|
>
|
|
163
219
|
{children}
|
|
@@ -173,9 +229,14 @@ export type PosterWallSkeletonProps = {
|
|
|
173
229
|
};
|
|
174
230
|
|
|
175
231
|
/** The tile grid at rest — the sweeping light never renders in the loading state. */
|
|
176
|
-
export function PosterWallSkeleton({
|
|
232
|
+
export function PosterWallSkeleton({
|
|
233
|
+
variant = "default",
|
|
234
|
+
className,
|
|
235
|
+
}: PosterWallSkeletonProps) {
|
|
177
236
|
const isCompact = variant === "compact";
|
|
178
|
-
const tileCount = isCompact
|
|
237
|
+
const tileCount = isCompact
|
|
238
|
+
? POSTER_WALL_COMPACT_TILE_COUNT
|
|
239
|
+
: POSTER_WALL_DESKTOP_TILE_COUNT;
|
|
179
240
|
|
|
180
241
|
return (
|
|
181
242
|
<div
|
|
@@ -183,17 +244,20 @@ export function PosterWallSkeleton({ variant = "default", className }: PosterWal
|
|
|
183
244
|
className={cn(
|
|
184
245
|
"relative overflow-hidden rounded-[18px] bg-[oklch(0.22_0.012_60)]",
|
|
185
246
|
isCompact ? "h-[330px]" : "h-[420px]",
|
|
186
|
-
className
|
|
247
|
+
className,
|
|
187
248
|
)}
|
|
188
249
|
>
|
|
189
250
|
<div
|
|
190
251
|
className={cn(
|
|
191
252
|
"absolute inset-0 grid auto-rows-fr gap-[2px] opacity-50",
|
|
192
|
-
isCompact ? "grid-cols-6" : "grid-cols-12"
|
|
253
|
+
isCompact ? "grid-cols-6" : "grid-cols-12",
|
|
193
254
|
)}
|
|
194
255
|
>
|
|
195
256
|
{Array.from({ length: tileCount }, (_, index) => (
|
|
196
|
-
<Skeleton
|
|
257
|
+
<Skeleton
|
|
258
|
+
key={index}
|
|
259
|
+
className="h-full w-full rounded-none bg-white/10"
|
|
260
|
+
/>
|
|
197
261
|
))}
|
|
198
262
|
</div>
|
|
199
263
|
|
|
@@ -209,11 +273,20 @@ export function PosterWallSkeleton({ variant = "default", className }: PosterWal
|
|
|
209
273
|
<div
|
|
210
274
|
className={cn(
|
|
211
275
|
"relative flex h-full min-w-0 flex-col",
|
|
212
|
-
isCompact
|
|
276
|
+
isCompact
|
|
277
|
+
? "justify-end gap-[11px] p-5"
|
|
278
|
+
: "justify-center gap-[15px] px-12",
|
|
213
279
|
)}
|
|
214
280
|
>
|
|
215
|
-
<Skeleton
|
|
216
|
-
|
|
281
|
+
<Skeleton
|
|
282
|
+
className={cn(
|
|
283
|
+
"bg-white/15",
|
|
284
|
+
isCompact ? "h-[11px] w-40" : "h-3 w-52",
|
|
285
|
+
)}
|
|
286
|
+
/>
|
|
287
|
+
<Skeleton
|
|
288
|
+
className={cn("bg-white/20", isCompact ? "h-7 w-64" : "h-10 w-96")}
|
|
289
|
+
/>
|
|
217
290
|
</div>
|
|
218
291
|
</div>
|
|
219
292
|
);
|
|
@@ -68,3 +68,27 @@ export const LoadingVsLoaded: Story = {
|
|
|
68
68
|
</div>
|
|
69
69
|
),
|
|
70
70
|
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The app-card header: title, count, and an accent "All ›" in the count's
|
|
74
|
+
* size tier. Both the tone and the subtitle are phone-scoped — resize past
|
|
75
|
+
* `lg` to see the muted action and the subtitle return.
|
|
76
|
+
*/
|
|
77
|
+
export const AppCard: Story = {
|
|
78
|
+
args: {
|
|
79
|
+
title: "Tonight",
|
|
80
|
+
count: 7,
|
|
81
|
+
subtitle: "Everything still to start before midnight",
|
|
82
|
+
subtitleFrom: "lg",
|
|
83
|
+
action: "All 15",
|
|
84
|
+
actionHref: "#",
|
|
85
|
+
actionTone: "app",
|
|
86
|
+
},
|
|
87
|
+
decorators: [
|
|
88
|
+
(Story) => (
|
|
89
|
+
<div className="w-[366px] rounded-[18px] bg-card px-3.5 pt-4">
|
|
90
|
+
<Story />
|
|
91
|
+
</div>
|
|
92
|
+
),
|
|
93
|
+
],
|
|
94
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { SectionHeader } from "./section-header";
|
|
4
|
+
|
|
5
|
+
describe("SectionHeader", () => {
|
|
6
|
+
it("renders title, count and a linked action", () => {
|
|
7
|
+
render(<SectionHeader title="Tonight" count={7} action="See all" actionHref="/events" />);
|
|
8
|
+
expect(screen.getByRole("heading", { name: "Tonight" })).toBeInTheDocument();
|
|
9
|
+
expect(screen.getByText("7")).toBeInTheDocument();
|
|
10
|
+
expect(screen.getByRole("link", { name: "See all" })).toHaveAttribute("href", "/events");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("gives the app tone its accent classes and the muted tone none of them", () => {
|
|
14
|
+
const { rerender } = render(
|
|
15
|
+
<SectionHeader title="Tonight" action="See all" actionHref="/events" actionTone="app" />
|
|
16
|
+
);
|
|
17
|
+
expect(screen.getByRole("link", { name: "See all" }).className).toContain("text-accent");
|
|
18
|
+
|
|
19
|
+
rerender(<SectionHeader title="Tonight" action="See all" actionHref="/events" />);
|
|
20
|
+
expect(screen.getByRole("link", { name: "See all" }).className).not.toContain("text-accent");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("keeps the subtitle in the DOM but hides it below lg with subtitleFrom", () => {
|
|
24
|
+
render(<SectionHeader title="Tonight" subtitle="Still to start" subtitleFrom="lg" />);
|
|
25
|
+
expect(screen.getByText("Still to start").className).toContain("hidden");
|
|
26
|
+
expect(screen.getByText("Still to start").className).toContain("lg:block");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -35,6 +35,21 @@ export type SectionHeaderProps = {
|
|
|
35
35
|
/** Trailing action label, e.g. "See all". */
|
|
36
36
|
action?: string;
|
|
37
37
|
actionHref?: string;
|
|
38
|
+
/**
|
|
39
|
+
* `muted` (default): 14px in `--muted-foreground`, at every width. `app`:
|
|
40
|
+
* BELOW `lg`, 12.5px semibold in `--accent` — the way an app's "All ›" reads
|
|
41
|
+
* at the end of a card's title row, quieter in size and louder in colour so
|
|
42
|
+
* it sits in the count's tier rather than competing with the title. From
|
|
43
|
+
* `lg` it falls back to `muted`: the app treatment is a phone treatment, and
|
|
44
|
+
* the desktop page it shares a header with is unchanged by it.
|
|
45
|
+
*/
|
|
46
|
+
actionTone?: "muted" | "app";
|
|
47
|
+
/**
|
|
48
|
+
* `always` (default) shows the subtitle at every width. `lg` hides it below
|
|
49
|
+
* the `lg` breakpoint for surfaces that narrate on desktop and not on a
|
|
50
|
+
* phone, where a card title with a count is the whole header.
|
|
51
|
+
*/
|
|
52
|
+
subtitleFrom?: "always" | "lg";
|
|
38
53
|
/** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
|
|
39
54
|
linkComponent?: React.ComponentType<SectionHeaderLinkProps>;
|
|
40
55
|
onAction?: (mouseEvent: React.MouseEvent) => void;
|
|
@@ -48,24 +63,38 @@ export type SectionHeaderProps = {
|
|
|
48
63
|
className?: string;
|
|
49
64
|
};
|
|
50
65
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
66
|
+
// `-my-2 py-2 -mr-1 pr-1`: the label keeps its visual size while its hit area
|
|
67
|
+
// grows to the 44px minimum. Negative margins cancel the padding so the row
|
|
68
|
+
// height and the right edge do not move.
|
|
69
|
+
const ACTION_BASE_CLASSES = cn(
|
|
70
|
+
"inline-flex min-h-11 shrink-0 items-center gap-0.5 rounded-sm -my-2 py-2 -mr-1 pr-1",
|
|
71
|
+
"transition-colors duration-150 motion-reduce:transition-none",
|
|
54
72
|
"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
55
73
|
);
|
|
56
74
|
|
|
75
|
+
const ACTION_TONE_CLASSES = {
|
|
76
|
+
muted: "text-[14px] text-muted-foreground hover:text-foreground",
|
|
77
|
+
app: cn(
|
|
78
|
+
"text-[12.5px] font-semibold text-accent hover:text-accent/80",
|
|
79
|
+
"lg:text-[14px] lg:font-normal lg:text-muted-foreground lg:hover:text-foreground"
|
|
80
|
+
),
|
|
81
|
+
} as const;
|
|
82
|
+
|
|
57
83
|
export function SectionHeader({
|
|
58
84
|
title,
|
|
59
85
|
count,
|
|
60
86
|
subtitle,
|
|
61
87
|
action,
|
|
62
88
|
actionHref,
|
|
89
|
+
actionTone = "muted",
|
|
90
|
+
subtitleFrom = "always",
|
|
63
91
|
linkComponent: LinkComponent = DefaultLink,
|
|
64
92
|
onAction,
|
|
65
93
|
as: Heading = "h2",
|
|
66
94
|
className,
|
|
67
95
|
}: SectionHeaderProps) {
|
|
68
96
|
const actionIcon = <ChevronRight className="h-3.5 w-3.5" aria-hidden />;
|
|
97
|
+
const actionClasses = cn(ACTION_BASE_CLASSES, ACTION_TONE_CLASSES[actionTone]);
|
|
69
98
|
|
|
70
99
|
return (
|
|
71
100
|
<div className={cn("flex flex-col mb-3.5", className)}>
|
|
@@ -75,18 +104,20 @@ export function SectionHeader({
|
|
|
75
104
|
{title}
|
|
76
105
|
</Heading>
|
|
77
106
|
{count !== undefined && (
|
|
78
|
-
<span className="ml-
|
|
107
|
+
<span className="ml-1.5 shrink-0 text-[12px] tabular-nums text-muted-foreground lg:ml-0.5">
|
|
108
|
+
{count}
|
|
109
|
+
</span>
|
|
79
110
|
)}
|
|
80
111
|
</div>
|
|
81
112
|
|
|
82
113
|
{action &&
|
|
83
114
|
(actionHref ? (
|
|
84
|
-
<LinkComponent href={actionHref} className={
|
|
115
|
+
<LinkComponent href={actionHref} className={actionClasses} onClick={onAction}>
|
|
85
116
|
{action}
|
|
86
117
|
{actionIcon}
|
|
87
118
|
</LinkComponent>
|
|
88
119
|
) : onAction ? (
|
|
89
|
-
<button type="button" className={
|
|
120
|
+
<button type="button" className={actionClasses} onClick={onAction}>
|
|
90
121
|
{action}
|
|
91
122
|
{actionIcon}
|
|
92
123
|
</button>
|
|
@@ -94,7 +125,14 @@ export function SectionHeader({
|
|
|
94
125
|
</div>
|
|
95
126
|
|
|
96
127
|
{subtitle && (
|
|
97
|
-
<p
|
|
128
|
+
<p
|
|
129
|
+
className={cn(
|
|
130
|
+
"mt-0.5 text-[13px] text-muted-foreground md:text-[14px]",
|
|
131
|
+
subtitleFrom === "lg" && "hidden lg:block"
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
{subtitle}
|
|
135
|
+
</p>
|
|
98
136
|
)}
|
|
99
137
|
</div>
|
|
100
138
|
);
|