@dsn-starter-kit/components-react 3.3.0 → 3.5.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/DateInput/DateInput.d.mts.map +1 -1
- package/dist/DateInput/DateInput2.mjs +0 -1
- package/dist/DateInput/DateInput2.mjs.map +1 -1
- package/dist/Drawer/Drawer.d.mts +11 -3
- package/dist/Drawer/Drawer.d.mts.map +1 -1
- package/dist/Drawer/Drawer2.mjs +28 -4
- package/dist/Drawer/Drawer2.mjs.map +1 -1
- package/dist/ModalDialog/ModalDialog.d.mts +12 -2
- package/dist/ModalDialog/ModalDialog.d.mts.map +1 -1
- package/dist/ModalDialog/ModalDialog2.mjs +35 -8
- package/dist/ModalDialog/ModalDialog2.mjs.map +1 -1
- package/dist/PageHeader/PageHeader2.mjs +1 -0
- package/dist/PageHeader/PageHeader2.mjs.map +1 -1
- package/dist/SearchInput/SearchInput.d.mts.map +1 -1
- package/dist/SearchInput/SearchInput2.mjs +0 -1
- package/dist/SearchInput/SearchInput2.mjs.map +1 -1
- package/dist/Select/Select.d.mts.map +1 -1
- package/dist/Select/Select2.mjs +0 -1
- package/dist/Select/Select2.mjs.map +1 -1
- package/dist/TimeInput/TimeInput.d.mts.map +1 -1
- package/dist/TimeInput/TimeInput2.mjs +0 -1
- package/dist/TimeInput/TimeInput2.mjs.map +1 -1
- package/dist/index.cjs +157 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +4661 -3168
- package/dist/index.d.cts +23 -5
- package/dist/index.d.cts.map +1 -1
- package/dist/utils/focusTrap.mjs +99 -0
- package/dist/utils/focusTrap.mjs.map +1 -0
- package/package.json +3 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Select2.mjs","names":[],"sources":["../../src/Select/Select.tsx"],"sourcesContent":["import React from 'react';\nimport { classNames, FormControlWidth } from '@dsn-starter-kit/core';\nimport { Icon } from '../Icon';\nimport '
|
|
1
|
+
{"version":3,"file":"Select2.mjs","names":[],"sources":["../../src/Select/Select.tsx"],"sourcesContent":["import React from 'react';\nimport { classNames, FormControlWidth } from '@dsn-starter-kit/core';\nimport { Icon } from '../Icon';\nimport './Select.css';\n\nexport interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {\n /**\n * Whether the select is in an invalid state\n * @default false\n */\n invalid?: boolean;\n\n /**\n * Width variant for the select\n * @default undefined (uses default max-width from form-control)\n */\n width?: FormControlWidth;\n\n /**\n * Additional CSS class names\n */\n className?: string;\n}\n\n/**\n * Select component\n * Dropdown select with a custom chevron-down icon at inline-end.\n * The native select arrow is hidden; the custom icon is decorative and non-interactive.\n *\n * @example\n * ```tsx\n * // Basic usage\n * <Select>\n * <option value=\"\">Kies een optie</option>\n * <option value=\"1\">Optie 1</option>\n * <option value=\"2\">Optie 2</option>\n * </Select>\n *\n * // With label\n * <FormField label=\"Land\" htmlFor=\"land\">\n * <Select id=\"land\">\n * <option value=\"\">Kies een land</option>\n * <option value=\"nl\">Nederland</option>\n * <option value=\"be\">België</option>\n * </Select>\n * </FormField>\n *\n * // Width variant\n * <Select width=\"lg\">...</Select>\n *\n * // Invalid state\n * <Select invalid aria-invalid=\"true\" aria-describedby=\"error\">...</Select>\n * ```\n */\nexport const Select = React.forwardRef<HTMLSelectElement, SelectProps>(\n ({ className, invalid, width, disabled, children, ...props }, ref) => {\n const wrapperClasses = classNames(\n 'dsn-select-wrapper',\n width && `dsn-select-wrapper--width-${width}`\n );\n\n const selectClasses = classNames('dsn-text-input', 'dsn-select', className);\n\n return (\n <div className={wrapperClasses}>\n <select\n ref={ref}\n className={selectClasses}\n aria-invalid={invalid || undefined}\n disabled={disabled}\n {...props}\n >\n {children}\n </select>\n {!disabled && (\n <Icon name=\"chevron-down\" className=\"dsn-select__icon\" aria-hidden />\n )}\n </div>\n );\n }\n);\n\nSelect.displayName = 'Select';\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsDA,MAAa,SAAS,MAAM,YACzB,EAAE,WAAW,SAAS,OAAO,UAAU,UAAU,GAAG,SAAS,QAAQ;CACpE,MAAM,iBAAiB,WACrB,sBACA,SAAS,6BAA6B,OACxC;CAEA,MAAM,gBAAgB,WAAW,kBAAkB,cAAc,SAAS;CAE1E,OACE,qBAAC,OAAD;EAAK,WAAW;EAAhB,UAAA,CACE,oBAAC,UAAD;GACO;GACL,WAAW;GACX,gBAAc,WAAW,KAAA;GACf;GACV,GAAI;GAEH;EACK,CAAA,GACP,CAAC,YACA,oBAAC,MAAD;GAAM,MAAK;GAAe,WAAU;GAAmB,eAAA;EAAa,CAAA,CAEnE;;AAET,CACF;AAEA,OAAO,cAAc"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TimeInput.d.mts","names":[],"sources":["../../src/TimeInput/TimeInput.tsx"],"mappings":";;
|
|
1
|
+
{"version":3,"file":"TimeInput.d.mts","names":[],"sources":["../../src/TimeInput/TimeInput.tsx"],"mappings":";;UAMiB,uBAAuB,KACtC,MAAM,oBAAoB;;;;;EAO1B;;;;EAKA;;;;;;;;;;;;;;;;;;;;;;;cAwBW,WAAS,MAAA,0BAAA,iBAAA,MAAA,cAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TimeInput2.mjs","names":[],"sources":["../../src/TimeInput/TimeInput.tsx"],"sourcesContent":["import React from 'react';\nimport { classNames } from '@dsn-starter-kit/core';\nimport { Button } from '../Button';\nimport { Icon } from '../Icon';\nimport '
|
|
1
|
+
{"version":3,"file":"TimeInput2.mjs","names":[],"sources":["../../src/TimeInput/TimeInput.tsx"],"sourcesContent":["import React from 'react';\nimport { classNames } from '@dsn-starter-kit/core';\nimport { Button } from '../Button';\nimport { Icon } from '../Icon';\nimport './TimeInput.css';\n\nexport interface TimeInputProps extends Omit<\n React.InputHTMLAttributes<HTMLInputElement>,\n 'type'\n> {\n /**\n * Whether the input is in an invalid state\n * @default false\n */\n invalid?: boolean;\n\n /**\n * Additional CSS class names\n */\n className?: string;\n}\n\n/**\n * Time Input component\n * Time input with an interactive clock button at inline-end that opens the native time picker.\n * Fixed width (sm) — not configurable, as time inputs have a predictable content width.\n *\n * @example\n * ```tsx\n * // Basic usage\n * <TimeInput />\n *\n * // With label\n * <FormFieldLabel htmlFor=\"time\">Tijd</FormFieldLabel>\n * <TimeInput id=\"time\" />\n *\n * // With default value\n * <TimeInput defaultValue=\"14:30\" />\n *\n * // Invalid state\n * <TimeInput invalid aria-invalid=\"true\" aria-describedby=\"error\" />\n * ```\n */\nexport const TimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(\n ({ className, invalid, disabled, readOnly, ...props }, ref) => {\n const inputRef = React.useRef<HTMLInputElement>(null);\n\n // Merge external ref with internal ref\n const handleRef = React.useCallback(\n (node: HTMLInputElement | null) => {\n (inputRef as React.MutableRefObject<HTMLInputElement | null>).current =\n node;\n if (typeof ref === 'function') {\n ref(node);\n } else if (ref) {\n (ref as React.MutableRefObject<HTMLInputElement | null>).current =\n node;\n }\n },\n [ref]\n );\n\n const handleButtonClick = () => {\n if (inputRef.current && 'showPicker' in inputRef.current) {\n inputRef.current.showPicker();\n }\n };\n\n const inputClasses = classNames(\n 'dsn-text-input',\n 'dsn-time-input',\n className\n );\n\n return (\n <div className=\"dsn-time-input-wrapper\">\n <input\n ref={handleRef}\n type=\"time\"\n className={inputClasses}\n aria-invalid={invalid || undefined}\n disabled={disabled}\n readOnly={readOnly}\n {...props}\n />\n {!disabled && !readOnly && (\n <Button\n variant=\"subtle\"\n size=\"small\"\n iconOnly\n iconStart={<Icon name=\"clock\" aria-hidden />}\n className=\"dsn-time-input__button\"\n onClick={handleButtonClick}\n >\n Tijdkiezer openen\n </Button>\n )}\n </div>\n );\n }\n);\n\nTimeInput.displayName = 'TimeInput';\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2CA,MAAa,YAAY,MAAM,YAC5B,EAAE,WAAW,SAAS,UAAU,UAAU,GAAG,SAAS,QAAQ;CAC7D,MAAM,WAAW,MAAM,OAAyB,IAAI;CAGpD,MAAM,YAAY,MAAM,aACrB,SAAkC;EACjC,SAA8D,UAC5D;EACF,IAAI,OAAO,QAAQ,YACjB,IAAI,IAAI;OACH,IAAI,KACT,IAAyD,UACvD;CAEN,GACA,CAAC,GAAG,CACN;CAEA,MAAM,0BAA0B;EAC9B,IAAI,SAAS,WAAW,gBAAgB,SAAS,SAC/C,SAAS,QAAQ,WAAW;CAEhC;CAEA,MAAM,eAAe,WACnB,kBACA,kBACA,SACF;CAEA,OACE,qBAAC,OAAD;EAAK,WAAU;EAAf,UAAA,CACE,oBAAC,SAAD;GACE,KAAK;GACL,MAAK;GACL,WAAW;GACX,gBAAc,WAAW,KAAA;GACf;GACA;GACV,GAAI;EACL,CAAA,GACA,CAAC,YAAY,CAAC,YACb,oBAAC,QAAD;GACE,SAAQ;GACR,MAAK;GACL,UAAA;GACA,WAAW,oBAAC,MAAD;IAAM,MAAK;IAAQ,eAAA;GAAa,CAAA;GAC3C,WAAU;GACV,SAAS;GACV,UAAA;EAEO,CAAA,CAEP;;AAET,CACF;AAEA,UAAU,cAAc"}
|
package/dist/index.cjs
CHANGED
|
@@ -3431,8 +3431,106 @@ const PageFooter = react.default.forwardRef(({ className, slot1, slot2, slot3, s
|
|
|
3431
3431
|
});
|
|
3432
3432
|
PageFooter.displayName = "PageFooter";
|
|
3433
3433
|
//#endregion
|
|
3434
|
+
//#region src/utils/focusTrap.ts
|
|
3435
|
+
/**
|
|
3436
|
+
* Focus-trap hulpfuncties voor dialoogachtige componenten.
|
|
3437
|
+
*
|
|
3438
|
+
* Gebaseerd op de techniek van Hidde de Vries:
|
|
3439
|
+
* https://hidde.blog/using-javascript-to-trap-focus-in-an-element/
|
|
3440
|
+
*
|
|
3441
|
+
* Het native `<dialog>` element trapt de focus zelf zodra het via `.showModal()`
|
|
3442
|
+
* geopend wordt. Die native trap is echter niet overal betrouwbaar: zodra het
|
|
3443
|
+
* dialoogvenster in een `<iframe>` staat (Storybook, embeds, previews) laat
|
|
3444
|
+
* Safari de focus alsnog over de iframe-grens ontsnappen naar de pagina
|
|
3445
|
+
* eromheen. Deze expliciete trap staat daarbovenop en maakt het gedrag
|
|
3446
|
+
* browseronafhankelijk.
|
|
3447
|
+
*/
|
|
3448
|
+
/**
|
|
3449
|
+
* Selector voor elementen die standaard in de tabvolgorde staan.
|
|
3450
|
+
* Uitgeschakelde controls en `tabindex="-1"` vallen er bewust buiten.
|
|
3451
|
+
*/
|
|
3452
|
+
const FOCUSABLE_SELECTOR = [
|
|
3453
|
+
"a[href]",
|
|
3454
|
+
"area[href]",
|
|
3455
|
+
"button:not(:disabled)",
|
|
3456
|
+
"input:not(:disabled):not([type=\"hidden\"])",
|
|
3457
|
+
"select:not(:disabled)",
|
|
3458
|
+
"textarea:not(:disabled)",
|
|
3459
|
+
"details > summary:first-of-type",
|
|
3460
|
+
"iframe",
|
|
3461
|
+
"audio[controls]",
|
|
3462
|
+
"video[controls]",
|
|
3463
|
+
"[contenteditable]:not([contenteditable=\"false\"])",
|
|
3464
|
+
"[tabindex]:not([tabindex^=\"-\"])"
|
|
3465
|
+
].join(", ");
|
|
3466
|
+
/**
|
|
3467
|
+
* Elementen die de focus niet kunnen krijgen omdat ze (of een voorouder)
|
|
3468
|
+
* verborgen of inert zijn. `[hidden]` en `[inert]` halen een element uit de
|
|
3469
|
+
* tabvolgorde, `[aria-hidden="true"]` hoort geen focusbare inhoud te bevatten.
|
|
3470
|
+
*/
|
|
3471
|
+
function isReachable(element) {
|
|
3472
|
+
return !element.closest("[hidden], [inert], [aria-hidden=\"true\"]");
|
|
3473
|
+
}
|
|
3474
|
+
/**
|
|
3475
|
+
* Alle bereikbare focusbare elementen binnen een container, in DOM-volgorde.
|
|
3476
|
+
*
|
|
3477
|
+
* De trap staat of valt met die volgorde: het eerste en het laatste element
|
|
3478
|
+
* bepalen waar Tab en Shift+Tab omslaan. Daarom lopen we alle elementen langs
|
|
3479
|
+
* en toetsen we ze met `matches`, in plaats van `querySelectorAll` met de hele
|
|
3480
|
+
* selectorlijst. Browsers geven daar documentvolgorde bij terug, maar jsdom
|
|
3481
|
+
* groepeert het resultaat per losse selector, en dan zou de trap in tests
|
|
3482
|
+
* ander gedrag vertonen dan in het echt.
|
|
3483
|
+
*/
|
|
3484
|
+
function getFocusableElements(container) {
|
|
3485
|
+
return Array.from(container.querySelectorAll("*")).filter((element) => element.matches(FOCUSABLE_SELECTOR) && isReachable(element));
|
|
3486
|
+
}
|
|
3487
|
+
/**
|
|
3488
|
+
* Houdt de toetsenbordfocus binnen `containerRef` zolang `active` waar is.
|
|
3489
|
+
*
|
|
3490
|
+
* De listener hangt aan de container zelf, niet aan het document: hij grijpt
|
|
3491
|
+
* alleen in wanneer de focus al binnen de container zit, en laat de rest van de
|
|
3492
|
+
* pagina met rust.
|
|
3493
|
+
*
|
|
3494
|
+
* Elementen met `tabindex="-1"` (zoals de heading die bij openen focus krijgt)
|
|
3495
|
+
* staan niet in de tabvolgorde. Vanaf zo'n element wrapt Shift+Tab naar het
|
|
3496
|
+
* laatste focusbare element, zodat de focus ook daar niet naar buiten valt.
|
|
3497
|
+
*/
|
|
3498
|
+
function useFocusTrap(containerRef, active) {
|
|
3499
|
+
react.default.useEffect(() => {
|
|
3500
|
+
if (!active) return;
|
|
3501
|
+
const container = containerRef.current;
|
|
3502
|
+
if (!container) return;
|
|
3503
|
+
const handleKeyDown = (event) => {
|
|
3504
|
+
if (event.key !== "Tab") return;
|
|
3505
|
+
const focusable = getFocusableElements(container);
|
|
3506
|
+
if (focusable.length === 0) {
|
|
3507
|
+
event.preventDefault();
|
|
3508
|
+
return;
|
|
3509
|
+
}
|
|
3510
|
+
const first = focusable[0];
|
|
3511
|
+
const last = focusable[focusable.length - 1];
|
|
3512
|
+
const current = document.activeElement;
|
|
3513
|
+
const isInTabOrder = current ? focusable.includes(current) : false;
|
|
3514
|
+
if (event.shiftKey) {
|
|
3515
|
+
if (current === first || !isInTabOrder) {
|
|
3516
|
+
event.preventDefault();
|
|
3517
|
+
last.focus();
|
|
3518
|
+
}
|
|
3519
|
+
} else if (current === last) {
|
|
3520
|
+
event.preventDefault();
|
|
3521
|
+
first.focus();
|
|
3522
|
+
}
|
|
3523
|
+
};
|
|
3524
|
+
container.addEventListener("keydown", handleKeyDown);
|
|
3525
|
+
return () => container.removeEventListener("keydown", handleKeyDown);
|
|
3526
|
+
}, [active, containerRef]);
|
|
3527
|
+
}
|
|
3528
|
+
//#endregion
|
|
3434
3529
|
//#region src/Drawer/Drawer.tsx
|
|
3435
|
-
const DrawerContext = react.default.createContext({
|
|
3530
|
+
const DrawerContext = react.default.createContext({
|
|
3531
|
+
headingId: "",
|
|
3532
|
+
headingRef: { current: null }
|
|
3533
|
+
});
|
|
3436
3534
|
/**
|
|
3437
3535
|
* Drawer component
|
|
3438
3536
|
* Zijpaneel dat vanuit links of rechts de viewport inschuift.
|
|
@@ -3459,22 +3557,33 @@ const DrawerContext = react.default.createContext({ headingId: "" });
|
|
|
3459
3557
|
* </Drawer>
|
|
3460
3558
|
* ```
|
|
3461
3559
|
*/
|
|
3462
|
-
const Drawer = react.default.forwardRef(({ className, isOpen, onClose, modal = true, side = "right", children, ...props }, ref) => {
|
|
3560
|
+
const Drawer = react.default.forwardRef(({ className, isOpen, onClose, modal = true, triggerRef, side = "right", children, ...props }, ref) => {
|
|
3463
3561
|
const internalRef = react.default.useRef(null);
|
|
3464
3562
|
const dialogRef = ref ?? internalRef;
|
|
3465
3563
|
const headingId = react.default.useId();
|
|
3564
|
+
const headingRef = react.default.useRef(null);
|
|
3466
3565
|
react.default.useEffect(() => {
|
|
3467
3566
|
const dialog = dialogRef.current;
|
|
3468
3567
|
if (!dialog) return;
|
|
3469
3568
|
if (isOpen && !dialog.open) {
|
|
3470
3569
|
if (modal) dialog.showModal();
|
|
3471
3570
|
else dialog.show();
|
|
3571
|
+
(headingRef.current ?? dialog).focus();
|
|
3472
3572
|
} else if (!isOpen && dialog.open) dialog.close();
|
|
3473
3573
|
}, [
|
|
3474
3574
|
isOpen,
|
|
3475
3575
|
modal,
|
|
3476
3576
|
dialogRef
|
|
3477
3577
|
]);
|
|
3578
|
+
useFocusTrap(dialogRef, isOpen && modal);
|
|
3579
|
+
react.default.useEffect(() => {
|
|
3580
|
+
const trigger = triggerRef?.current;
|
|
3581
|
+
if (!trigger) return;
|
|
3582
|
+
trigger.setAttribute("aria-expanded", isOpen ? "true" : "false");
|
|
3583
|
+
return () => {
|
|
3584
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
3585
|
+
};
|
|
3586
|
+
}, [isOpen, triggerRef]);
|
|
3478
3587
|
react.default.useEffect(() => {
|
|
3479
3588
|
if (modal) return;
|
|
3480
3589
|
const dialog = dialogRef.current;
|
|
@@ -3498,6 +3607,7 @@ const Drawer = react.default.forwardRef(({ className, isOpen, onClose, modal = t
|
|
|
3498
3607
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DrawerContext.Provider, {
|
|
3499
3608
|
value: {
|
|
3500
3609
|
headingId,
|
|
3610
|
+
headingRef,
|
|
3501
3611
|
onClose
|
|
3502
3612
|
},
|
|
3503
3613
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dialog", {
|
|
@@ -3505,6 +3615,7 @@ const Drawer = react.default.forwardRef(({ className, isOpen, onClose, modal = t
|
|
|
3505
3615
|
className: classes,
|
|
3506
3616
|
"aria-labelledby": headingId,
|
|
3507
3617
|
onCancel: handleCancel,
|
|
3618
|
+
tabIndex: -1,
|
|
3508
3619
|
...props,
|
|
3509
3620
|
children
|
|
3510
3621
|
})
|
|
@@ -3538,14 +3649,21 @@ DrawerHeader.displayName = "DrawerHeader";
|
|
|
3538
3649
|
/**
|
|
3539
3650
|
* DrawerHeading
|
|
3540
3651
|
* De heading van het zijpaneel. ID wordt automatisch gegenereerd voor aria-labelledby.
|
|
3652
|
+
* Krijgt `tabindex="-1"` zodat de Drawer er bij openen de focus op kan zetten.
|
|
3541
3653
|
*/
|
|
3542
3654
|
const DrawerHeading = react.default.forwardRef(({ className, level = 2, children, ...props }, ref) => {
|
|
3543
|
-
const { headingId } = react.default.useContext(DrawerContext);
|
|
3655
|
+
const { headingId, headingRef } = react.default.useContext(DrawerContext);
|
|
3544
3656
|
const Tag = `h${level}`;
|
|
3657
|
+
const setRef = react.default.useCallback((node) => {
|
|
3658
|
+
headingRef.current = node;
|
|
3659
|
+
if (typeof ref === "function") ref(node);
|
|
3660
|
+
else if (ref) ref.current = node;
|
|
3661
|
+
}, [headingRef, ref]);
|
|
3545
3662
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Tag, {
|
|
3546
|
-
ref,
|
|
3663
|
+
ref: setRef,
|
|
3547
3664
|
id: headingId,
|
|
3548
3665
|
className: (0, _dsn_starter_kit_core.classNames)("dsn-drawer-heading", className),
|
|
3666
|
+
tabIndex: -1,
|
|
3549
3667
|
...props,
|
|
3550
3668
|
children
|
|
3551
3669
|
});
|
|
@@ -3827,6 +3945,7 @@ const PageHeader = react.default.forwardRef(({ className, logoSlot, sticky = "no
|
|
|
3827
3945
|
isOpen: isMenuOpen,
|
|
3828
3946
|
onClose: handleMenuClose,
|
|
3829
3947
|
side: "left",
|
|
3948
|
+
triggerRef: menuButtonRef,
|
|
3830
3949
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DrawerHeader, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DrawerHeading, { children: menuButtonLabel }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DrawerBody, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Stack, {
|
|
3831
3950
|
space: "5xl",
|
|
3832
3951
|
children: [primaryNavigation && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("nav", {
|
|
@@ -4490,13 +4609,18 @@ const CardGroup = react.default.forwardRef(({ className, as: Tag = "ul", childre
|
|
|
4490
4609
|
CardGroup.displayName = "CardGroup";
|
|
4491
4610
|
//#endregion
|
|
4492
4611
|
//#region src/ModalDialog/ModalDialog.tsx
|
|
4493
|
-
const ModalDialogContext = react.default.createContext({
|
|
4612
|
+
const ModalDialogContext = react.default.createContext({
|
|
4613
|
+
headingId: "",
|
|
4614
|
+
headingRef: { current: null }
|
|
4615
|
+
});
|
|
4494
4616
|
/**
|
|
4495
4617
|
* ModalDialog component
|
|
4496
4618
|
* Modaal dialoogvenster gebaseerd op het native `<dialog>` element.
|
|
4497
4619
|
*
|
|
4498
|
-
* Gebruik altijd `.showModal()` (intern afgehandeld via `isOpen` prop)
|
|
4499
|
-
* Dit garandeert
|
|
4620
|
+
* Gebruik altijd `.showModal()` (intern afgehandeld via `isOpen` prop), nooit `.show()`.
|
|
4621
|
+
* Dit garandeert aria-modal semantiek en het inert-attribuut op de achtergrond.
|
|
4622
|
+
* De focus blijft binnen het venster via de native focus-trap plus een
|
|
4623
|
+
* expliciete trap, en komt bij openen op de heading te staan.
|
|
4500
4624
|
*
|
|
4501
4625
|
* @example
|
|
4502
4626
|
* ```tsx
|
|
@@ -4516,16 +4640,28 @@ const ModalDialogContext = react.default.createContext({ headingId: "" });
|
|
|
4516
4640
|
* </ModalDialog>
|
|
4517
4641
|
* ```
|
|
4518
4642
|
*/
|
|
4519
|
-
const ModalDialog = react.default.forwardRef(({ className, isOpen, onClose, children, ...props }, ref) => {
|
|
4643
|
+
const ModalDialog = react.default.forwardRef(({ className, isOpen, onClose, triggerRef, children, ...props }, ref) => {
|
|
4520
4644
|
const internalRef = react.default.useRef(null);
|
|
4521
4645
|
const dialogRef = ref ?? internalRef;
|
|
4522
4646
|
const headingId = react.default.useId();
|
|
4647
|
+
const headingRef = react.default.useRef(null);
|
|
4523
4648
|
react.default.useEffect(() => {
|
|
4524
4649
|
const dialog = dialogRef.current;
|
|
4525
4650
|
if (!dialog) return;
|
|
4526
|
-
if (isOpen && !dialog.open)
|
|
4527
|
-
|
|
4651
|
+
if (isOpen && !dialog.open) {
|
|
4652
|
+
dialog.showModal();
|
|
4653
|
+
(headingRef.current ?? dialog).focus();
|
|
4654
|
+
} else if (!isOpen && dialog.open) dialog.close();
|
|
4528
4655
|
}, [isOpen, dialogRef]);
|
|
4656
|
+
useFocusTrap(dialogRef, isOpen);
|
|
4657
|
+
react.default.useEffect(() => {
|
|
4658
|
+
const trigger = triggerRef?.current;
|
|
4659
|
+
if (!trigger) return;
|
|
4660
|
+
trigger.setAttribute("aria-expanded", isOpen ? "true" : "false");
|
|
4661
|
+
return () => {
|
|
4662
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
4663
|
+
};
|
|
4664
|
+
}, [isOpen, triggerRef]);
|
|
4529
4665
|
const handleCancel = (event) => {
|
|
4530
4666
|
event.preventDefault();
|
|
4531
4667
|
onClose?.();
|
|
@@ -4534,6 +4670,7 @@ const ModalDialog = react.default.forwardRef(({ className, isOpen, onClose, chil
|
|
|
4534
4670
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModalDialogContext.Provider, {
|
|
4535
4671
|
value: {
|
|
4536
4672
|
headingId,
|
|
4673
|
+
headingRef,
|
|
4537
4674
|
onClose
|
|
4538
4675
|
},
|
|
4539
4676
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dialog", {
|
|
@@ -4541,6 +4678,7 @@ const ModalDialog = react.default.forwardRef(({ className, isOpen, onClose, chil
|
|
|
4541
4678
|
className: classes,
|
|
4542
4679
|
"aria-labelledby": headingId,
|
|
4543
4680
|
onCancel: handleCancel,
|
|
4681
|
+
tabIndex: -1,
|
|
4544
4682
|
...props,
|
|
4545
4683
|
children
|
|
4546
4684
|
})
|
|
@@ -4574,14 +4712,21 @@ ModalDialogHeader.displayName = "ModalDialogHeader";
|
|
|
4574
4712
|
/**
|
|
4575
4713
|
* ModalDialogHeading
|
|
4576
4714
|
* De heading van het dialoogvenster. ID wordt automatisch gegenereerd voor aria-labelledby.
|
|
4715
|
+
* Krijgt `tabindex="-1"` zodat het ModalDialog er bij openen de focus op kan zetten.
|
|
4577
4716
|
*/
|
|
4578
4717
|
const ModalDialogHeading = react.default.forwardRef(({ className, level = 2, children, ...props }, ref) => {
|
|
4579
|
-
const { headingId } = react.default.useContext(ModalDialogContext);
|
|
4718
|
+
const { headingId, headingRef } = react.default.useContext(ModalDialogContext);
|
|
4580
4719
|
const Tag = `h${level}`;
|
|
4720
|
+
const setRef = react.default.useCallback((node) => {
|
|
4721
|
+
headingRef.current = node;
|
|
4722
|
+
if (typeof ref === "function") ref(node);
|
|
4723
|
+
else if (ref) ref.current = node;
|
|
4724
|
+
}, [headingRef, ref]);
|
|
4581
4725
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Tag, {
|
|
4582
|
-
ref,
|
|
4726
|
+
ref: setRef,
|
|
4583
4727
|
id: headingId,
|
|
4584
4728
|
className: (0, _dsn_starter_kit_core.classNames)("dsn-modal-dialog-heading", className),
|
|
4729
|
+
tabIndex: -1,
|
|
4585
4730
|
...props,
|
|
4586
4731
|
children
|
|
4587
4732
|
});
|