@bearmenu/ui 0.8.42 → 0.8.44
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-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/empty-state.js +1 -1
- package/dist/components/event-detail-panel.js +1 -1
- 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 +1 -1
- 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/responsive-overlay.d.ts +16 -0
- package/dist/components/responsive-overlay.js +41 -20
- package/dist/components/searchable-select.js +1 -1
- 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/empty-state.test.tsx +3 -3
- package/src/components/responsive-overlay.stories.tsx +85 -1
- package/src/components/responsive-overlay.test.tsx +135 -0
- package/src/components/responsive-overlay.tsx +86 -17
- 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",
|
|
@@ -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';
|
|
@@ -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';
|
|
@@ -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';
|
|
@@ -64,6 +64,22 @@ type ResponsiveOverlayProps = {
|
|
|
64
64
|
* keeps today's default in every mode (dialog, sheet, drawer).
|
|
65
65
|
*/
|
|
66
66
|
initialFocusRef?: React.RefObject<HTMLElement | null>;
|
|
67
|
+
/**
|
|
68
|
+
* Whether closing the overlay returns focus to whatever had it before the
|
|
69
|
+
* overlay opened. Two things do that today: Radix's `onCloseAutoFocus` —
|
|
70
|
+
* the FocusScope's own auto-focus-on-unmount, refocusing the element that
|
|
71
|
+
* was active before the overlay trapped focus — and this component's own
|
|
72
|
+
* `useFocusReturn`, which refocuses that same element a frame later so it
|
|
73
|
+
* survives the overlay's own unmount. `true` (the default) keeps today's
|
|
74
|
+
* behaviour in every mode (dialog, sheet, drawer): the opener gets focus
|
|
75
|
+
* back.
|
|
76
|
+
*
|
|
77
|
+
* Pass `false` when closing hands focus to another surface that takes it
|
|
78
|
+
* itself — a pick-mode bar docking over the header, say. Both paths are
|
|
79
|
+
* disabled and focus stays wherever the consumer puts it (typically inside
|
|
80
|
+
* the same `onOpenChange(false)` handler that closes the overlay).
|
|
81
|
+
*/
|
|
82
|
+
returnFocus?: boolean;
|
|
67
83
|
/**
|
|
68
84
|
* A fixed height for the phone drawer instead of sizing to its content —
|
|
69
85
|
* `DrawerContent`'s `snapHeight` (the frames' ~640px table sheet). Applies
|
|
@@ -15,7 +15,7 @@ var CONTAINER_DESCRIPTION = {
|
|
|
15
15
|
dialog: DialogDescription,
|
|
16
16
|
sheet: SheetDescription
|
|
17
17
|
};
|
|
18
|
-
function useFocusReturn(open) {
|
|
18
|
+
function useFocusReturn(open, returnFocus) {
|
|
19
19
|
const openerRef = React.useRef(null);
|
|
20
20
|
const wasOpen = React.useRef(false);
|
|
21
21
|
React.useEffect(() => {
|
|
@@ -26,7 +26,7 @@ function useFocusReturn(open) {
|
|
|
26
26
|
if (wasOpen.current) {
|
|
27
27
|
wasOpen.current = false;
|
|
28
28
|
const opener = openerRef.current;
|
|
29
|
-
if (opener && opener.isConnected) {
|
|
29
|
+
if (returnFocus && opener && opener.isConnected) {
|
|
30
30
|
requestAnimationFrame(() => opener.focus());
|
|
31
31
|
}
|
|
32
32
|
}
|
|
@@ -39,7 +39,7 @@ function useFocusReturn(open) {
|
|
|
39
39
|
}
|
|
40
40
|
document.addEventListener("focusin", track);
|
|
41
41
|
return () => document.removeEventListener("focusin", track);
|
|
42
|
-
}, [open]);
|
|
42
|
+
}, [open, returnFocus]);
|
|
43
43
|
}
|
|
44
44
|
var ResponsiveOverlay = React.forwardRef(
|
|
45
45
|
({
|
|
@@ -57,11 +57,12 @@ var ResponsiveOverlay = React.forwardRef(
|
|
|
57
57
|
className,
|
|
58
58
|
bodyClassName,
|
|
59
59
|
initialFocusRef,
|
|
60
|
+
returnFocus = true,
|
|
60
61
|
drawerHeight
|
|
61
62
|
}, ref) => {
|
|
62
63
|
const isWide = useMediaQuery(wideFrom === "lg" ? DESKTOP_MEDIA_QUERY : MD_MEDIA_QUERY);
|
|
63
64
|
const container = isWide ? desktop : "drawer";
|
|
64
|
-
useFocusReturn(open);
|
|
65
|
+
useFocusReturn(open, returnFocus);
|
|
65
66
|
const handleOpenAutoFocus = React.useCallback(
|
|
66
67
|
(event) => {
|
|
67
68
|
if (!(initialFocusRef == null ? void 0 : initialFocusRef.current)) return;
|
|
@@ -70,10 +71,39 @@ var ResponsiveOverlay = React.forwardRef(
|
|
|
70
71
|
},
|
|
71
72
|
[initialFocusRef]
|
|
72
73
|
);
|
|
74
|
+
const handleCloseAutoFocus = React.useCallback(
|
|
75
|
+
(event) => {
|
|
76
|
+
if (!returnFocus) event.preventDefault();
|
|
77
|
+
},
|
|
78
|
+
[returnFocus]
|
|
79
|
+
);
|
|
73
80
|
const describedBy = subtitle ? {} : { "aria-describedby": void 0 };
|
|
74
81
|
const Title = CONTAINER_TITLE[container];
|
|
75
82
|
const Description = CONTAINER_DESCRIPTION[container];
|
|
76
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
|
+
);
|
|
77
107
|
const header = /* @__PURE__ */ jsxs(
|
|
78
108
|
"div",
|
|
79
109
|
{
|
|
@@ -84,19 +114,7 @@ var ResponsiveOverlay = React.forwardRef(
|
|
|
84
114
|
/* @__PURE__ */ jsx(Title, { className: cn("font-bold leading-tight", isDrawer ? "text-[22px]" : "text-[19px]"), children: title }),
|
|
85
115
|
subtitle ? /* @__PURE__ */ jsx(Description, { className: "mt-1 text-[13px] text-muted-foreground", children: subtitle }) : null
|
|
86
116
|
] }),
|
|
87
|
-
|
|
88
|
-
"button",
|
|
89
|
-
{
|
|
90
|
-
type: "button",
|
|
91
|
-
"data-slot": "responsive-overlay-close",
|
|
92
|
-
onClick: () => onOpenChange(false),
|
|
93
|
-
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",
|
|
94
|
-
children: [
|
|
95
|
-
/* @__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 }) }),
|
|
96
|
-
/* @__PURE__ */ jsx("span", { className: "sr-only", children: closeLabel })
|
|
97
|
-
]
|
|
98
|
-
}
|
|
99
|
-
)
|
|
117
|
+
closeButton
|
|
100
118
|
]
|
|
101
119
|
}
|
|
102
120
|
);
|
|
@@ -129,7 +147,8 @@ var ResponsiveOverlay = React.forwardRef(
|
|
|
129
147
|
attachment: "inset",
|
|
130
148
|
hideClose: true,
|
|
131
149
|
overlayClassName: "bg-scrim-overlay backdrop-blur-none",
|
|
132
|
-
onOpenAutoFocus: handleOpenAutoFocus
|
|
150
|
+
onOpenAutoFocus: handleOpenAutoFocus,
|
|
151
|
+
onCloseAutoFocus: handleCloseAutoFocus
|
|
133
152
|
}, describedBy), {
|
|
134
153
|
"data-slot": "responsive-overlay-sheet",
|
|
135
154
|
"data-modal": modal ? "true" : "false"
|
|
@@ -154,7 +173,8 @@ var ResponsiveOverlay = React.forwardRef(
|
|
|
154
173
|
size,
|
|
155
174
|
showCloseButton: false,
|
|
156
175
|
overlayClassName: "bg-scrim-overlay backdrop-blur-none",
|
|
157
|
-
onOpenAutoFocus: handleOpenAutoFocus
|
|
176
|
+
onOpenAutoFocus: handleOpenAutoFocus,
|
|
177
|
+
onCloseAutoFocus: handleCloseAutoFocus
|
|
158
178
|
}, describedBy), {
|
|
159
179
|
className: cn(
|
|
160
180
|
// The dialog's own `grid gap-4 p-6` would put air between the
|
|
@@ -175,7 +195,8 @@ var ResponsiveOverlay = React.forwardRef(
|
|
|
175
195
|
__spreadProps(__spreadValues(__spreadValues({
|
|
176
196
|
ref,
|
|
177
197
|
overlayClassName: "bg-scrim-overlay",
|
|
178
|
-
onOpenAutoFocus: handleOpenAutoFocus
|
|
198
|
+
onOpenAutoFocus: handleOpenAutoFocus,
|
|
199
|
+
onCloseAutoFocus: handleCloseAutoFocus
|
|
179
200
|
}, drawerHeight !== void 0 ? { snapHeight: drawerHeight } : {}), describedBy), {
|
|
180
201
|
className: cn("flex max-h-[85vh] flex-col p-0", className),
|
|
181
202
|
children: [
|
|
@@ -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';
|
|
@@ -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",
|
|
@@ -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 () => {
|
|
@@ -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
|
|
@@ -122,6 +147,65 @@ export const FormRail: Story = {
|
|
|
122
147
|
},
|
|
123
148
|
};
|
|
124
149
|
|
|
150
|
+
/**
|
|
151
|
+
* `returnFocus={false}` — closing hands off to a surface that takes focus
|
|
152
|
+
* itself, rather than returning it to the opener. Here that surface is a
|
|
153
|
+
* pick-mode bar docking over the header: click "Add item", then "Pick a
|
|
154
|
+
* table" inside it — focus lands on the bar's own button, not back on
|
|
155
|
+
* "Add item".
|
|
156
|
+
*/
|
|
157
|
+
export const HandsOffFocusOnClose: Story = {
|
|
158
|
+
render: () => {
|
|
159
|
+
const [open, setOpen] = React.useState(false);
|
|
160
|
+
const [pickMode, setPickMode] = React.useState(false);
|
|
161
|
+
const pickButtonRef = React.useRef<HTMLButtonElement>(null);
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div className="relative">
|
|
165
|
+
{pickMode ? (
|
|
166
|
+
<div className="mb-3 flex items-center justify-between rounded-lg border border-border bg-surface-2 p-3">
|
|
167
|
+
<span className="text-[14px] font-medium">Pick a table on the floor</span>
|
|
168
|
+
<Button ref={pickButtonRef} variant="outline" onClick={() => setPickMode(false)}>
|
|
169
|
+
Cancel
|
|
170
|
+
</Button>
|
|
171
|
+
</div>
|
|
172
|
+
) : null}
|
|
173
|
+
<Button variant="cta" onClick={() => setOpen(true)}>
|
|
174
|
+
Add item
|
|
175
|
+
</Button>
|
|
176
|
+
<ResponsiveOverlay
|
|
177
|
+
open={open}
|
|
178
|
+
onOpenChange={(next) => {
|
|
179
|
+
if (!next) {
|
|
180
|
+
// The overlay hands off to the pick-mode bar itself — it moves
|
|
181
|
+
// focus into its own "Cancel" button before the overlay even
|
|
182
|
+
// starts unmounting, so the overlay must not fight it back.
|
|
183
|
+
setPickMode(true);
|
|
184
|
+
requestAnimationFrame(() => pickButtonRef.current?.focus());
|
|
185
|
+
}
|
|
186
|
+
setOpen(next);
|
|
187
|
+
}}
|
|
188
|
+
title="Add an item"
|
|
189
|
+
subtitle="Table 18 · from the menu"
|
|
190
|
+
closeLabel="Close"
|
|
191
|
+
returnFocus={false}
|
|
192
|
+
footer={
|
|
193
|
+
<ActionBar
|
|
194
|
+
secondary={<Button variant="outline">Pick a table</Button>}
|
|
195
|
+
primary={<Button variant="cta">Add 1 item</Button>}
|
|
196
|
+
/>
|
|
197
|
+
}
|
|
198
|
+
>
|
|
199
|
+
<p className="text-[14px] text-muted-foreground">
|
|
200
|
+
Closing this does not return focus to "Add item" — the pick-mode bar above takes it
|
|
201
|
+
instead.
|
|
202
|
+
</p>
|
|
203
|
+
</ResponsiveOverlay>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
125
209
|
/** T1 — an overlay with no footer note: the two actions divide the band. */
|
|
126
210
|
export const JoinTheTable: Story = {
|
|
127
211
|
render: () => {
|
|
@@ -191,6 +191,92 @@ describe("ResponsiveOverlay", () => {
|
|
|
191
191
|
await waitFor(() => expect(opener).toHaveFocus());
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
+
describe("returnFocus", () => {
|
|
195
|
+
/**
|
|
196
|
+
* Mirrors a pick-mode bar docking over the header: the consumer decides,
|
|
197
|
+
* from inside the same `onOpenChange(false)` handler that closes the
|
|
198
|
+
* overlay, to move focus itself — scheduled a frame out, same as a bar
|
|
199
|
+
* that focuses itself once it mounts. The overlay is still trapping
|
|
200
|
+
* focus synchronously at the moment `onOpenChange` fires (it hasn't
|
|
201
|
+
* unmounted yet), so an immediate `.focus()` call here would just be
|
|
202
|
+
* bounced back inside by that trap; that is Radix's ordinary modal
|
|
203
|
+
* behaviour, unrelated to `returnFocus`, which is what a caller works
|
|
204
|
+
* around by deferring.
|
|
205
|
+
*/
|
|
206
|
+
function Harness({
|
|
207
|
+
returnFocus,
|
|
208
|
+
...props
|
|
209
|
+
}: Partial<React.ComponentProps<typeof ResponsiveOverlay>>) {
|
|
210
|
+
const [isOpen, setIsOpen] = React.useState(false);
|
|
211
|
+
const elsewhereRef = React.useRef<HTMLButtonElement>(null);
|
|
212
|
+
return (
|
|
213
|
+
<>
|
|
214
|
+
<button type="button" onClick={() => setIsOpen(true)}>
|
|
215
|
+
Add item
|
|
216
|
+
</button>
|
|
217
|
+
<button type="button" ref={elsewhereRef}>
|
|
218
|
+
Elsewhere
|
|
219
|
+
</button>
|
|
220
|
+
<ResponsiveOverlay
|
|
221
|
+
open={isOpen}
|
|
222
|
+
onOpenChange={(next) => {
|
|
223
|
+
if (!next && returnFocus === false) {
|
|
224
|
+
requestAnimationFrame(() => elsewhereRef.current?.focus());
|
|
225
|
+
}
|
|
226
|
+
setIsOpen(next);
|
|
227
|
+
}}
|
|
228
|
+
title="Add an item"
|
|
229
|
+
closeLabel="Close"
|
|
230
|
+
returnFocus={returnFocus}
|
|
231
|
+
{...props}
|
|
232
|
+
>
|
|
233
|
+
<p>body</p>
|
|
234
|
+
</ResponsiveOverlay>
|
|
235
|
+
</>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Two rAF ticks — where `useFocusReturn`'s own refocus would land. */
|
|
240
|
+
async function nextFrames(count: number) {
|
|
241
|
+
for (let i = 0; i < count; i++) {
|
|
242
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const MODES: Array<{
|
|
247
|
+
name: string;
|
|
248
|
+
wide: boolean;
|
|
249
|
+
props: Partial<React.ComponentProps<typeof ResponsiveOverlay>>;
|
|
250
|
+
}> = [
|
|
251
|
+
{ name: "drawer", wide: false, props: {} },
|
|
252
|
+
{ name: "dialog", wide: true, props: {} },
|
|
253
|
+
{ name: "sheet", wide: true, props: { desktop: "sheet" } },
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
for (const mode of MODES) {
|
|
257
|
+
it(`${mode.name}: default (true) returns focus to the opener`, async () => {
|
|
258
|
+
setWidth(mode.wide);
|
|
259
|
+
render(<Harness {...mode.props} />);
|
|
260
|
+
const opener = screen.getByRole("button", { name: "Add item" });
|
|
261
|
+
await userEvent.click(opener);
|
|
262
|
+
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
|
263
|
+
await waitFor(() => expect(opener).toHaveFocus());
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it(`${mode.name}: returnFocus={false} leaves focus where the consumer puts it`, async () => {
|
|
267
|
+
setWidth(mode.wide);
|
|
268
|
+
render(<Harness {...mode.props} returnFocus={false} />);
|
|
269
|
+
const opener = screen.getByRole("button", { name: "Add item" });
|
|
270
|
+
const elsewhere = screen.getByRole("button", { name: "Elsewhere" });
|
|
271
|
+
await userEvent.click(opener);
|
|
272
|
+
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
|
273
|
+
await nextFrames(2);
|
|
274
|
+
expect(elsewhere).toHaveFocus();
|
|
275
|
+
expect(opener).not.toHaveFocus();
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
194
280
|
describe('desktop="sheet"', () => {
|
|
195
281
|
/** A width-aware matchMedia, for the md / lg split. */
|
|
196
282
|
function setViewport(px: number) {
|
|
@@ -388,6 +474,55 @@ describe("ResponsiveOverlay", () => {
|
|
|
388
474
|
});
|
|
389
475
|
});
|
|
390
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
|
+
|
|
391
526
|
it("stays out of the DOM while closed", () => {
|
|
392
527
|
render(
|
|
393
528
|
<ResponsiveOverlay open={false} onOpenChange={() => {}} title="Add an item">
|
|
@@ -32,8 +32,12 @@ const CONTAINER_DESCRIPTION = {
|
|
|
32
32
|
* The opener is tracked while the overlay is CLOSED rather than captured when
|
|
33
33
|
* it opens: by the time an effect could run on the opening render, the dialog
|
|
34
34
|
* has already mounted and pulled focus into itself.
|
|
35
|
+
*
|
|
36
|
+
* `returnFocus={false}` disables the `requestAnimationFrame` refocus below,
|
|
37
|
+
* but tracking keeps running regardless — cheap, and it means flipping the
|
|
38
|
+
* prop back on mid-lifecycle has an opener ready immediately.
|
|
35
39
|
*/
|
|
36
|
-
function useFocusReturn(open: boolean) {
|
|
40
|
+
function useFocusReturn(open: boolean, returnFocus: boolean) {
|
|
37
41
|
const openerRef = React.useRef<HTMLElement | null>(null);
|
|
38
42
|
const wasOpen = React.useRef(false);
|
|
39
43
|
|
|
@@ -48,7 +52,7 @@ function useFocusReturn(open: boolean) {
|
|
|
48
52
|
const opener = openerRef.current;
|
|
49
53
|
// The overlay is still unmounting on this tick, and it takes focus with
|
|
50
54
|
// it; restoring on the next frame lands after that.
|
|
51
|
-
if (opener && opener.isConnected) {
|
|
55
|
+
if (returnFocus && opener && opener.isConnected) {
|
|
52
56
|
requestAnimationFrame(() => opener.focus());
|
|
53
57
|
}
|
|
54
58
|
}
|
|
@@ -62,7 +66,7 @@ function useFocusReturn(open: boolean) {
|
|
|
62
66
|
}
|
|
63
67
|
document.addEventListener("focusin", track);
|
|
64
68
|
return () => document.removeEventListener("focusin", track);
|
|
65
|
-
}, [open]);
|
|
69
|
+
}, [open, returnFocus]);
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
export type ResponsiveOverlayProps = {
|
|
@@ -124,6 +128,22 @@ export type ResponsiveOverlayProps = {
|
|
|
124
128
|
* keeps today's default in every mode (dialog, sheet, drawer).
|
|
125
129
|
*/
|
|
126
130
|
initialFocusRef?: React.RefObject<HTMLElement | null>;
|
|
131
|
+
/**
|
|
132
|
+
* Whether closing the overlay returns focus to whatever had it before the
|
|
133
|
+
* overlay opened. Two things do that today: Radix's `onCloseAutoFocus` —
|
|
134
|
+
* the FocusScope's own auto-focus-on-unmount, refocusing the element that
|
|
135
|
+
* was active before the overlay trapped focus — and this component's own
|
|
136
|
+
* `useFocusReturn`, which refocuses that same element a frame later so it
|
|
137
|
+
* survives the overlay's own unmount. `true` (the default) keeps today's
|
|
138
|
+
* behaviour in every mode (dialog, sheet, drawer): the opener gets focus
|
|
139
|
+
* back.
|
|
140
|
+
*
|
|
141
|
+
* Pass `false` when closing hands focus to another surface that takes it
|
|
142
|
+
* itself — a pick-mode bar docking over the header, say. Both paths are
|
|
143
|
+
* disabled and focus stays wherever the consumer puts it (typically inside
|
|
144
|
+
* the same `onOpenChange(false)` handler that closes the overlay).
|
|
145
|
+
*/
|
|
146
|
+
returnFocus?: boolean;
|
|
127
147
|
/**
|
|
128
148
|
* A fixed height for the phone drawer instead of sizing to its content —
|
|
129
149
|
* `DrawerContent`'s `snapHeight` (the frames' ~640px table sheet). Applies
|
|
@@ -178,13 +198,14 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
178
198
|
className,
|
|
179
199
|
bodyClassName,
|
|
180
200
|
initialFocusRef,
|
|
201
|
+
returnFocus = true,
|
|
181
202
|
drawerHeight,
|
|
182
203
|
},
|
|
183
204
|
ref
|
|
184
205
|
) => {
|
|
185
206
|
const isWide = useMediaQuery(wideFrom === "lg" ? DESKTOP_MEDIA_QUERY : MD_MEDIA_QUERY);
|
|
186
207
|
const container: "drawer" | "dialog" | "sheet" = isWide ? desktop : "drawer";
|
|
187
|
-
useFocusReturn(open);
|
|
208
|
+
useFocusReturn(open, returnFocus);
|
|
188
209
|
|
|
189
210
|
// Radix (and vaul, underneath the drawer) call this before moving focus
|
|
190
211
|
// themselves; preventing the default there is what stops the first-
|
|
@@ -198,6 +219,19 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
198
219
|
[initialFocusRef]
|
|
199
220
|
);
|
|
200
221
|
|
|
222
|
+
// Radix's FocusScope calls this on unmount before it runs its own default
|
|
223
|
+
// action — refocusing whatever was active before the scope trapped focus,
|
|
224
|
+
// i.e. the opener. Vaul forwards it straight through to the same Radix
|
|
225
|
+
// primitive underneath the drawer, so one handler covers all three modes.
|
|
226
|
+
// Leaving it a no-op when `returnFocus` is true reproduces today's
|
|
227
|
+
// behaviour exactly: the prop was never passed before this change.
|
|
228
|
+
const handleCloseAutoFocus = React.useCallback(
|
|
229
|
+
(event: Event) => {
|
|
230
|
+
if (!returnFocus) event.preventDefault();
|
|
231
|
+
},
|
|
232
|
+
[returnFocus]
|
|
233
|
+
);
|
|
234
|
+
|
|
201
235
|
// The name and the description are Radix's own: its `Title` and
|
|
202
236
|
// `Description` register ids in the dialog context and `Content` points
|
|
203
237
|
// `aria-labelledby` / `aria-describedby` at them. Passing our own ids
|
|
@@ -215,6 +249,50 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
215
249
|
// the desktop containers (already frames-checked elsewhere) don't move.
|
|
216
250
|
const isDrawer = container === "drawer";
|
|
217
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
|
+
|
|
218
296
|
const header = (
|
|
219
297
|
<div
|
|
220
298
|
data-slot="responsive-overlay-header"
|
|
@@ -230,19 +308,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
230
308
|
</Description>
|
|
231
309
|
) : null}
|
|
232
310
|
</div>
|
|
233
|
-
|
|
234
|
-
type="button"
|
|
235
|
-
data-slot="responsive-overlay-close"
|
|
236
|
-
onClick={() => onOpenChange(false)}
|
|
237
|
-
// 32px disc, 44px of tap target around it — the frames draw the disc,
|
|
238
|
-
// the accessibility floor asks for the target.
|
|
239
|
-
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"
|
|
240
|
-
>
|
|
241
|
-
<span className="grid size-8 place-items-center rounded-full bg-surface-2 transition-colors hover:bg-muted">
|
|
242
|
-
<X className="size-4" aria-hidden />
|
|
243
|
-
</span>
|
|
244
|
-
<span className="sr-only">{closeLabel}</span>
|
|
245
|
-
</button>
|
|
311
|
+
{closeButton}
|
|
246
312
|
</div>
|
|
247
313
|
);
|
|
248
314
|
|
|
@@ -278,6 +344,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
278
344
|
hideClose
|
|
279
345
|
overlayClassName="bg-scrim-overlay backdrop-blur-none"
|
|
280
346
|
onOpenAutoFocus={handleOpenAutoFocus}
|
|
347
|
+
onCloseAutoFocus={handleCloseAutoFocus}
|
|
281
348
|
{...describedBy}
|
|
282
349
|
data-slot="responsive-overlay-sheet"
|
|
283
350
|
data-modal={modal ? "true" : "false"}
|
|
@@ -310,6 +377,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
310
377
|
showCloseButton={false}
|
|
311
378
|
overlayClassName="bg-scrim-overlay backdrop-blur-none"
|
|
312
379
|
onOpenAutoFocus={handleOpenAutoFocus}
|
|
380
|
+
onCloseAutoFocus={handleCloseAutoFocus}
|
|
313
381
|
{...describedBy}
|
|
314
382
|
className={cn(
|
|
315
383
|
// The dialog's own `grid gap-4 p-6` would put air between the
|
|
@@ -332,6 +400,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
332
400
|
ref={ref}
|
|
333
401
|
overlayClassName="bg-scrim-overlay"
|
|
334
402
|
onOpenAutoFocus={handleOpenAutoFocus}
|
|
403
|
+
onCloseAutoFocus={handleCloseAutoFocus}
|
|
335
404
|
{...(drawerHeight !== undefined ? { snapHeight: drawerHeight } : {})}
|
|
336
405
|
{...describedBy}
|
|
337
406
|
className={cn("flex max-h-[85vh] flex-col p-0", className)}
|
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);
|