@bearmenu/ui 0.8.43 → 0.8.45
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-CLFE4U5T.js → chunk-6PQINSAA.js} +1 -1
- package/dist/{chunk-K6ACNU2L.js → chunk-GHKTVHQG.js} +1 -1
- package/dist/{chunk-AHL45DD2.js → chunk-HJTENOB2.js} +31 -16
- package/dist/{chunk-WHUG6QEI.js → chunk-RYPPN2GZ.js} +12 -2
- package/dist/{chunk-RQPUBKFU.js → chunk-VXGIDKXV.js} +1 -1
- package/dist/components/alert-dialog.js +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/booking-row.js +1 -1
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.js +1 -1
- package/dist/components/carousel.js +1 -1
- package/dist/components/coach-marks.js +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/concept-convergence.js +1 -1
- package/dist/components/drift-rail.js +1 -1
- package/dist/components/empty-state.js +1 -1
- package/dist/components/event-detail-panel.js +1 -1
- package/dist/components/event-index-row.js +3 -3
- package/dist/components/event-list-row-wide.js +1 -1
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/link-button.js +1 -1
- package/dist/components/menu-family-block.js +1 -1
- package/dist/components/menu-index-list.js +3 -3
- package/dist/components/menu-item.js +3 -3
- package/dist/components/multi-select-combobox.js +2 -2
- package/dist/components/pagination.js +1 -1
- package/dist/components/pan-zoom-viewport.js +1 -1
- package/dist/components/place-card.js +1 -1
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/poster-stack.d.ts +3 -0
- package/dist/components/poster-stack.js +2 -1
- package/dist/components/poster-tape.js +1 -1
- package/dist/components/responsive-overlay.js +24 -13
- package/dist/components/searchable-select.js +1 -1
- package/dist/components/section-header.js +3 -3
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/sliding-panels.js +1 -1
- package/dist/components/top-ranking-entry.js +3 -3
- package/package.json +1 -1
- package/src/components/badge.stories.tsx +20 -0
- package/src/components/badge.test.tsx +59 -0
- package/src/components/badge.tsx +12 -2
- package/src/components/button.test.tsx +2 -1
- package/src/components/button.tsx +1 -1
- package/src/components/drift-rail.test.tsx +101 -0
- package/src/components/drift-rail.tsx +45 -23
- package/src/components/empty-state.test.tsx +3 -3
- package/src/components/event-index-row.tsx +3 -3
- package/src/components/menu-index-list.tsx +2 -2
- package/src/components/poster-stack.test.tsx +47 -0
- package/src/components/poster-stack.tsx +5 -1
- package/src/components/responsive-overlay.stories.tsx +26 -1
- package/src/components/responsive-overlay.test.tsx +49 -0
- package/src/components/responsive-overlay.tsx +45 -13
- package/src/components/section-header.tsx +3 -3
- package/src/globals.css +5 -1
|
@@ -25,7 +25,7 @@ var buttonVariants = cva(
|
|
|
25
25
|
* surface: the fill darkens a notch, the border firms up, the type
|
|
26
26
|
* stays put. `--accent-subtle` is that step; `active:` goes one more.
|
|
27
27
|
*/
|
|
28
|
-
outline: "border border-
|
|
28
|
+
outline: "border border-input bg-card hover:bg-accent-subtle hover:border-accent/40 active:bg-muted",
|
|
29
29
|
secondary: "bg-secondary text-secondary-foreground shadow-sm border border-border hover:bg-accent-subtle hover:border-accent/40 active:bg-muted",
|
|
30
30
|
ghost: "hover:bg-accent-subtle active:bg-muted",
|
|
31
31
|
link: "text-primary underline-offset-4 hover:underline",
|
|
@@ -36,35 +36,50 @@ function DriftRail({
|
|
|
36
36
|
observer.observe(node);
|
|
37
37
|
return () => observer.disconnect();
|
|
38
38
|
}, []);
|
|
39
|
+
const half = useRef(0);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const track = trackRef.current;
|
|
42
|
+
if (!track || typeof ResizeObserver === "undefined") return void 0;
|
|
43
|
+
const observer = new ResizeObserver(() => {
|
|
44
|
+
half.current = track.scrollWidth / 2;
|
|
45
|
+
});
|
|
46
|
+
observer.observe(track);
|
|
47
|
+
return () => observer.disconnect();
|
|
48
|
+
}, []);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const scroller = scrollerRef.current;
|
|
51
|
+
if (!scroller) return void 0;
|
|
52
|
+
const onScroll = () => {
|
|
53
|
+
if (half.current > 0 && scroller.scrollLeft >= half.current) {
|
|
54
|
+
scroller.scrollLeft -= half.current;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
scroller.addEventListener("scroll", onScroll, { passive: true });
|
|
58
|
+
return () => scroller.removeEventListener("scroll", onScroll);
|
|
59
|
+
}, []);
|
|
39
60
|
useEffect(() => {
|
|
40
61
|
const scroller = scrollerRef.current;
|
|
41
62
|
const track = trackRef.current;
|
|
42
63
|
if (!scroller || !track) return void 0;
|
|
64
|
+
if (reverse && scroller.scrollLeft === 0) scroller.scrollLeft = track.scrollWidth / 2;
|
|
65
|
+
const drifting = !reducedMotion && inView && !(pauseOnHover && isHovered);
|
|
66
|
+
if (!drifting) return void 0;
|
|
43
67
|
let frame = 0;
|
|
44
68
|
let last = performance.now();
|
|
45
|
-
const drifting = !reducedMotion && inView && !(pauseOnHover && isHovered);
|
|
46
|
-
if (reverse && scroller.scrollLeft === 0) scroller.scrollLeft = track.scrollWidth / 2;
|
|
47
69
|
position.current = scroller.scrollLeft;
|
|
48
70
|
const tick = (now) => {
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const perSecond = half / durationSeconds;
|
|
71
|
+
const width = half.current;
|
|
72
|
+
if (width > 0) {
|
|
73
|
+
if (now >= heldUntil.current) {
|
|
74
|
+
const perSecond = width / durationSeconds;
|
|
54
75
|
const delta = perSecond * (now - last) / 1e3;
|
|
55
76
|
position.current += reverse ? -delta : delta;
|
|
77
|
+
if (position.current >= width) position.current -= width;
|
|
78
|
+
else if (position.current < 0) position.current += width;
|
|
79
|
+
scroller.scrollLeft = position.current;
|
|
56
80
|
} else {
|
|
57
81
|
position.current = scroller.scrollLeft;
|
|
58
82
|
}
|
|
59
|
-
let wrapped = false;
|
|
60
|
-
if (position.current >= half) {
|
|
61
|
-
position.current -= half;
|
|
62
|
-
wrapped = true;
|
|
63
|
-
} else if (position.current < 0) {
|
|
64
|
-
position.current += half;
|
|
65
|
-
wrapped = true;
|
|
66
|
-
}
|
|
67
|
-
if (driftingNow || wrapped) scroller.scrollLeft = position.current;
|
|
68
83
|
}
|
|
69
84
|
last = now;
|
|
70
85
|
frame = requestAnimationFrame(tick);
|
|
@@ -79,8 +79,9 @@ var badgeVariants = cva(
|
|
|
79
79
|
* coming after the variant classes.
|
|
80
80
|
*/
|
|
81
81
|
size: {
|
|
82
|
-
chip: "",
|
|
83
|
-
|
|
82
|
+
chip: "rounded-sm",
|
|
83
|
+
// measured: status badge radius 7px, no token
|
|
84
|
+
status: "gap-1 px-2 py-[3px] text-xs leading-[18px] font-semibold rounded-[7px]"
|
|
84
85
|
},
|
|
85
86
|
// v1.0 motion: "live" surface signal — applies `live-pulse` archetype.
|
|
86
87
|
// Pair with a colored variant (e.g. variant="destructive" live).
|
|
@@ -89,6 +90,15 @@ var badgeVariants = cva(
|
|
|
89
90
|
false: ""
|
|
90
91
|
}
|
|
91
92
|
},
|
|
93
|
+
// `size` sets a radius meant for the plain variants (default, outline,
|
|
94
|
+
// success, …), which otherwise leak the base `rounded-lg` (10px). The
|
|
95
|
+
// pill (`rounded-full`) and soft (`rounded-md`) variants already draw
|
|
96
|
+
// their own measured radius — reassert it here so `cn`'s tailwind-merge
|
|
97
|
+
// doesn't let the size-level radius clobber it.
|
|
98
|
+
compoundVariants: [
|
|
99
|
+
{ variant: ["confirmed", "pending", "code", "completed", "assignee"], class: "rounded-full" },
|
|
100
|
+
{ variant: ["softSuccess", "softWarning", "softDestructive", "softNeutral"], class: "rounded-md" }
|
|
101
|
+
],
|
|
92
102
|
defaultVariants: {
|
|
93
103
|
variant: "default",
|
|
94
104
|
size: "chip",
|
package/dist/components/badge.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Popover, PopoverTrigger, PopoverContent } from '../chunk-2ADCZXZJ.js';
|
|
2
2
|
import { Command, CommandInput, CommandList, CommandLoading, CommandEmpty, CommandGroup, CommandItem } from '../chunk-PGQXVJXW.js';
|
|
3
3
|
import '../chunk-IQIJCMG2.js';
|
|
4
|
-
import { Button } from '../chunk-
|
|
4
|
+
import { Button } from '../chunk-GHKTVHQG.js';
|
|
5
5
|
import '../chunk-RIH2IS7X.js';
|
|
6
6
|
import '../chunk-5YNIQ2BL.js';
|
|
7
7
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { PhotoGround } from '../chunk-SWD6CJ35.js';
|
|
2
2
|
import { Convergence } from '../chunk-4XDJXMLF.js';
|
|
3
3
|
import '../chunk-OIV7B44M.js';
|
|
4
|
-
import { Badge } from '../chunk-
|
|
4
|
+
import { Badge } from '../chunk-RYPPN2GZ.js';
|
|
5
5
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
6
6
|
import '../chunk-MMUBH76A.js';
|
|
7
7
|
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
@@ -2,7 +2,7 @@ import { resolveEventEntryFee } from '../chunk-DNSXMHYX.js';
|
|
|
2
2
|
import { Separator } from '../chunk-NWZGSLPU.js';
|
|
3
3
|
import { EventPosterFallback } from '../chunk-FDITZ2VM.js';
|
|
4
4
|
import { Skeleton } from '../chunk-XBBEX4SF.js';
|
|
5
|
-
import { Button } from '../chunk-
|
|
5
|
+
import { Button } from '../chunk-GHKTVHQG.js';
|
|
6
6
|
import '../chunk-RIH2IS7X.js';
|
|
7
7
|
import '../chunk-5YNIQ2BL.js';
|
|
8
8
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
@@ -29,7 +29,7 @@ function EventIndexRow({
|
|
|
29
29
|
const dot = /* @__PURE__ */ jsx("span", { className: "px-1 text-border", "aria-hidden": true, children: "\xB7" });
|
|
30
30
|
return /* @__PURE__ */ jsxs("div", { className: cn("relative", className), children: [
|
|
31
31
|
!hideRule && /* @__PURE__ */ jsx(Separator, { className: "absolute inset-x-0 top-0" }),
|
|
32
|
-
/* @__PURE__ */ jsxs("div", { className: "relative -mx-3 flex gap-3 rounded-lg px-3 py-4 transition-colors duration-150 hover:bg-card/60 motion-reduce:transition-none md:gap-4 md:py-5", children: [
|
|
32
|
+
/* @__PURE__ */ jsxs("div", { className: "relative -mx-3 flex gap-3 rounded-lg px-3 py-4 transition-colors duration-150 hover:bg-card/60 active:bg-card/60 motion-reduce:transition-none md:gap-4 md:py-5", children: [
|
|
33
33
|
/* @__PURE__ */ jsxs("div", { className: "relative aspect-square w-[72px] shrink-0 overflow-hidden rounded-[9px] bg-muted md:w-[104px]", children: [
|
|
34
34
|
/* @__PURE__ */ jsx("div", { className: "absolute inset-0", children: fallback }),
|
|
35
35
|
posterUrl && // eslint-disable-next-line @next/next/no-img-element
|
|
@@ -49,7 +49,7 @@ function EventIndexRow({
|
|
|
49
49
|
LinkComponent,
|
|
50
50
|
{
|
|
51
51
|
href,
|
|
52
|
-
className: "rounded-sm transition-colors duration-150 after:absolute after:inset-0 hover:text-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
52
|
+
className: "rounded-sm transition-colors duration-150 after:absolute after:inset-0 hover:text-primary active:text-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
53
53
|
children: title
|
|
54
54
|
}
|
|
55
55
|
) }),
|
|
@@ -61,7 +61,7 @@ function EventIndexRow({
|
|
|
61
61
|
LinkComponent,
|
|
62
62
|
{
|
|
63
63
|
href: venueHref,
|
|
64
|
-
className: "relative z-10 rounded-sm font-semibold text-foreground/85 underline decoration-border underline-offset-2 transition-colors duration-150 hover:text-primary hover:decoration-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
64
|
+
className: "relative z-10 rounded-sm font-semibold text-foreground/85 underline decoration-border underline-offset-2 transition-colors duration-150 hover:text-primary hover:decoration-primary active:text-primary active:decoration-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
65
65
|
children: venue
|
|
66
66
|
}
|
|
67
67
|
) : /* @__PURE__ */ jsx("span", { className: "font-semibold text-foreground/85", children: venue })
|
|
@@ -3,7 +3,7 @@ import { Tag } from '../chunk-MWB7S54O.js';
|
|
|
3
3
|
import { Separator } from '../chunk-NWZGSLPU.js';
|
|
4
4
|
import { EventPosterFallback } from '../chunk-FDITZ2VM.js';
|
|
5
5
|
import { Skeleton } from '../chunk-XBBEX4SF.js';
|
|
6
|
-
import { buttonVariants } from '../chunk-
|
|
6
|
+
import { buttonVariants } from '../chunk-GHKTVHQG.js';
|
|
7
7
|
import '../chunk-RIH2IS7X.js';
|
|
8
8
|
import '../chunk-5YNIQ2BL.js';
|
|
9
9
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
@@ -3,7 +3,7 @@ import { HueChip } from '../chunk-6VOWT4YR.js';
|
|
|
3
3
|
import { Separator } from '../chunk-NWZGSLPU.js';
|
|
4
4
|
import { PhotoGround, PHOTO_GROUND } from '../chunk-SWD6CJ35.js';
|
|
5
5
|
import { Skeleton } from '../chunk-XBBEX4SF.js';
|
|
6
|
-
import { buttonVariants } from '../chunk-
|
|
6
|
+
import { buttonVariants } from '../chunk-GHKTVHQG.js';
|
|
7
7
|
import '../chunk-RIH2IS7X.js';
|
|
8
8
|
import '../chunk-5YNIQ2BL.js';
|
|
9
9
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Separator } from '../chunk-NWZGSLPU.js';
|
|
2
2
|
import { Skeleton } from '../chunk-XBBEX4SF.js';
|
|
3
|
-
import { Button } from '../chunk-
|
|
3
|
+
import { Button } from '../chunk-GHKTVHQG.js';
|
|
4
4
|
import '../chunk-RIH2IS7X.js';
|
|
5
5
|
import '../chunk-5YNIQ2BL.js';
|
|
6
6
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
@@ -52,7 +52,7 @@ function MenuIndexList({
|
|
|
52
52
|
LinkComponent,
|
|
53
53
|
{
|
|
54
54
|
href: entry.href,
|
|
55
|
-
className: "group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none",
|
|
55
|
+
className: "group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent active:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none",
|
|
56
56
|
children: [
|
|
57
57
|
/* @__PURE__ */ jsx(
|
|
58
58
|
"span",
|
|
@@ -67,7 +67,7 @@ function MenuIndexList({
|
|
|
67
67
|
/* @__PURE__ */ jsx(
|
|
68
68
|
ChevronRight,
|
|
69
69
|
{
|
|
70
|
-
className: "h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent motion-reduce:transition-none",
|
|
70
|
+
className: "h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent group-active:text-accent motion-reduce:transition-none",
|
|
71
71
|
"aria-hidden": true
|
|
72
72
|
}
|
|
73
73
|
)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { MentionSignal, MenuItem } from '../chunk-
|
|
2
|
-
import '../chunk-
|
|
3
|
-
import '../chunk-
|
|
1
|
+
export { MentionSignal, MenuItem } from '../chunk-VXGIDKXV.js';
|
|
2
|
+
import '../chunk-6PQINSAA.js';
|
|
3
|
+
import '../chunk-RYPPN2GZ.js';
|
|
4
4
|
import '../chunk-FEK5XGZW.js';
|
|
5
5
|
import '../chunk-MMUBH76A.js';
|
|
@@ -2,10 +2,10 @@ import { Checkbox } from '../chunk-L3CDXVEP.js';
|
|
|
2
2
|
import { Popover, PopoverTrigger, PopoverContent } from '../chunk-2ADCZXZJ.js';
|
|
3
3
|
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../chunk-PGQXVJXW.js';
|
|
4
4
|
import '../chunk-IQIJCMG2.js';
|
|
5
|
-
import { Button } from '../chunk-
|
|
5
|
+
import { Button } from '../chunk-GHKTVHQG.js';
|
|
6
6
|
import '../chunk-RIH2IS7X.js';
|
|
7
7
|
import '../chunk-5YNIQ2BL.js';
|
|
8
|
-
import { Badge } from '../chunk-
|
|
8
|
+
import { Badge } from '../chunk-RYPPN2GZ.js';
|
|
9
9
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
10
10
|
import { __spreadValues } from '../chunk-MMUBH76A.js';
|
|
11
11
|
import * as React from 'react';
|
|
@@ -6,7 +6,7 @@ import { showsGradeAtom } from '../chunk-I5V446BX.js';
|
|
|
6
6
|
import { Separator } from '../chunk-NWZGSLPU.js';
|
|
7
7
|
import { springSnappy } from '../chunk-OIV7B44M.js';
|
|
8
8
|
import { Skeleton } from '../chunk-XBBEX4SF.js';
|
|
9
|
-
import { Badge } from '../chunk-
|
|
9
|
+
import { Badge } from '../chunk-RYPPN2GZ.js';
|
|
10
10
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
11
11
|
import { __objRest, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
12
12
|
import { forwardRef, memo, useState, useMemo } from 'react';
|
|
@@ -3,7 +3,7 @@ import '../chunk-FMTJGO76.js';
|
|
|
3
3
|
import '../chunk-MWB7S54O.js';
|
|
4
4
|
import '../chunk-NWZGSLPU.js';
|
|
5
5
|
import { Text } from '../chunk-ZLMRVBD7.js';
|
|
6
|
-
import { Badge } from '../chunk-
|
|
6
|
+
import { Badge } from '../chunk-RYPPN2GZ.js';
|
|
7
7
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
8
8
|
import '../chunk-MMUBH76A.js';
|
|
9
9
|
import { useState } from 'react';
|
|
@@ -50,6 +50,9 @@ type PosterStackPoster = {
|
|
|
50
50
|
caption?: string;
|
|
51
51
|
/** Rendered when `src` is absent or fails. Defaults to `<EventPosterFallback />`. */
|
|
52
52
|
fallback?: React.ReactNode;
|
|
53
|
+
/** Load now at high priority instead of lazily. For the poster that is the
|
|
54
|
+
* page's LCP element; every other poster stays lazy. */
|
|
55
|
+
eager?: boolean;
|
|
53
56
|
};
|
|
54
57
|
type PosterStackProps = {
|
|
55
58
|
/** Candidates in order. The first three whose image loads are shown, so
|
|
@@ -85,7 +85,8 @@ function PosterStack({
|
|
|
85
85
|
{
|
|
86
86
|
src: poster.src,
|
|
87
87
|
alt: poster.caption ? "" : poster.alt,
|
|
88
|
-
loading: "lazy",
|
|
88
|
+
loading: poster.eager ? "eager" : "lazy",
|
|
89
|
+
fetchPriority: poster.eager ? "high" : void 0,
|
|
89
90
|
decoding: "async",
|
|
90
91
|
className: cn(
|
|
91
92
|
"h-full w-full object-cover",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DriftRail } from '../chunk-
|
|
1
|
+
import { DriftRail } from '../chunk-HJTENOB2.js';
|
|
2
2
|
import { EventPosterFallback } from '../chunk-FDITZ2VM.js';
|
|
3
3
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
4
4
|
import { __objRest, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
@@ -81,6 +81,29 @@ var ResponsiveOverlay = React.forwardRef(
|
|
|
81
81
|
const Title = CONTAINER_TITLE[container];
|
|
82
82
|
const Description = CONTAINER_DESCRIPTION[container];
|
|
83
83
|
const isDrawer = container === "drawer";
|
|
84
|
+
const closeButton = isDrawer ? /* @__PURE__ */ jsx(
|
|
85
|
+
"button",
|
|
86
|
+
{
|
|
87
|
+
type: "button",
|
|
88
|
+
"data-slot": "responsive-overlay-close",
|
|
89
|
+
"aria-label": closeLabel,
|
|
90
|
+
onClick: () => onOpenChange(false),
|
|
91
|
+
className: "sr-only rounded-full text-muted-foreground transition-colors focus-visible:not-sr-only focus-visible:-m-1.5 focus-visible:grid focus-visible:size-11 focus-visible:shrink-0 focus-visible:place-items-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
92
|
+
children: /* @__PURE__ */ jsx("span", { className: "grid size-8 place-items-center rounded-full bg-surface-2 transition-colors hover:bg-muted", children: /* @__PURE__ */ jsx(X, { className: "size-4", "aria-hidden": true }) })
|
|
93
|
+
}
|
|
94
|
+
) : /* @__PURE__ */ jsxs(
|
|
95
|
+
"button",
|
|
96
|
+
{
|
|
97
|
+
type: "button",
|
|
98
|
+
"data-slot": "responsive-overlay-close",
|
|
99
|
+
onClick: () => onOpenChange(false),
|
|
100
|
+
className: "-m-1.5 grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
101
|
+
children: [
|
|
102
|
+
/* @__PURE__ */ jsx("span", { className: "grid size-8 place-items-center rounded-full bg-surface-2 transition-colors hover:bg-muted", children: /* @__PURE__ */ jsx(X, { className: "size-4", "aria-hidden": true }) }),
|
|
103
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: closeLabel })
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
);
|
|
84
107
|
const header = /* @__PURE__ */ jsxs(
|
|
85
108
|
"div",
|
|
86
109
|
{
|
|
@@ -91,19 +114,7 @@ var ResponsiveOverlay = React.forwardRef(
|
|
|
91
114
|
/* @__PURE__ */ jsx(Title, { className: cn("font-bold leading-tight", isDrawer ? "text-[22px]" : "text-[19px]"), children: title }),
|
|
92
115
|
subtitle ? /* @__PURE__ */ jsx(Description, { className: "mt-1 text-[13px] text-muted-foreground", children: subtitle }) : null
|
|
93
116
|
] }),
|
|
94
|
-
|
|
95
|
-
"button",
|
|
96
|
-
{
|
|
97
|
-
type: "button",
|
|
98
|
-
"data-slot": "responsive-overlay-close",
|
|
99
|
-
onClick: () => onOpenChange(false),
|
|
100
|
-
className: "-m-1.5 grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
101
|
-
children: [
|
|
102
|
-
/* @__PURE__ */ jsx("span", { className: "grid size-8 place-items-center rounded-full bg-surface-2 transition-colors hover:bg-muted", children: /* @__PURE__ */ jsx(X, { className: "size-4", "aria-hidden": true }) }),
|
|
103
|
-
/* @__PURE__ */ jsx("span", { className: "sr-only", children: closeLabel })
|
|
104
|
-
]
|
|
105
|
-
}
|
|
106
|
-
)
|
|
117
|
+
closeButton
|
|
107
118
|
]
|
|
108
119
|
}
|
|
109
120
|
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Popover, PopoverTrigger, PopoverContent } from '../chunk-2ADCZXZJ.js';
|
|
2
2
|
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../chunk-PGQXVJXW.js';
|
|
3
3
|
import '../chunk-IQIJCMG2.js';
|
|
4
|
-
import { Button } from '../chunk-
|
|
4
|
+
import { Button } from '../chunk-GHKTVHQG.js';
|
|
5
5
|
import '../chunk-RIH2IS7X.js';
|
|
6
6
|
import '../chunk-5YNIQ2BL.js';
|
|
7
7
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
@@ -14,10 +14,10 @@ var ACTION_BASE_CLASSES = cn(
|
|
|
14
14
|
"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
15
15
|
);
|
|
16
16
|
var ACTION_TONE_CLASSES = {
|
|
17
|
-
muted: "text-[14px] text-muted-foreground hover:text-foreground",
|
|
17
|
+
muted: "text-[14px] text-muted-foreground hover:text-foreground active:text-foreground",
|
|
18
18
|
app: cn(
|
|
19
|
-
"text-[12.5px] font-semibold text-accent hover:text-accent/80",
|
|
20
|
-
"lg:text-[14px] lg:font-normal lg:text-muted-foreground lg:hover:text-foreground"
|
|
19
|
+
"text-[12.5px] font-semibold text-accent hover:text-accent/80 active:text-accent/80",
|
|
20
|
+
"lg:text-[14px] lg:font-normal lg:text-muted-foreground lg:hover:text-foreground lg:active:text-foreground"
|
|
21
21
|
)
|
|
22
22
|
};
|
|
23
23
|
function SectionHeader({
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { MenuItem } from '../chunk-
|
|
2
|
-
import '../chunk-
|
|
1
|
+
import { MenuItem } from '../chunk-VXGIDKXV.js';
|
|
2
|
+
import '../chunk-6PQINSAA.js';
|
|
3
3
|
import { Separator } from '../chunk-NWZGSLPU.js';
|
|
4
4
|
import { Skeleton } from '../chunk-XBBEX4SF.js';
|
|
5
|
-
import '../chunk-
|
|
5
|
+
import '../chunk-RYPPN2GZ.js';
|
|
6
6
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
7
7
|
import { __objRest, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
8
8
|
import { MapPin, Star, MessageSquareQuote } from 'lucide-react';
|
package/package.json
CHANGED
|
@@ -112,6 +112,26 @@ export const StatusSize: Story = {
|
|
|
112
112
|
),
|
|
113
113
|
};
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The two plain-variant radii: `size="chip"` (the inline default) draws
|
|
117
|
+
* `rounded-sm` (6px), `size="status"` draws the measured 7px the frames use
|
|
118
|
+
* for the taller pill. Pill (`confirmed`/`pending`/…) and soft
|
|
119
|
+
* (`softSuccess`/…) variants keep their own `rounded-full` / `rounded-md`
|
|
120
|
+
* regardless of size — see `Assignee` and `SoftTones` below.
|
|
121
|
+
*/
|
|
122
|
+
export const Radius: Story = {
|
|
123
|
+
render: () => (
|
|
124
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
125
|
+
<Badge variant="outline" size="chip">
|
|
126
|
+
chip · 6px
|
|
127
|
+
</Badge>
|
|
128
|
+
<Badge variant="outline" size="status">
|
|
129
|
+
status · 7px
|
|
130
|
+
</Badge>
|
|
131
|
+
</div>
|
|
132
|
+
),
|
|
133
|
+
};
|
|
134
|
+
|
|
115
135
|
/**
|
|
116
136
|
* `assignee` — who an item belongs to, in the shared table's organise view. It
|
|
117
137
|
* is a control, not a status: the whole pill opens "Who had this?", so it keeps
|
|
@@ -79,4 +79,63 @@ describe("Badge", () => {
|
|
|
79
79
|
expect(badge).toHaveClass("py-[3px]", "text-[10.5px]", "font-bold");
|
|
80
80
|
});
|
|
81
81
|
});
|
|
82
|
+
|
|
83
|
+
describe("radius", () => {
|
|
84
|
+
// A plain variant (no radius of its own) leaks the base rounded-lg
|
|
85
|
+
// (10px) without a size-level radius. `chip` measures 6px, `status` 7px
|
|
86
|
+
// — the frames' status pill has no token for it.
|
|
87
|
+
it('size="chip" (the default) draws 6px, not the base 10px', () => {
|
|
88
|
+
render(<Badge variant="outline">Outline</Badge>);
|
|
89
|
+
const badge = screen.getByText("Outline");
|
|
90
|
+
expect(badge).toHaveClass("rounded-sm");
|
|
91
|
+
expect(badge.className).not.toContain("rounded-lg");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('size="status" draws the measured 7px, not the base 10px', () => {
|
|
95
|
+
render(
|
|
96
|
+
<Badge variant="outline" size="status">
|
|
97
|
+
Outline
|
|
98
|
+
</Badge>
|
|
99
|
+
);
|
|
100
|
+
const badge = screen.getByText("Outline");
|
|
101
|
+
expect(badge).toHaveClass("rounded-[7px]");
|
|
102
|
+
expect(badge.className).not.toContain("rounded-lg");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it.each(["confirmed", "pending", "code", "completed", "assignee"] as const)(
|
|
106
|
+
"%s keeps its own rounded-full at both sizes",
|
|
107
|
+
(variant) => {
|
|
108
|
+
render(<Badge variant={variant}>Label</Badge>);
|
|
109
|
+
expect(screen.getByText("Label")).toHaveClass("rounded-full");
|
|
110
|
+
|
|
111
|
+
render(
|
|
112
|
+
<Badge variant={variant} size="status">
|
|
113
|
+
Label
|
|
114
|
+
</Badge>
|
|
115
|
+
);
|
|
116
|
+
const withStatus = screen.getAllByText("Label")[1];
|
|
117
|
+
expect(withStatus).toHaveClass("rounded-full");
|
|
118
|
+
expect(withStatus.className).not.toContain("rounded-sm");
|
|
119
|
+
expect(withStatus.className).not.toContain("rounded-[7px]");
|
|
120
|
+
}
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
it.each(["softSuccess", "softWarning", "softDestructive", "softNeutral"] as const)(
|
|
124
|
+
"%s keeps its own rounded-md at both sizes",
|
|
125
|
+
(variant) => {
|
|
126
|
+
render(<Badge variant={variant}>Label</Badge>);
|
|
127
|
+
expect(screen.getByText("Label")).toHaveClass("rounded-md");
|
|
128
|
+
|
|
129
|
+
render(
|
|
130
|
+
<Badge variant={variant} size="status">
|
|
131
|
+
Label
|
|
132
|
+
</Badge>
|
|
133
|
+
);
|
|
134
|
+
const withStatus = screen.getAllByText("Label")[1];
|
|
135
|
+
expect(withStatus).toHaveClass("rounded-md");
|
|
136
|
+
expect(withStatus.className).not.toContain("rounded-sm");
|
|
137
|
+
expect(withStatus.className).not.toContain("rounded-[7px]");
|
|
138
|
+
}
|
|
139
|
+
);
|
|
140
|
+
});
|
|
82
141
|
});
|
package/src/components/badge.tsx
CHANGED
|
@@ -87,8 +87,9 @@ const badgeVariants = cva(
|
|
|
87
87
|
* coming after the variant classes.
|
|
88
88
|
*/
|
|
89
89
|
size: {
|
|
90
|
-
chip: "",
|
|
91
|
-
|
|
90
|
+
chip: "rounded-sm",
|
|
91
|
+
// measured: status badge radius 7px, no token
|
|
92
|
+
status: "gap-1 px-2 py-[3px] text-xs leading-[18px] font-semibold rounded-[7px]",
|
|
92
93
|
},
|
|
93
94
|
// v1.0 motion: "live" surface signal — applies `live-pulse` archetype.
|
|
94
95
|
// Pair with a colored variant (e.g. variant="destructive" live).
|
|
@@ -97,6 +98,15 @@ const badgeVariants = cva(
|
|
|
97
98
|
false: "",
|
|
98
99
|
},
|
|
99
100
|
},
|
|
101
|
+
// `size` sets a radius meant for the plain variants (default, outline,
|
|
102
|
+
// success, …), which otherwise leak the base `rounded-lg` (10px). The
|
|
103
|
+
// pill (`rounded-full`) and soft (`rounded-md`) variants already draw
|
|
104
|
+
// their own measured radius — reassert it here so `cn`'s tailwind-merge
|
|
105
|
+
// doesn't let the size-level radius clobber it.
|
|
106
|
+
compoundVariants: [
|
|
107
|
+
{ variant: ["confirmed", "pending", "code", "completed", "assignee"], class: "rounded-full" },
|
|
108
|
+
{ variant: ["softSuccess", "softWarning", "softDestructive", "softNeutral"], class: "rounded-md" },
|
|
109
|
+
],
|
|
100
110
|
defaultVariants: {
|
|
101
111
|
variant: "default",
|
|
102
112
|
size: "chip",
|
|
@@ -16,7 +16,8 @@ describe("buttonVariants", () => {
|
|
|
16
16
|
it("maps variant prop to correct classes", () => {
|
|
17
17
|
expect(buttonVariants({ variant: "default" })).toContain("bg-primary");
|
|
18
18
|
expect(buttonVariants({ variant: "destructive" })).toContain("bg-destructive");
|
|
19
|
-
expect(buttonVariants({ variant: "outline" })).toContain("border-
|
|
19
|
+
expect(buttonVariants({ variant: "outline" })).toContain("border-input");
|
|
20
|
+
expect(buttonVariants({ variant: "outline" })).toContain("bg-card");
|
|
20
21
|
expect(buttonVariants({ variant: "secondary" })).toContain("bg-secondary");
|
|
21
22
|
expect(buttonVariants({ variant: "ghost" })).toContain("hover:bg-accent");
|
|
22
23
|
expect(buttonVariants({ variant: "link" })).toContain("text-primary");
|
|
@@ -26,7 +26,7 @@ const buttonVariants = cva(
|
|
|
26
26
|
* stays put. `--accent-subtle` is that step; `active:` goes one more.
|
|
27
27
|
*/
|
|
28
28
|
outline:
|
|
29
|
-
"border border-
|
|
29
|
+
"border border-input bg-card hover:bg-accent-subtle hover:border-accent/40 active:bg-muted",
|
|
30
30
|
secondary:
|
|
31
31
|
"bg-secondary text-secondary-foreground shadow-sm border border-border hover:bg-accent-subtle hover:border-accent/40 active:bg-muted",
|
|
32
32
|
ghost: "hover:bg-accent-subtle active:bg-muted",
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { act, fireEvent, render } from "@testing-library/react";
|
|
3
|
+
import { DriftRail } from "./drift-rail";
|
|
4
|
+
|
|
5
|
+
let intersect: (isIntersecting: boolean) => void = () => {};
|
|
6
|
+
let resize: () => void = () => {};
|
|
7
|
+
let frames = new Map<number, (now: number) => void>();
|
|
8
|
+
let nextFrame = 0;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
frames = new Map();
|
|
12
|
+
vi.stubGlobal("matchMedia", () => ({
|
|
13
|
+
matches: false,
|
|
14
|
+
addEventListener() {},
|
|
15
|
+
removeEventListener() {},
|
|
16
|
+
}));
|
|
17
|
+
vi.stubGlobal(
|
|
18
|
+
"IntersectionObserver",
|
|
19
|
+
class {
|
|
20
|
+
constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) {
|
|
21
|
+
intersect = (isIntersecting) => callback([{ isIntersecting }]);
|
|
22
|
+
}
|
|
23
|
+
observe() {}
|
|
24
|
+
disconnect() {}
|
|
25
|
+
}
|
|
26
|
+
);
|
|
27
|
+
vi.stubGlobal(
|
|
28
|
+
"ResizeObserver",
|
|
29
|
+
class {
|
|
30
|
+
constructor(callback: () => void) {
|
|
31
|
+
resize = callback;
|
|
32
|
+
}
|
|
33
|
+
observe() {}
|
|
34
|
+
disconnect() {}
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
vi.stubGlobal("requestAnimationFrame", (callback: (now: number) => void) => {
|
|
38
|
+
nextFrame += 1;
|
|
39
|
+
frames.set(nextFrame, callback);
|
|
40
|
+
return nextFrame;
|
|
41
|
+
});
|
|
42
|
+
vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
vi.unstubAllGlobals();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** A rail whose track is 1000px wide (two 500px copies). */
|
|
49
|
+
function renderRail() {
|
|
50
|
+
const { container } = render(
|
|
51
|
+
<DriftRail durationSeconds={10}>
|
|
52
|
+
<span>a</span>
|
|
53
|
+
<span>b</span>
|
|
54
|
+
</DriftRail>
|
|
55
|
+
);
|
|
56
|
+
const scroller = container.firstElementChild as HTMLDivElement;
|
|
57
|
+
const track = scroller.firstElementChild as HTMLDivElement;
|
|
58
|
+
const scrollWidth = vi.fn(() => 1000);
|
|
59
|
+
Object.defineProperty(track, "scrollWidth", { get: scrollWidth });
|
|
60
|
+
act(() => resize());
|
|
61
|
+
return { scroller, scrollWidth };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function runFrame(now: number) {
|
|
65
|
+
const pending = [...frames.values()];
|
|
66
|
+
frames.clear();
|
|
67
|
+
pending.forEach((frame) => frame(now));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("DriftRail", () => {
|
|
71
|
+
it("drifts at one copy per duration without measuring the track each frame", () => {
|
|
72
|
+
const { scroller, scrollWidth } = renderRail();
|
|
73
|
+
const readsAfterMount = scrollWidth.mock.calls.length;
|
|
74
|
+
const start = performance.now();
|
|
75
|
+
|
|
76
|
+
runFrame(start + 1000);
|
|
77
|
+
runFrame(start + 2000);
|
|
78
|
+
|
|
79
|
+
// 500px per 10s is 50px per second.
|
|
80
|
+
expect(scroller.scrollLeft).toBeGreaterThan(0);
|
|
81
|
+
expect(scrollWidth.mock.calls.length).toBe(readsAfterMount);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("stops the frame loop entirely while off screen", () => {
|
|
85
|
+
renderRail();
|
|
86
|
+
act(() => intersect(false));
|
|
87
|
+
runFrame(performance.now() + 16);
|
|
88
|
+
expect(frames.size).toBe(0);
|
|
89
|
+
|
|
90
|
+
act(() => intersect(true));
|
|
91
|
+
expect(frames.size).toBeGreaterThan(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("wraps the reader's own scroll by one copy, with no frame loop running", () => {
|
|
95
|
+
const { scroller } = renderRail();
|
|
96
|
+
act(() => intersect(false));
|
|
97
|
+
scroller.scrollLeft = 620;
|
|
98
|
+
fireEvent.scroll(scroller);
|
|
99
|
+
expect(scroller.scrollLeft).toBe(120);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -88,44 +88,66 @@ export function DriftRail({
|
|
|
88
88
|
return () => observer.disconnect();
|
|
89
89
|
}, []);
|
|
90
90
|
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
91
|
+
// One copy's width, kept by a ResizeObserver. Read off the track on every
|
|
92
|
+
// frame, it forced a style and layout pass per frame (measured on /events at
|
|
93
|
+
// up to 558ms of recalc per second).
|
|
94
|
+
const half = useRef(0);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const track = trackRef.current;
|
|
97
|
+
if (!track || typeof ResizeObserver === "undefined") return undefined;
|
|
98
|
+
const observer = new ResizeObserver(() => {
|
|
99
|
+
half.current = track.scrollWidth / 2;
|
|
100
|
+
});
|
|
101
|
+
observer.observe(track);
|
|
102
|
+
return () => observer.disconnect();
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
// The wrap, for the reader's own scrolling: the two copies are identical,
|
|
106
|
+
// so jumping by exactly one copy's width is invisible even mid-swipe. On a
|
|
107
|
+
// scroll event rather than in the frame loop, so the loop can stop.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const scroller = scrollerRef.current;
|
|
110
|
+
if (!scroller) return undefined;
|
|
111
|
+
const onScroll = () => {
|
|
112
|
+
if (half.current > 0 && scroller.scrollLeft >= half.current) {
|
|
113
|
+
scroller.scrollLeft -= half.current;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
scroller.addEventListener("scroll", onScroll, { passive: true });
|
|
117
|
+
return () => scroller.removeEventListener("scroll", onScroll);
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
120
|
+
// The drift. The frame loop runs only while the rail can drift: on screen,
|
|
121
|
+
// motion allowed, not hovered. Otherwise it does no work at all.
|
|
94
122
|
useEffect(() => {
|
|
95
123
|
const scroller = scrollerRef.current;
|
|
96
124
|
const track = trackRef.current;
|
|
97
125
|
if (!scroller || !track) return undefined;
|
|
98
126
|
|
|
99
|
-
let frame = 0;
|
|
100
|
-
let last = performance.now();
|
|
101
|
-
const drifting = !reducedMotion && inView && !(pauseOnHover && isHovered);
|
|
102
|
-
|
|
103
127
|
// Start the reverse loop inside the second copy so it has room to travel.
|
|
104
128
|
if (reverse && scroller.scrollLeft === 0) scroller.scrollLeft = track.scrollWidth / 2;
|
|
129
|
+
|
|
130
|
+
const drifting = !reducedMotion && inView && !(pauseOnHover && isHovered);
|
|
131
|
+
if (!drifting) return undefined;
|
|
132
|
+
|
|
133
|
+
let frame = 0;
|
|
134
|
+
let last = performance.now();
|
|
105
135
|
position.current = scroller.scrollLeft;
|
|
106
136
|
|
|
107
137
|
const tick = (now: number) => {
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const perSecond = half / durationSeconds;
|
|
138
|
+
const width = half.current;
|
|
139
|
+
if (width > 0) {
|
|
140
|
+
if (now >= heldUntil.current) {
|
|
141
|
+
const perSecond = width / durationSeconds;
|
|
113
142
|
const delta = (perSecond * (now - last)) / 1000;
|
|
114
143
|
position.current += reverse ? -delta : delta;
|
|
144
|
+
if (position.current >= width) position.current -= width;
|
|
145
|
+
else if (position.current < 0) position.current += width;
|
|
146
|
+
scroller.scrollLeft = position.current;
|
|
115
147
|
} else {
|
|
148
|
+
// Held: follow the reader, never fight the finger.
|
|
116
149
|
position.current = scroller.scrollLeft;
|
|
117
150
|
}
|
|
118
|
-
// The wrap applies in both modes: the two copies are identical, so
|
|
119
|
-
// jumping by exactly one copy's width is invisible even mid-swipe.
|
|
120
|
-
let wrapped = false;
|
|
121
|
-
if (position.current >= half) {
|
|
122
|
-
position.current -= half;
|
|
123
|
-
wrapped = true;
|
|
124
|
-
} else if (position.current < 0) {
|
|
125
|
-
position.current += half;
|
|
126
|
-
wrapped = true;
|
|
127
|
-
}
|
|
128
|
-
if (driftingNow || wrapped) scroller.scrollLeft = position.current;
|
|
129
151
|
}
|
|
130
152
|
last = now;
|
|
131
153
|
frame = requestAnimationFrame(tick);
|
|
@@ -9,12 +9,12 @@ const retry = { label: "Try again", onClick: () => {} };
|
|
|
9
9
|
describe("EmptyState", () => {
|
|
10
10
|
it("draws a retry on an error as an outline button", () => {
|
|
11
11
|
render(<ErrorEmptyState title="Couldn't load this report" description="On our side." action={retry} />);
|
|
12
|
-
expect(screen.getByRole("button", { name: "Try again" })).toHaveClass("border-
|
|
12
|
+
expect(screen.getByRole("button", { name: "Try again" })).toHaveClass("border-input", "bg-card");
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
it("draws a clear-filters on a search as an outline button", () => {
|
|
16
16
|
render(<SearchEmptyState title="No results" description="Try another name." action={retry} />);
|
|
17
|
-
expect(screen.getByRole("button", { name: "Try again" })).toHaveClass("border-
|
|
17
|
+
expect(screen.getByRole("button", { name: "Try again" })).toHaveClass("border-input", "bg-card");
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
it("keeps the plain empty state's create action filled", () => {
|
|
@@ -24,7 +24,7 @@ describe("EmptyState", () => {
|
|
|
24
24
|
|
|
25
25
|
it("lets the caller choose the action style", () => {
|
|
26
26
|
render(<EmptyState title="Nothing" description="Here." action={retry} actionVariant="outline" />);
|
|
27
|
-
expect(screen.getByRole("button", { name: "Try again" })).toHaveClass("border-
|
|
27
|
+
expect(screen.getByRole("button", { name: "Try again" })).toHaveClass("border-input", "bg-card");
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
it("calls the action", async () => {
|
|
@@ -100,7 +100,7 @@ export function EventIndexRow({
|
|
|
100
100
|
<div className={cn("relative", className)}>
|
|
101
101
|
{!hideRule && <Separator className="absolute inset-x-0 top-0" />}
|
|
102
102
|
|
|
103
|
-
<div className="relative -mx-3 flex gap-3 rounded-lg px-3 py-4 transition-colors duration-150 hover:bg-card/60 motion-reduce:transition-none md:gap-4 md:py-5">
|
|
103
|
+
<div className="relative -mx-3 flex gap-3 rounded-lg px-3 py-4 transition-colors duration-150 hover:bg-card/60 active:bg-card/60 motion-reduce:transition-none md:gap-4 md:py-5">
|
|
104
104
|
{/* Square poster at the same 72/104 the rest of the row family uses —
|
|
105
105
|
the editorial surfaces above this one are 4:3, but a third ratio
|
|
106
106
|
inside the ROW family would break the sibling-shape rule for one
|
|
@@ -125,7 +125,7 @@ export function EventIndexRow({
|
|
|
125
125
|
<Heading className="text-[15px] font-bold leading-snug tracking-[-0.01em] text-foreground md:text-[16.5px]">
|
|
126
126
|
<LinkComponent
|
|
127
127
|
href={href}
|
|
128
|
-
className="rounded-sm transition-colors duration-150 after:absolute after:inset-0 hover:text-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
128
|
+
className="rounded-sm transition-colors duration-150 after:absolute after:inset-0 hover:text-primary active:text-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
129
129
|
>
|
|
130
130
|
{title}
|
|
131
131
|
</LinkComponent>
|
|
@@ -139,7 +139,7 @@ export function EventIndexRow({
|
|
|
139
139
|
{venueHref ? (
|
|
140
140
|
<LinkComponent
|
|
141
141
|
href={venueHref}
|
|
142
|
-
className="relative z-10 rounded-sm font-semibold text-foreground/85 underline decoration-border underline-offset-2 transition-colors duration-150 hover:text-primary hover:decoration-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
142
|
+
className="relative z-10 rounded-sm font-semibold text-foreground/85 underline decoration-border underline-offset-2 transition-colors duration-150 hover:text-primary hover:decoration-primary active:text-primary active:decoration-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
143
143
|
>
|
|
144
144
|
{venue}
|
|
145
145
|
</LinkComponent>
|
|
@@ -112,7 +112,7 @@ export function MenuIndexList({
|
|
|
112
112
|
<Separator />
|
|
113
113
|
<LinkComponent
|
|
114
114
|
href={entry.href}
|
|
115
|
-
className="group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none"
|
|
115
|
+
className="group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent active:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none"
|
|
116
116
|
>
|
|
117
117
|
{/* The family's mark: a dot in its hue. */}
|
|
118
118
|
<span
|
|
@@ -131,7 +131,7 @@ export function MenuIndexList({
|
|
|
131
131
|
{entry.countLabel ?? String(entry.count)}
|
|
132
132
|
</span>
|
|
133
133
|
<ChevronRight
|
|
134
|
-
className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent motion-reduce:transition-none"
|
|
134
|
+
className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent group-active:text-accent motion-reduce:transition-none"
|
|
135
135
|
aria-hidden
|
|
136
136
|
/>
|
|
137
137
|
</LinkComponent>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { render } from "@testing-library/react";
|
|
3
|
+
import { PosterStack } from "./poster-stack";
|
|
4
|
+
|
|
5
|
+
// jsdom has neither; the stack checks reduced motion, then observes itself.
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.stubGlobal("matchMedia", () => ({ matches: false }));
|
|
8
|
+
vi.stubGlobal(
|
|
9
|
+
"IntersectionObserver",
|
|
10
|
+
class {
|
|
11
|
+
observe() {}
|
|
12
|
+
disconnect() {}
|
|
13
|
+
}
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.unstubAllGlobals();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const posters = [
|
|
21
|
+
{ src: "/a.jpg", alt: "A", eager: true },
|
|
22
|
+
{ src: "/b.jpg", alt: "B" },
|
|
23
|
+
{ src: "/c.jpg", alt: "C" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
describe("PosterStack eager", () => {
|
|
27
|
+
it("loads only the eager poster now, at high priority", () => {
|
|
28
|
+
const { container } = render(<PosterStack posters={posters} />);
|
|
29
|
+
const [first, ...rest] = Array.from(container.querySelectorAll("img"));
|
|
30
|
+
|
|
31
|
+
expect(first.getAttribute("loading")).toBe("eager");
|
|
32
|
+
expect(first.getAttribute("fetchpriority")).toBe("high");
|
|
33
|
+
for (const img of rest) {
|
|
34
|
+
expect(img.getAttribute("loading")).toBe("lazy");
|
|
35
|
+
expect(img.hasAttribute("fetchpriority")).toBe(false);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("stays lazy when no poster opts in", () => {
|
|
40
|
+
const { container } = render(
|
|
41
|
+
<PosterStack posters={posters.map(({ src, alt }) => ({ src, alt }))} />
|
|
42
|
+
);
|
|
43
|
+
for (const img of container.querySelectorAll("img")) {
|
|
44
|
+
expect(img.getAttribute("loading")).toBe("lazy");
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -60,6 +60,9 @@ export type PosterStackPoster = {
|
|
|
60
60
|
caption?: string;
|
|
61
61
|
/** Rendered when `src` is absent or fails. Defaults to `<EventPosterFallback />`. */
|
|
62
62
|
fallback?: React.ReactNode;
|
|
63
|
+
/** Load now at high priority instead of lazily. For the poster that is the
|
|
64
|
+
* page's LCP element; every other poster stays lazy. */
|
|
65
|
+
eager?: boolean;
|
|
63
66
|
};
|
|
64
67
|
|
|
65
68
|
export type PosterStackProps = {
|
|
@@ -182,7 +185,8 @@ export function PosterStack({
|
|
|
182
185
|
<img
|
|
183
186
|
src={poster.src}
|
|
184
187
|
alt={poster.caption ? "" : poster.alt}
|
|
185
|
-
loading="lazy"
|
|
188
|
+
loading={poster.eager ? "eager" : "lazy"}
|
|
189
|
+
fetchPriority={poster.eager ? "high" : undefined}
|
|
186
190
|
decoding="async"
|
|
187
191
|
className={cn(
|
|
188
192
|
"h-full w-full object-cover",
|
|
@@ -13,7 +13,7 @@ const meta: Meta<typeof ResponsiveOverlay> = {
|
|
|
13
13
|
docs: {
|
|
14
14
|
description: {
|
|
15
15
|
component:
|
|
16
|
-
"One overlay authored once: a Drawer under md, a Dialog from md. The chrome is the same at both widths — title, one-line subtitle, round close, body, footer band. Resize the preview across 768px to see it change container. The scrim is `--scrim-overlay`, never `--scrim`.",
|
|
16
|
+
"One overlay authored once: a Drawer under md, a Dialog from md. The chrome is the same at both widths — title, one-line subtitle, round close, body, footer band. Resize the preview across 768px to see it change container. The scrim is `--scrim-overlay`, never `--scrim`. The drawer's round close button is keyboard-only: it stays `sr-only` (in the a11y tree and the tab order, never `display:none`) until it is `:focus-visible`, since the drawer already closes on a drag-down or a backdrop tap. Tab into the drawer below to see it appear. Dialog and sheet keep the always-visible close button.",
|
|
17
17
|
},
|
|
18
18
|
},
|
|
19
19
|
},
|
|
@@ -58,6 +58,31 @@ export const AddAnItem: Story = {
|
|
|
58
58
|
},
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The drawer's close button (below 768px) has no visible circular X by
|
|
63
|
+
* default — a drag down or a backdrop tap already dismiss it. Tab through
|
|
64
|
+
* the open drawer to reveal the keyboard-only close button, drawn as the
|
|
65
|
+
* same disc dialog/sheet show at all times, with a visible focus ring.
|
|
66
|
+
*/
|
|
67
|
+
export const DrawerCloseIsKeyboardOnly: Story = {
|
|
68
|
+
render: () => {
|
|
69
|
+
const [open, setOpen] = React.useState(true);
|
|
70
|
+
return (
|
|
71
|
+
<ResponsiveOverlay
|
|
72
|
+
open={open}
|
|
73
|
+
onOpenChange={setOpen}
|
|
74
|
+
title="Add an item"
|
|
75
|
+
subtitle="Table 18 · from the menu"
|
|
76
|
+
closeLabel="Close"
|
|
77
|
+
>
|
|
78
|
+
<p className="text-[14px] text-muted-foreground">
|
|
79
|
+
Press Tab to move focus onto the close button — it is invisible until then.
|
|
80
|
+
</p>
|
|
81
|
+
</ResponsiveOverlay>
|
|
82
|
+
);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
61
86
|
/**
|
|
62
87
|
* floor/14-w1 — a working rail. `desktop="sheet"` with `modal={false}` and
|
|
63
88
|
* `wideFrom="lg"`: from 1024 the page stays live beside it (click a table and
|
|
@@ -474,6 +474,55 @@ describe("ResponsiveOverlay", () => {
|
|
|
474
474
|
});
|
|
475
475
|
});
|
|
476
476
|
|
|
477
|
+
describe("close button", () => {
|
|
478
|
+
// The drawer already closes on a drag-down and a backdrop tap; the
|
|
479
|
+
// circular X is a mouse-only affordance the frames never draw there, so
|
|
480
|
+
// it stays out of view until a keyboard user tabs to it.
|
|
481
|
+
it("drawer: has no visible close — sr-only, not display:none", () => {
|
|
482
|
+
open();
|
|
483
|
+
const close = screen.getByRole("button", { name: "Close" });
|
|
484
|
+
expect(close).toHaveClass("sr-only");
|
|
485
|
+
expect(close.className).not.toMatch(/\bhidden\b/);
|
|
486
|
+
expect(close).not.toHaveAttribute("hidden");
|
|
487
|
+
expect(close.style.display).not.toBe("none");
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("drawer: the close button is in the a11y tree with its label", () => {
|
|
491
|
+
open();
|
|
492
|
+
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("drawer: the close button is tabbable", () => {
|
|
496
|
+
open();
|
|
497
|
+
const close = screen.getByRole("button", { name: "Close" });
|
|
498
|
+
expect(close).not.toHaveAttribute("tabindex", "-1");
|
|
499
|
+
expect(close).not.toBeDisabled();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("drawer: clicking the close button closes it", async () => {
|
|
503
|
+
const onOpenChange = vi.fn();
|
|
504
|
+
open({ onOpenChange });
|
|
505
|
+
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
|
506
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("dialog: keeps the always-visible close button", () => {
|
|
510
|
+
setWidth(true);
|
|
511
|
+
open();
|
|
512
|
+
const close = screen.getByRole("button", { name: "Close" });
|
|
513
|
+
expect(close.className).not.toContain("sr-only");
|
|
514
|
+
expect(close).toHaveClass("size-11");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("sheet: keeps the always-visible close button", () => {
|
|
518
|
+
setWidth(true);
|
|
519
|
+
open({ desktop: "sheet" });
|
|
520
|
+
const close = screen.getByRole("button", { name: "Close" });
|
|
521
|
+
expect(close.className).not.toContain("sr-only");
|
|
522
|
+
expect(close).toHaveClass("size-11");
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
477
526
|
it("stays out of the DOM while closed", () => {
|
|
478
527
|
render(
|
|
479
528
|
<ResponsiveOverlay open={false} onOpenChange={() => {}} title="Add an item">
|
|
@@ -249,6 +249,50 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
249
249
|
// the desktop containers (already frames-checked elsewhere) don't move.
|
|
250
250
|
const isDrawer = container === "drawer";
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* The drawer already has two ways to dismiss it that don't need a visible
|
|
254
|
+
* target — a drag down (vaul's `dismissible`) and the backdrop tap — and
|
|
255
|
+
* the frames never draw a circular X on the phone sheet. Drawing one
|
|
256
|
+
* anyway put a mouse-only affordance in a mode designed around touch and
|
|
257
|
+
* gravity.
|
|
258
|
+
*
|
|
259
|
+
* A keyboard user still needs one: `sr-only` (`clip-path`, not
|
|
260
|
+
* `display:none`, so the element stays in the a11y tree and the tab
|
|
261
|
+
* order) keeps it invisible until `:focus-visible` flips it back with
|
|
262
|
+
* `not-sr-only` — at which point it draws as the same 44×44 disc, in the
|
|
263
|
+
* same trailing header slot, that dialog and sheet show all the time.
|
|
264
|
+
* The disc's own box classes (`size-11`, `-m-1.5`, …) are gated behind
|
|
265
|
+
* `focus-visible:` too, not left unconditional, so nothing here fights
|
|
266
|
+
* `sr-only`'s 1px box for specificity while it is hidden.
|
|
267
|
+
*/
|
|
268
|
+
const closeButton = isDrawer ? (
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
data-slot="responsive-overlay-close"
|
|
272
|
+
aria-label={closeLabel}
|
|
273
|
+
onClick={() => onOpenChange(false)}
|
|
274
|
+
className="sr-only rounded-full text-muted-foreground transition-colors focus-visible:not-sr-only focus-visible:-m-1.5 focus-visible:grid focus-visible:size-11 focus-visible:shrink-0 focus-visible:place-items-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
275
|
+
>
|
|
276
|
+
<span className="grid size-8 place-items-center rounded-full bg-surface-2 transition-colors hover:bg-muted">
|
|
277
|
+
<X className="size-4" aria-hidden />
|
|
278
|
+
</span>
|
|
279
|
+
</button>
|
|
280
|
+
) : (
|
|
281
|
+
<button
|
|
282
|
+
type="button"
|
|
283
|
+
data-slot="responsive-overlay-close"
|
|
284
|
+
onClick={() => onOpenChange(false)}
|
|
285
|
+
// 32px disc, 44px of tap target around it — the frames draw the disc,
|
|
286
|
+
// the accessibility floor asks for the target.
|
|
287
|
+
className="-m-1.5 grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
288
|
+
>
|
|
289
|
+
<span className="grid size-8 place-items-center rounded-full bg-surface-2 transition-colors hover:bg-muted">
|
|
290
|
+
<X className="size-4" aria-hidden />
|
|
291
|
+
</span>
|
|
292
|
+
<span className="sr-only">{closeLabel}</span>
|
|
293
|
+
</button>
|
|
294
|
+
);
|
|
295
|
+
|
|
252
296
|
const header = (
|
|
253
297
|
<div
|
|
254
298
|
data-slot="responsive-overlay-header"
|
|
@@ -264,19 +308,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
264
308
|
</Description>
|
|
265
309
|
) : null}
|
|
266
310
|
</div>
|
|
267
|
-
|
|
268
|
-
type="button"
|
|
269
|
-
data-slot="responsive-overlay-close"
|
|
270
|
-
onClick={() => onOpenChange(false)}
|
|
271
|
-
// 32px disc, 44px of tap target around it — the frames draw the disc,
|
|
272
|
-
// the accessibility floor asks for the target.
|
|
273
|
-
className="-m-1.5 grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
274
|
-
>
|
|
275
|
-
<span className="grid size-8 place-items-center rounded-full bg-surface-2 transition-colors hover:bg-muted">
|
|
276
|
-
<X className="size-4" aria-hidden />
|
|
277
|
-
</span>
|
|
278
|
-
<span className="sr-only">{closeLabel}</span>
|
|
279
|
-
</button>
|
|
311
|
+
{closeButton}
|
|
280
312
|
</div>
|
|
281
313
|
);
|
|
282
314
|
|
|
@@ -79,10 +79,10 @@ const ACTION_BASE_CLASSES = cn(
|
|
|
79
79
|
);
|
|
80
80
|
|
|
81
81
|
const ACTION_TONE_CLASSES = {
|
|
82
|
-
muted: "text-[14px] text-muted-foreground hover:text-foreground",
|
|
82
|
+
muted: "text-[14px] text-muted-foreground hover:text-foreground active:text-foreground",
|
|
83
83
|
app: cn(
|
|
84
|
-
"text-[12.5px] font-semibold text-accent hover:text-accent/80",
|
|
85
|
-
"lg:text-[14px] lg:font-normal lg:text-muted-foreground lg:hover:text-foreground"
|
|
84
|
+
"text-[12.5px] font-semibold text-accent hover:text-accent/80 active:text-accent/80",
|
|
85
|
+
"lg:text-[14px] lg:font-normal lg:text-muted-foreground lg:hover:text-foreground lg:active:text-foreground"
|
|
86
86
|
),
|
|
87
87
|
} as const;
|
|
88
88
|
|
package/src/globals.css
CHANGED
|
@@ -108,7 +108,11 @@ h6 {
|
|
|
108
108
|
*/
|
|
109
109
|
--warning-deep: oklch(0.62 0.15 70);
|
|
110
110
|
--border: oklch(0.895 0 0);
|
|
111
|
-
|
|
111
|
+
/* Measured off the boards' edge: the outline button and inputs share this
|
|
112
|
+
line. Slightly darker and warmer than --border on purpose — a hairline
|
|
113
|
+
that only separates (border) can sit lighter than one that also has to
|
|
114
|
+
read as an interactive edge (input). */
|
|
115
|
+
--input: oklch(0.86 0.015 60);
|
|
112
116
|
--ring: oklch(0.25 0.05 35);
|
|
113
117
|
--chart-1: oklch(0.646 0.222 41.116);
|
|
114
118
|
--chart-2: oklch(0.6 0.118 184.704);
|