@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.
@@ -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 '../TextInput/TextInput.css';\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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuDA,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
+ {"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":";;UAOiB,uBAAuB,KACtC,MAAM,oBAAoB;;;;;EAO1B;;;;EAKA;;;;;;;;;;;;;;;;;;;;;;;cAwBW,WAAS,MAAA,0BAAA,iBAAA,MAAA,cAAA"}
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,6 +1,5 @@
1
1
  import { Icon } from "../Icon/Icon2.mjs";
2
2
  import { Button } from "../Button/Button2.mjs";
3
- /* empty css */
4
3
  /* empty css */
5
4
  import React from "react";
6
5
  import { classNames } from "@dsn-starter-kit/core";
@@ -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 '../TextInput/TextInput.css';\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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4CA,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"}
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({ headingId: "" });
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({ headingId: "" });
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) — nooit `.show()`.
4499
- * Dit garandeert natieve focus-trap, aria-modal semantiek en inert-attribuut op de achtergrond.
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) dialog.showModal();
4527
- else if (!isOpen && dialog.open) dialog.close();
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
  });