@charcuterie/ui 3.1.0 → 3.2.0
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/Dialog/Dialog.d.ts +12 -2
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +2 -2
- package/dist/Dialog/Dialog.js.map +1 -1
- package/dist/Modal/Modal.d.ts +6 -2
- package/dist/Modal/Modal.d.ts.map +1 -1
- package/dist/Modal/Modal.js +1 -1
- package/dist/Modal/Modal.js.map +1 -1
- package/dist/Overlay/OverlayPanel.d.ts +13 -2
- package/dist/Overlay/OverlayPanel.d.ts.map +1 -1
- package/dist/Overlay/OverlayPanel.js +5 -1
- package/dist/Overlay/OverlayPanel.js.map +1 -1
- package/package.json +1 -1
- package/src/Dialog/Dialog.stories.tsx +48 -0
- package/src/Dialog/Dialog.test.tsx +30 -0
- package/src/Dialog/Dialog.tsx +13 -1
- package/src/Modal/Modal.tsx +7 -1
- package/src/Overlay/OverlayPanel.tsx +17 -1
package/dist/Dialog/Dialog.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
1
|
+
import type { ReactNode, RefObject } from "react";
|
|
2
2
|
export type DialogSize = "full" | "lg" | "md" | "sm" | "xl";
|
|
3
3
|
export type DialogProps = {
|
|
4
4
|
/**
|
|
@@ -17,6 +17,16 @@ export type DialogProps = {
|
|
|
17
17
|
* Escape and a backdrop press both close. Off for the one honest
|
|
18
18
|
* case — a destructive confirm the user has to answer.
|
|
19
19
|
*/
|
|
20
|
+
/**
|
|
21
|
+
* Which element takes the caret when the dialog opens.
|
|
22
|
+
*
|
|
23
|
+
* A dialog wrapping a form wants its first field, not the Close
|
|
24
|
+
* button the focus manager would otherwise pick — and a consumer
|
|
25
|
+
* cannot fix that from outside, because focusing in its own
|
|
26
|
+
* effect races the manager and loses. Docket's capture dialog is
|
|
27
|
+
* the case: every keystroke landed on "Close".
|
|
28
|
+
*/
|
|
29
|
+
initialFocus?: RefObject<HTMLElement | null>;
|
|
20
30
|
isDismissable?: boolean;
|
|
21
31
|
isVisible: boolean;
|
|
22
32
|
/**
|
|
@@ -49,5 +59,5 @@ export type DialogProps = {
|
|
|
49
59
|
* "dialog" and nothing else, which is what every hand-rolled modal in
|
|
50
60
|
* the inventory does.
|
|
51
61
|
*/
|
|
52
|
-
export declare const Dialog: ({ children, className, footer, heading, headingLevel, isDismissable, isVisible, onClose, size, }: DialogProps) => ReactNode;
|
|
62
|
+
export declare const Dialog: ({ children, className, footer, heading, headingLevel, initialFocus, isDismissable, isVisible, onClose, size, }: DialogProps) => ReactNode;
|
|
53
63
|
//# sourceMappingURL=Dialog.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Dialog.d.ts","sourceRoot":"","sources":["../../src/Dialog/Dialog.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"Dialog.d.ts","sourceRoot":"","sources":["../../src/Dialog/Dialog.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAMjD,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAA;AAE3D,MAAM,MAAM,WAAW,GAAG;IACxB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,wCAAwC;IACxC,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB,OAAO,EAAE,MAAM,CAAA;IACf,YAAY,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IACxB;;;OAGG;IACH;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAA;IAC5C,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,SAAS,EAAE,OAAO,CAAA;IAClB;;;OAGG;IACH,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,IAAI,CAAC,EAAE,UAAU,CAAA;CAClB,CAAA;AAqBD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,MAAM,GAAI,gHAWpB,WAAW,KAAG,SA6DhB,CAAA"}
|
package/dist/Dialog/Dialog.js
CHANGED
|
@@ -44,10 +44,10 @@ const SIZE_CLASS = {
|
|
|
44
44
|
* "dialog" and nothing else, which is what every hand-rolled modal in
|
|
45
45
|
* the inventory does.
|
|
46
46
|
*/
|
|
47
|
-
export const Dialog = ({ children, className, footer, heading, headingLevel = 2, isDismissable = true, isVisible, onClose, size = "md", }) => {
|
|
47
|
+
export const Dialog = ({ children, className, footer, heading, headingLevel = 2, initialFocus, isDismissable = true, isVisible, onClose, size = "md", }) => {
|
|
48
48
|
const headingId = useUniqueId();
|
|
49
49
|
const Heading = `h${headingLevel}`;
|
|
50
|
-
return (_jsxs(Modal, { "aria-labelledby": headingId, className: toClassName(SIZE_CLASS[size], className), isDismissable: isDismissable, isVisible: isVisible, onClose: onClose, children: [_jsxs("header", { className: "flex shrink-0 items-center justify-between gap-3 border-b border-border-subtle p-4", children: [_jsx(Heading, { className: "font-semibold text-lg leading-tight", id: headingId, children: heading }), isDismissable ? (_jsx(Button, { appearance: "ghost", intent: "neutral", onClick: onClose, size: "sm", children: "Close" })) : null] }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-4",
|
|
50
|
+
return (_jsxs(Modal, { "aria-labelledby": headingId, className: toClassName(SIZE_CLASS[size], className), initialFocus: initialFocus, isDismissable: isDismissable, isVisible: isVisible, onClose: onClose, children: [_jsxs("header", { className: "flex shrink-0 items-center justify-between gap-3 border-b border-border-subtle p-4", children: [_jsx(Heading, { className: "font-semibold text-lg leading-tight", id: headingId, children: heading }), isDismissable ? (_jsx(Button, { appearance: "ghost", intent: "neutral", onClick: onClose, size: "sm", children: "Close" })) : null] }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-4",
|
|
51
51
|
// biome-ignore lint/a11y/noNoninteractiveTabindex: a scroll container must be focusable or a keyboard user can see the pane and cannot scroll it — axe's `scrollable-region-focusable`, which fails this component without the line. Same call, and the same wording, as `LogViewer`'s scroll pane. The rule is right about `<div>`s in general and wrong about scrollers.
|
|
52
52
|
tabIndex: 0, children: children }), footer ? (_jsx("footer", { className: "flex shrink-0 flex-wrap justify-end gap-2 border-t border-border-subtle p-4", children: footer })) : null] }));
|
|
53
53
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Dialog.js","sourceRoot":"","sources":["../../src/Dialog/Dialog.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAGhD,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAsB,CAAA;AAC7C,OAAO,EAAE,KAAK,EAAE,MAAM,mBAAoB,CAAA;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;
|
|
1
|
+
{"version":3,"file":"Dialog.js","sourceRoot":"","sources":["../../src/Dialog/Dialog.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAGhD,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAsB,CAAA;AAC7C,OAAO,EAAE,KAAK,EAAE,MAAM,mBAAoB,CAAA;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAyC/C;;;;GAIG;AACH,MAAM,UAAU,GAA+B;IAC7C,EAAE,EAAE,+BAA+B;IACnC,EAAE,EAAE,+BAA+B;IACnC,EAAE,EAAE,gCAAgC;IACpC,iEAAiE;IACjE,8DAA8D;IAC9D,6DAA6D;IAC7D,kEAAkE;IAClE,+DAA+D;IAC/D,+DAA+D;IAC/D,EAAE,EAAE,gCAAgC;IACpC,IAAI,EAAE,0DAA0D;CACjE,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,EACrB,QAAQ,EACR,SAAS,EACT,MAAM,EACN,OAAO,EACP,YAAY,GAAG,CAAC,EAChB,YAAY,EACZ,aAAa,GAAG,IAAI,EACpB,SAAS,EACT,OAAO,EACP,IAAI,GAAG,IAAI,GACC,EAAa,EAAE;IAC3B,MAAM,SAAS,GAAG,WAAW,EAAE,CAAA;IAE/B,MAAM,OAAO,GAAG,IAAI,YAAY,EAAW,CAAA;IAE3C,OAAO,CACL,MAAC,KAAK,uBACa,SAAS,EAC1B,SAAS,EAAE,WAAW,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,SAAS,CAAC,EACnD,YAAY,EAAE,YAAY,EAC1B,aAAa,EAAE,aAAa,EAC5B,SAAS,EAAE,SAAS,EACpB,OAAO,EAAE,OAAO,aAEhB,kBAAQ,SAAS,EAAC,oFAAoF,aACpG,KAAC,OAAO,IACN,SAAS,EAAC,qCAAqC,EAC/C,EAAE,EAAE,SAAS,YAEZ,OAAO,GACA,EAET,aAAa,CAAC,CAAC,CAAC,CACf,KAAC,MAAM,IACL,UAAU,EAAC,OAAO,EAClB,MAAM,EAAC,SAAS,EAChB,OAAO,EAAE,OAAO,EAChB,IAAI,EAAC,IAAI,sBAGF,CACV,CAAC,CAAC,CAAC,IAAI,IACD,EAcT,cACE,SAAS,EAAC,kCAAkC;gBAC5C,2WAA2W;gBAC3W,QAAQ,EAAE,CAAC,YAEV,QAAQ,GACL,EAEL,MAAM,CAAC,CAAC,CAAC,CACR,iBAAQ,SAAS,EAAC,6EAA6E,YAC5F,MAAM,GACA,CACV,CAAC,CAAC,CAAC,IAAI,IACF,CACT,CAAA;AACH,CAAC,CAAA"}
|
package/dist/Modal/Modal.d.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
1
|
+
import type { ReactNode, RefObject } from "react";
|
|
2
2
|
export type ModalProps = {
|
|
3
3
|
"aria-label"?: string;
|
|
4
4
|
"aria-labelledby"?: string;
|
|
5
5
|
children: ReactNode;
|
|
6
6
|
className?: string;
|
|
7
|
+
/** Which element takes the caret when the modal opens. Without
|
|
8
|
+
* it the focus manager takes the first tabbable element — the
|
|
9
|
+
* Close button on anything with chrome. */
|
|
10
|
+
initialFocus?: RefObject<HTMLElement | null>;
|
|
7
11
|
/** Escape and a backdrop press both close. */
|
|
8
12
|
isDismissable?: boolean;
|
|
9
13
|
isVisible: boolean;
|
|
@@ -14,5 +18,5 @@ export type ModalProps = {
|
|
|
14
18
|
onClose: () => void;
|
|
15
19
|
role?: "alertdialog" | "dialog";
|
|
16
20
|
};
|
|
17
|
-
export declare const Modal: ({ "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, children, className, isDismissable, isVisible, onClose, role, }: ModalProps) => ReactNode;
|
|
21
|
+
export declare const Modal: ({ "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, children, className, initialFocus, isDismissable, isVisible, onClose, role, }: ModalProps) => ReactNode;
|
|
18
22
|
//# sourceMappingURL=Modal.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Modal.d.ts","sourceRoot":"","sources":["../../src/Modal/Modal.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"Modal.d.ts","sourceRoot":"","sources":["../../src/Modal/Modal.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAKjD,MAAM,MAAM,UAAU,GAAG;IACvB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,QAAQ,EAAE,SAAS,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;+CAE2C;IAC3C,YAAY,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAA;IAC5C,8CAA8C;IAC9C,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,SAAS,EAAE,OAAO,CAAA;IAClB;;;OAGG;IACH,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,IAAI,CAAC,EAAE,aAAa,GAAG,QAAQ,CAAA;CAChC,CAAA;AAqCD,eAAO,MAAM,KAAK,GAAI,6IAUnB,UAAU,KAAG,SAaf,CAAA"}
|
package/dist/Modal/Modal.js
CHANGED
|
@@ -34,5 +34,5 @@ import { toClassName } from "../toClassName.js";
|
|
|
34
34
|
* and dismiss top-first.
|
|
35
35
|
*/
|
|
36
36
|
const MODAL_BOX_CLASS = "flex max-h-[85dvh] flex-col overflow-hidden rounded-lg border border-border-default bg-surface-overlay text-content-primary shadow-high";
|
|
37
|
-
export const Modal = ({ "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, children, className, isDismissable = true, isVisible, onClose, role = "dialog", }) => (_jsx(OverlayPanel, { "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className: toClassName(MODAL_BOX_CLASS, className), isDismissable: isDismissable, isVisible: isVisible, onClose: onClose, role: role, children: children }));
|
|
37
|
+
export const Modal = ({ "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, children, className, initialFocus, isDismissable = true, isVisible, onClose, role = "dialog", }) => (_jsx(OverlayPanel, { "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className: toClassName(MODAL_BOX_CLASS, className), initialFocus: initialFocus, isDismissable: isDismissable, isVisible: isVisible, onClose: onClose, role: role, children: children }));
|
|
38
38
|
//# sourceMappingURL=Modal.js.map
|
package/dist/Modal/Modal.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Modal.js","sourceRoot":"","sources":["../../src/Modal/Modal.tsx"],"names":[],"mappings":";AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,4BAA6B,CAAA;AAC1D,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;
|
|
1
|
+
{"version":3,"file":"Modal.js","sourceRoot":"","sources":["../../src/Modal/Modal.tsx"],"names":[],"mappings":";AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,4BAA6B,CAAA;AAC1D,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAsB/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,eAAe,GACnB,yIAAyI,CAAA;AAE3I,MAAM,CAAC,MAAM,KAAK,GAAG,CAAC,EACpB,YAAY,EAAE,SAAS,EACvB,iBAAiB,EAAE,cAAc,EACjC,QAAQ,EACR,SAAS,EACT,YAAY,EACZ,aAAa,GAAG,IAAI,EACpB,SAAS,EACT,OAAO,EACP,IAAI,GAAG,QAAQ,GACJ,EAAa,EAAE,CAAC,CAC3B,KAAC,YAAY,kBACC,SAAS,qBACJ,cAAc,EAC/B,SAAS,EAAE,WAAW,CAAC,eAAe,EAAE,SAAS,CAAC,EAClD,YAAY,EAAE,YAAY,EAC1B,aAAa,EAAE,aAAa,EAC5B,SAAS,EAAE,SAAS,EACpB,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,IAAI,YAET,QAAQ,GACI,CAChB,CAAA"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
1
|
+
import type { ReactNode, RefObject } from "react";
|
|
2
2
|
/**
|
|
3
3
|
* The portalled, backdrop-backed, focus-trapping panel that `Modal`
|
|
4
4
|
* renders. No chrome — that is `Dialog`'s job — only the four things
|
|
@@ -40,11 +40,22 @@ export type OverlayPanelProps = {
|
|
|
40
40
|
"aria-labelledby"?: string;
|
|
41
41
|
children: ReactNode;
|
|
42
42
|
className?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Which element takes the caret when the panel opens.
|
|
45
|
+
*
|
|
46
|
+
* Without it the focus manager takes the first tabbable element,
|
|
47
|
+
* which for anything with chrome is the **Close button** — so a
|
|
48
|
+
* panel wrapping a form opens with the caret on "Close" and the
|
|
49
|
+
* first thing typed goes nowhere. A consumer cannot fix that from
|
|
50
|
+
* outside: focusing in its own effect races the manager, and the
|
|
51
|
+
* manager wins, which reads as the app ignoring you.
|
|
52
|
+
*/
|
|
53
|
+
initialFocus?: RefObject<HTMLElement | null>;
|
|
43
54
|
/** Escape and an outside press both close. */
|
|
44
55
|
isDismissable?: boolean;
|
|
45
56
|
isVisible: boolean;
|
|
46
57
|
onClose: () => void;
|
|
47
58
|
role?: "alertdialog" | "dialog";
|
|
48
59
|
};
|
|
49
|
-
export declare const OverlayPanel: ({ "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, children, className, isDismissable, isVisible, onClose, role, }: OverlayPanelProps) => ReactNode;
|
|
60
|
+
export declare const OverlayPanel: ({ "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, children, className, initialFocus, isDismissable, isVisible, onClose, role, }: OverlayPanelProps) => ReactNode;
|
|
50
61
|
//# sourceMappingURL=OverlayPanel.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"OverlayPanel.d.ts","sourceRoot":"","sources":["../../src/Overlay/OverlayPanel.tsx"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"OverlayPanel.d.ts","sourceRoot":"","sources":["../../src/Overlay/OverlayPanel.tsx"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAOjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,QAAQ,EAAE,SAAS,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;;;;;OASG;IACH,YAAY,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAA;IAC5C,8CAA8C;IAC9C,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,SAAS,EAAE,OAAO,CAAA;IAClB,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,IAAI,CAAC,EAAE,aAAa,GAAG,QAAQ,CAAA;CAChC,CAAA;AAED,eAAO,MAAM,YAAY,GAAI,6IAU1B,iBAAiB,KAAG,SA0HtB,CAAA"}
|
|
@@ -5,7 +5,7 @@ import { useCallback, useEffect } from "react";
|
|
|
5
5
|
import { lockScrollBehind } from "./lockScrollBehind.js";
|
|
6
6
|
import { useOverlayStack } from "./OverlayStack.js";
|
|
7
7
|
import { SharedBackdrop } from "./SharedBackdrop.js";
|
|
8
|
-
export const OverlayPanel = ({ "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, children, className, isDismissable = true, isVisible, onClose, role = "dialog", }) => {
|
|
8
|
+
export const OverlayPanel = ({ "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, children, className, initialFocus, isDismissable = true, isVisible, onClose, role = "dialog", }) => {
|
|
9
9
|
const stack = useOverlayStack();
|
|
10
10
|
const id = useUniqueId();
|
|
11
11
|
// A stable close, so registering it does not churn the stack as the
|
|
@@ -59,6 +59,10 @@ export const OverlayPanel = ({ "aria-label": ariaLabel, "aria-labelledby": ariaL
|
|
|
59
59
|
return null;
|
|
60
60
|
}
|
|
61
61
|
return (_jsxs(_Fragment, { children: [stack.isProvided ? null : (_jsx(SharedBackdrop, { isVisible: true })), _jsx(FloatingPortal, { children: _jsx("div", { className: "fixed inset-0 z-[var(--layer-modal)] flex items-center justify-center", children: _jsx(FloatingFocusManager, { context: context,
|
|
62
|
+
// `undefined` keeps the manager's own default (the first
|
|
63
|
+
// tabbable), so nothing changes for a panel that does not
|
|
64
|
+
// ask.
|
|
65
|
+
initialFocus: initialFocus ?? undefined,
|
|
62
66
|
// Only the top traps focus; a lower panel is `inert`
|
|
63
67
|
// anyway, and two live traps fight over one caret.
|
|
64
68
|
disabled: !isTop, modal: true, children: _jsx("div", { ...getFloatingProps(), "aria-label": ariaLabel,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"OverlayPanel.js","sourceRoot":"","sources":["../../src/Overlay/OverlayPanel.tsx"],"names":[],"mappings":";AAAA,OAAO,EACL,YAAY,EACZ,WAAW,GACZ,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACL,oBAAoB,EACpB,cAAc,EACd,UAAU,EACV,WAAW,EACX,eAAe,EACf,OAAO,GACR,MAAM,oBAAoB,CAAA;AAE3B,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAE9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AACxD,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAoB,CAAA;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAsB,CAAA;
|
|
1
|
+
{"version":3,"file":"OverlayPanel.js","sourceRoot":"","sources":["../../src/Overlay/OverlayPanel.tsx"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useVisibility } from "@charcuterie/logic"
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
3
|
import type { ReactNode } from "react"
|
|
4
|
+
import { useRef } from "react"
|
|
4
5
|
|
|
5
6
|
import { toStoryChoice } from "../argTypes.storyHelpers.ts"
|
|
6
7
|
import { Button } from "../Button/Button.tsx"
|
|
@@ -92,6 +93,34 @@ const DialogDemo = ({
|
|
|
92
93
|
)
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
const InitialFocusDemo = (): ReactNode => {
|
|
97
|
+
const { hide, isVisible, show } = useVisibility()
|
|
98
|
+
const titleRef = useRef<HTMLInputElement>(null)
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<>
|
|
102
|
+
<Button appearance="soft" onClick={show} size="sm">
|
|
103
|
+
Add a title
|
|
104
|
+
</Button>
|
|
105
|
+
|
|
106
|
+
<Dialog
|
|
107
|
+
heading="Add a title"
|
|
108
|
+
initialFocus={titleRef}
|
|
109
|
+
isVisible={isVisible}
|
|
110
|
+
onClose={hide}
|
|
111
|
+
>
|
|
112
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
113
|
+
Title
|
|
114
|
+
<input
|
|
115
|
+
className="rounded-md border border-border-subtle bg-surface-base px-3 py-2"
|
|
116
|
+
ref={titleRef}
|
|
117
|
+
/>
|
|
118
|
+
</label>
|
|
119
|
+
</Dialog>
|
|
120
|
+
</>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
95
124
|
export const Default: Story = {
|
|
96
125
|
args: {
|
|
97
126
|
heading: "Bay 3",
|
|
@@ -366,6 +395,25 @@ export const NoBody: Story = {
|
|
|
366
395
|
render: () => <NoBodyDemo />,
|
|
367
396
|
}
|
|
368
397
|
|
|
398
|
+
/**
|
|
399
|
+
* A dialog that wraps a FORM wants the caret in its first field,
|
|
400
|
+
* not on the Close button the focus manager would otherwise pick.
|
|
401
|
+
*
|
|
402
|
+
* This is not a nicety: a capture dialog exists to be typed into
|
|
403
|
+
* the moment it opens, and without `initialFocus` the first thing
|
|
404
|
+
* typed goes to a button and vanishes. A consumer cannot fix it
|
|
405
|
+
* from outside — focusing in its own effect races the focus
|
|
406
|
+
* manager, and the manager wins.
|
|
407
|
+
*/
|
|
408
|
+
export const InitialFocus: Story = {
|
|
409
|
+
args: {
|
|
410
|
+
heading: "Bay 3",
|
|
411
|
+
isVisible: false,
|
|
412
|
+
onClose: () => {},
|
|
413
|
+
},
|
|
414
|
+
render: () => <InitialFocusDemo />,
|
|
415
|
+
}
|
|
416
|
+
|
|
369
417
|
export const Interactive: Story = {
|
|
370
418
|
args: {
|
|
371
419
|
heading: "Bay 3",
|
|
@@ -14,6 +14,7 @@ const {
|
|
|
14
14
|
AllStates,
|
|
15
15
|
AllVariants,
|
|
16
16
|
Default,
|
|
17
|
+
InitialFocus,
|
|
17
18
|
Interactive,
|
|
18
19
|
NoBody,
|
|
19
20
|
Responsive,
|
|
@@ -338,3 +339,32 @@ test("stacked dialogs share one scrim and dismiss top-first", async () => {
|
|
|
338
339
|
document.body.querySelector(".bg-scrim"),
|
|
339
340
|
).toBeNull()
|
|
340
341
|
})
|
|
342
|
+
|
|
343
|
+
test("initialFocus puts the caret in the field, not on Close", async () => {
|
|
344
|
+
// Without it the focus manager takes the first tabbable element,
|
|
345
|
+
// which is the Close button — so a dialog wrapping a form eats
|
|
346
|
+
// whatever is typed first. The consumer cannot fix that from
|
|
347
|
+
// outside: focusing in its own effect races the manager and
|
|
348
|
+
// loses, which reads as the app ignoring you.
|
|
349
|
+
const { body, canvas } = await mountStory(InitialFocus)
|
|
350
|
+
|
|
351
|
+
await userEvent.click(
|
|
352
|
+
expectAgentDrivable(canvas, {
|
|
353
|
+
name: "Add a title",
|
|
354
|
+
role: "button",
|
|
355
|
+
}),
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
const field = expectAgentDrivable(body, {
|
|
359
|
+
name: "Title",
|
|
360
|
+
role: "textbox",
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
await waitFor(async () => {
|
|
364
|
+
await expect(field).toHaveFocus()
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
await userEvent.keyboard("Sharpen the chisels")
|
|
368
|
+
|
|
369
|
+
await expect(field).toHaveValue("Sharpen the chisels")
|
|
370
|
+
})
|
package/src/Dialog/Dialog.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useUniqueId } from "@charcuterie/logic"
|
|
2
|
-
import type { ReactNode } from "react"
|
|
2
|
+
import type { ReactNode, RefObject } from "react"
|
|
3
3
|
|
|
4
4
|
import { Button } from "../Button/Button.tsx"
|
|
5
5
|
import { Modal } from "../Modal/Modal.tsx"
|
|
@@ -24,6 +24,16 @@ export type DialogProps = {
|
|
|
24
24
|
* Escape and a backdrop press both close. Off for the one honest
|
|
25
25
|
* case — a destructive confirm the user has to answer.
|
|
26
26
|
*/
|
|
27
|
+
/**
|
|
28
|
+
* Which element takes the caret when the dialog opens.
|
|
29
|
+
*
|
|
30
|
+
* A dialog wrapping a form wants its first field, not the Close
|
|
31
|
+
* button the focus manager would otherwise pick — and a consumer
|
|
32
|
+
* cannot fix that from outside, because focusing in its own
|
|
33
|
+
* effect races the manager and loses. Docket's capture dialog is
|
|
34
|
+
* the case: every keystroke landed on "Close".
|
|
35
|
+
*/
|
|
36
|
+
initialFocus?: RefObject<HTMLElement | null>
|
|
27
37
|
isDismissable?: boolean
|
|
28
38
|
isVisible: boolean
|
|
29
39
|
/**
|
|
@@ -82,6 +92,7 @@ export const Dialog = ({
|
|
|
82
92
|
footer,
|
|
83
93
|
heading,
|
|
84
94
|
headingLevel = 2,
|
|
95
|
+
initialFocus,
|
|
85
96
|
isDismissable = true,
|
|
86
97
|
isVisible,
|
|
87
98
|
onClose,
|
|
@@ -95,6 +106,7 @@ export const Dialog = ({
|
|
|
95
106
|
<Modal
|
|
96
107
|
aria-labelledby={headingId}
|
|
97
108
|
className={toClassName(SIZE_CLASS[size], className)}
|
|
109
|
+
initialFocus={initialFocus}
|
|
98
110
|
isDismissable={isDismissable}
|
|
99
111
|
isVisible={isVisible}
|
|
100
112
|
onClose={onClose}
|
package/src/Modal/Modal.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ReactNode } from "react"
|
|
1
|
+
import type { ReactNode, RefObject } from "react"
|
|
2
2
|
|
|
3
3
|
import { OverlayPanel } from "../Overlay/OverlayPanel.tsx"
|
|
4
4
|
import { toClassName } from "../toClassName.ts"
|
|
@@ -8,6 +8,10 @@ export type ModalProps = {
|
|
|
8
8
|
"aria-labelledby"?: string
|
|
9
9
|
children: ReactNode
|
|
10
10
|
className?: string
|
|
11
|
+
/** Which element takes the caret when the modal opens. Without
|
|
12
|
+
* it the focus manager takes the first tabbable element — the
|
|
13
|
+
* Close button on anything with chrome. */
|
|
14
|
+
initialFocus?: RefObject<HTMLElement | null>
|
|
11
15
|
/** Escape and a backdrop press both close. */
|
|
12
16
|
isDismissable?: boolean
|
|
13
17
|
isVisible: boolean
|
|
@@ -59,6 +63,7 @@ export const Modal = ({
|
|
|
59
63
|
"aria-labelledby": ariaLabelledBy,
|
|
60
64
|
children,
|
|
61
65
|
className,
|
|
66
|
+
initialFocus,
|
|
62
67
|
isDismissable = true,
|
|
63
68
|
isVisible,
|
|
64
69
|
onClose,
|
|
@@ -68,6 +73,7 @@ export const Modal = ({
|
|
|
68
73
|
aria-label={ariaLabel}
|
|
69
74
|
aria-labelledby={ariaLabelledBy}
|
|
70
75
|
className={toClassName(MODAL_BOX_CLASS, className)}
|
|
76
|
+
initialFocus={initialFocus}
|
|
71
77
|
isDismissable={isDismissable}
|
|
72
78
|
isVisible={isVisible}
|
|
73
79
|
onClose={onClose}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
useInteractions,
|
|
11
11
|
useRole,
|
|
12
12
|
} from "@floating-ui/react"
|
|
13
|
-
import type { ReactNode } from "react"
|
|
13
|
+
import type { ReactNode, RefObject } from "react"
|
|
14
14
|
import { useCallback, useEffect } from "react"
|
|
15
15
|
|
|
16
16
|
import { lockScrollBehind } from "./lockScrollBehind.ts"
|
|
@@ -58,6 +58,17 @@ export type OverlayPanelProps = {
|
|
|
58
58
|
"aria-labelledby"?: string
|
|
59
59
|
children: ReactNode
|
|
60
60
|
className?: string
|
|
61
|
+
/**
|
|
62
|
+
* Which element takes the caret when the panel opens.
|
|
63
|
+
*
|
|
64
|
+
* Without it the focus manager takes the first tabbable element,
|
|
65
|
+
* which for anything with chrome is the **Close button** — so a
|
|
66
|
+
* panel wrapping a form opens with the caret on "Close" and the
|
|
67
|
+
* first thing typed goes nowhere. A consumer cannot fix that from
|
|
68
|
+
* outside: focusing in its own effect races the manager, and the
|
|
69
|
+
* manager wins, which reads as the app ignoring you.
|
|
70
|
+
*/
|
|
71
|
+
initialFocus?: RefObject<HTMLElement | null>
|
|
61
72
|
/** Escape and an outside press both close. */
|
|
62
73
|
isDismissable?: boolean
|
|
63
74
|
isVisible: boolean
|
|
@@ -70,6 +81,7 @@ export const OverlayPanel = ({
|
|
|
70
81
|
"aria-labelledby": ariaLabelledBy,
|
|
71
82
|
children,
|
|
72
83
|
className,
|
|
84
|
+
initialFocus,
|
|
73
85
|
isDismissable = true,
|
|
74
86
|
isVisible,
|
|
75
87
|
onClose,
|
|
@@ -160,6 +172,10 @@ export const OverlayPanel = ({
|
|
|
160
172
|
<div className="fixed inset-0 z-[var(--layer-modal)] flex items-center justify-center">
|
|
161
173
|
<FloatingFocusManager
|
|
162
174
|
context={context}
|
|
175
|
+
// `undefined` keeps the manager's own default (the first
|
|
176
|
+
// tabbable), so nothing changes for a panel that does not
|
|
177
|
+
// ask.
|
|
178
|
+
initialFocus={initialFocus ?? undefined}
|
|
163
179
|
// Only the top traps focus; a lower panel is `inert`
|
|
164
180
|
// anyway, and two live traps fight over one caret.
|
|
165
181
|
disabled={!isTop}
|