@nldd/design-system 0.8.87 → 0.8.89

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.
Files changed (128) hide show
  1. package/README.md +2 -2
  2. package/custom-elements.json +305 -66
  3. package/dist/components/components.js +1007 -218
  4. package/dist/components/content/image/image.d.ts +23 -3
  5. package/dist/components/content/image/image.d.ts.map +1 -1
  6. package/dist/components/content/image/image.js +74 -17
  7. package/dist/components/content/image/image.js.map +1 -1
  8. package/dist/components/content/image/image.template.js +1 -1
  9. package/dist/components/content/image/image.template.js.map +1 -1
  10. package/dist/components/{dist-LX8LuMac.js → dist-BLaE_n_F.js} +1 -1
  11. package/dist/components/{dist-CX3DK4i7.js → dist-BeymuiGt.js} +3 -3
  12. package/dist/components/{dist-CbCO0WCd.js → dist-CENYEz7C.js} +1 -1
  13. package/dist/components/{dist-B_Mnmrj4.js → dist-CYLM5ciE.js} +1 -1
  14. package/dist/components/{dist-fIxe0l7k.js → dist-DHA-0RyT.js} +91 -34
  15. package/dist/components/{dist-CgDuh26K.js → dist-D_ckX1sK.js} +1 -1
  16. package/dist/components/{dist-eVFegyjM.js → dist-Dh_b9r4M.js} +1 -1
  17. package/dist/components/{dist-DXZ-cFAl.js → dist-DkZlotgT.js} +2 -2
  18. package/dist/components/{dist-CSrO_mYe.js → dist-TCqV9dho.js} +1 -1
  19. package/dist/components/{dist-ByH8hQze.js → dist-Y54Lma86.js} +2 -2
  20. package/dist/components/{dist-CRwuSgMM.js → dist-yyE8P2O6.js} +2 -2
  21. package/dist/components/inputs/radio-button/radio-button.d.ts +58 -18
  22. package/dist/components/inputs/radio-button/radio-button.d.ts.map +1 -1
  23. package/dist/components/inputs/radio-button/radio-button.js +136 -29
  24. package/dist/components/inputs/radio-button/radio-button.js.map +1 -1
  25. package/dist/components/inputs/radio-button/radio-button.styles.d.ts.map +1 -1
  26. package/dist/components/inputs/radio-button/radio-button.styles.js +17 -23
  27. package/dist/components/inputs/radio-button/radio-button.styles.js.map +1 -1
  28. package/dist/components/inputs/radio-button/radio-button.template.d.ts.map +1 -1
  29. package/dist/components/inputs/radio-button/radio-button.template.js +13 -10
  30. package/dist/components/inputs/radio-button/radio-button.template.js.map +1 -1
  31. package/dist/components/inputs/radio-button-field/radio-button-field.d.ts +65 -18
  32. package/dist/components/inputs/radio-button-field/radio-button-field.d.ts.map +1 -1
  33. package/dist/components/inputs/radio-button-field/radio-button-field.js +151 -29
  34. package/dist/components/inputs/radio-button-field/radio-button-field.js.map +1 -1
  35. package/dist/components/inputs/radio-button-field/radio-button-field.styles.d.ts.map +1 -1
  36. package/dist/components/inputs/radio-button-field/radio-button-field.styles.js +17 -0
  37. package/dist/components/inputs/radio-button-field/radio-button-field.styles.js.map +1 -1
  38. package/dist/components/inputs/radio-button-field/radio-button-field.template.d.ts.map +1 -1
  39. package/dist/components/inputs/radio-button-field/radio-button-field.template.js +21 -12
  40. package/dist/components/inputs/radio-button-field/radio-button-field.template.js.map +1 -1
  41. package/dist/components/inputs/radio-button-group/radio-button-group.d.ts +3 -0
  42. package/dist/components/inputs/radio-button-group/radio-button-group.d.ts.map +1 -1
  43. package/dist/components/inputs/radio-button-group/radio-button-group.js +16 -4
  44. package/dist/components/inputs/radio-button-group/radio-button-group.js.map +1 -1
  45. package/dist/components/inputs/segmented-control/segmented-control.d.ts +46 -13
  46. package/dist/components/inputs/segmented-control/segmented-control.d.ts.map +1 -1
  47. package/dist/components/inputs/segmented-control/segmented-control.js +145 -16
  48. package/dist/components/inputs/segmented-control/segmented-control.js.map +1 -1
  49. package/dist/components/inputs/segmented-control/segmented-control.styles.d.ts.map +1 -1
  50. package/dist/components/inputs/segmented-control/segmented-control.styles.js +16 -0
  51. package/dist/components/inputs/segmented-control/segmented-control.styles.js.map +1 -1
  52. package/dist/components/inputs/segmented-control/segmented-control.template.d.ts.map +1 -1
  53. package/dist/components/inputs/segmented-control/segmented-control.template.js +47 -24
  54. package/dist/components/inputs/segmented-control/segmented-control.template.js.map +1 -1
  55. package/dist/components/inputs/text-editor/text-editor.cursor.d.ts.map +1 -1
  56. package/dist/components/inputs/text-editor/text-editor.cursor.js +14 -1
  57. package/dist/components/inputs/text-editor/text-editor.cursor.js.map +1 -1
  58. package/dist/components/inputs/text-editor/text-editor.mentions.d.ts.map +1 -1
  59. package/dist/components/inputs/text-editor/text-editor.mentions.js +3 -1
  60. package/dist/components/inputs/text-editor/text-editor.mentions.js.map +1 -1
  61. package/dist/components/inputs/toggle-button/toggle-button.d.ts +36 -2
  62. package/dist/components/inputs/toggle-button/toggle-button.d.ts.map +1 -1
  63. package/dist/components/inputs/toggle-button/toggle-button.js +116 -3
  64. package/dist/components/inputs/toggle-button/toggle-button.js.map +1 -1
  65. package/dist/components/inputs/toggle-button/toggle-button.styles.d.ts.map +1 -1
  66. package/dist/components/inputs/toggle-button/toggle-button.styles.js +17 -1
  67. package/dist/components/inputs/toggle-button/toggle-button.styles.js.map +1 -1
  68. package/dist/components/inputs/toggle-button/toggle-button.template.d.ts.map +1 -1
  69. package/dist/components/inputs/toggle-button/toggle-button.template.js +25 -2
  70. package/dist/components/inputs/toggle-button/toggle-button.template.js.map +1 -1
  71. package/dist/components/inputs/toggle-button-group/toggle-button-group.d.ts +4 -0
  72. package/dist/components/inputs/toggle-button-group/toggle-button-group.d.ts.map +1 -1
  73. package/dist/components/inputs/toggle-button-group/toggle-button-group.js +21 -2
  74. package/dist/components/inputs/toggle-button-group/toggle-button-group.js.map +1 -1
  75. package/dist/components/layout/page/page.d.ts +23 -5
  76. package/dist/components/layout/page/page.d.ts.map +1 -1
  77. package/dist/components/layout/page/page.js +39 -34
  78. package/dist/components/layout/page/page.js.map +1 -1
  79. package/dist/components/layout/page/page.styles.d.ts.map +1 -1
  80. package/dist/components/layout/page/page.styles.js +2 -0
  81. package/dist/components/layout/page/page.styles.js.map +1 -1
  82. package/dist/components/layout/sheet/sheet.template.d.ts.map +1 -1
  83. package/dist/components/layout/sheet/sheet.template.js +3 -0
  84. package/dist/components/layout/sheet/sheet.template.js.map +1 -1
  85. package/dist/components/layout/window/window.template.d.ts.map +1 -1
  86. package/dist/components/layout/window/window.template.js +3 -0
  87. package/dist/components/layout/window/window.template.js.map +1 -1
  88. package/dist/components/lists-and-tables/list/list.d.ts +1 -1
  89. package/dist/components/lists-and-tables/list/list.d.ts.map +1 -1
  90. package/dist/components/lists-and-tables/list/list.js +4 -3
  91. package/dist/components/lists-and-tables/list/list.js.map +1 -1
  92. package/dist/components/lists-and-tables/list/list.template.d.ts.map +1 -1
  93. package/dist/components/lists-and-tables/list/list.template.js +15 -10
  94. package/dist/components/lists-and-tables/list/list.template.js.map +1 -1
  95. package/dist/components/navigation/menu-bar/menu-bar.d.ts +4 -2
  96. package/dist/components/navigation/menu-bar/menu-bar.d.ts.map +1 -1
  97. package/dist/components/navigation/menu-bar/menu-bar.js +4 -2
  98. package/dist/components/navigation/menu-bar/menu-bar.js.map +1 -1
  99. package/dist/components/status-and-feedback/modal-dialog/modal-dialog.template.d.ts.map +1 -1
  100. package/dist/components/status-and-feedback/modal-dialog/modal-dialog.template.js +3 -0
  101. package/dist/components/status-and-feedback/modal-dialog/modal-dialog.template.js.map +1 -1
  102. package/dist/components/status-and-feedback/notification/notification-region.d.ts +4 -0
  103. package/dist/components/status-and-feedback/notification/notification-region.d.ts.map +1 -1
  104. package/dist/components/status-and-feedback/notification/notification-region.js +161 -19
  105. package/dist/components/status-and-feedback/notification/notification-region.js.map +1 -1
  106. package/dist/components/status-and-feedback/notification/notification.d.ts +17 -0
  107. package/dist/components/status-and-feedback/notification/notification.d.ts.map +1 -1
  108. package/dist/components/status-and-feedback/notification/notification.js +33 -0
  109. package/dist/components/status-and-feedback/notification/notification.js.map +1 -1
  110. package/dist/components/status-and-feedback/notification/notification.styles.d.ts.map +1 -1
  111. package/dist/components/status-and-feedback/notification/notification.styles.js +6 -0
  112. package/dist/components/status-and-feedback/notification/notification.styles.js.map +1 -1
  113. package/dist/css/variables.css +2 -2
  114. package/dist/nldd.min.js +629 -540
  115. package/dist/types/vue.d.ts +24 -20
  116. package/dist/utilities/described-by-mixin.d.ts +2 -2
  117. package/dist/utilities/described-by-mixin.d.ts.map +1 -1
  118. package/dist/utilities/described-by-mixin.js +11 -6
  119. package/dist/utilities/described-by-mixin.js.map +1 -1
  120. package/dist/utilities/radio-grouping.d.ts +66 -0
  121. package/dist/utilities/radio-grouping.d.ts.map +1 -0
  122. package/dist/utilities/radio-grouping.js +133 -0
  123. package/dist/utilities/radio-grouping.js.map +1 -0
  124. package/dist/utilities/radio-position.d.ts +20 -0
  125. package/dist/utilities/radio-position.d.ts.map +1 -0
  126. package/dist/utilities/radio-position.js +11 -0
  127. package/dist/utilities/radio-position.js.map +1 -0
  128. package/package.json +10 -10
@@ -8,9 +8,9 @@ import { t as NLDDIcon } from "./icon-C_zwJZ_g.js";
8
8
  import { n as isPointerMode, r as isTouchMode, t as isKeyboardMode } from "./input-modality-DhTeUxKi.js";
9
9
  import { t as breakpoints } from "./breakpoints-CkxsiRgH.js";
10
10
  import { t as VisibilityMixin } from "./visibility-mixin-BU1vIMW7.js";
11
- import { $ as layer, A as syntaxTree, B as Tree, C as indentNodeProp, Ct as findClusterBreak, D as matchBrackets, E as languageDataProp, F as NodeProp, G as RectangleMarker, H as Decoration, I as NodeSet, J as drawSelection, K as ViewPlugin, L as NodeType, M as styleTags, N as tags, Q as keymap, S as getIndentation, St as countColumn, T as indentUnit, U as Direction, V as parseMixed, W as EditorView, X as getDrawSelectionConfig, Y as dropCursor, _t as Text, a as HighlightStyle, at as ChangeDesc, b as foldService, c as Language, ct as EditorSelection, d as ParseContext, et as lineNumbers, f as StreamLanguage, ft as Prec, gt as StateField, h as defineLanguageFacet, ht as StateEffect, it as Annotation, j as Tag, k as syntaxHighlighting, l as LanguageDescription, lt as EditorState, nt as placeholder, o as IndentContext, ot as ChangeSet, pt as RangeSetBuilder, q as WidgetType, st as Compartment, u as LanguageSupport, ut as Facet, vt as Transaction, w as indentString, x as getIndentUnit, xt as combineConfig, y as foldNodeProp, z as Parser } from "./dist-fIxe0l7k.js";
12
- import { i as completionStatus, n as autocompletion, s as startCompletion, t as CompletionContext } from "./dist-CgDuh26K.js";
13
- import { n as html$2, r as htmlCompletionSource } from "./dist-CX3DK4i7.js";
11
+ import { $ as layer, A as syntaxTree, B as Tree, C as indentNodeProp, Ct as findClusterBreak, D as matchBrackets, E as languageDataProp, F as NodeProp, G as RectangleMarker, H as Decoration, I as NodeSet, J as drawSelection, K as ViewPlugin, L as NodeType, M as styleTags, N as tags, Q as keymap, S as getIndentation, St as countColumn, T as indentUnit, U as Direction, V as parseMixed, W as EditorView, X as getDrawSelectionConfig, Y as dropCursor, _t as Text, a as HighlightStyle, at as ChangeDesc, b as foldService, c as Language, ct as EditorSelection, d as ParseContext, et as lineNumbers, f as StreamLanguage, ft as Prec, gt as StateField, h as defineLanguageFacet, ht as StateEffect, it as Annotation, j as Tag, k as syntaxHighlighting, l as LanguageDescription, lt as EditorState, nt as placeholder, o as IndentContext, ot as ChangeSet, pt as RangeSetBuilder, q as WidgetType, st as Compartment, u as LanguageSupport, ut as Facet, vt as Transaction, w as indentString, x as getIndentUnit, xt as combineConfig, y as foldNodeProp, z as Parser } from "./dist-DHA-0RyT.js";
12
+ import { i as completionStatus, n as autocompletion, s as startCompletion, t as CompletionContext } from "./dist-D_ckX1sK.js";
13
+ import { n as html$2, r as htmlCompletionSource } from "./dist-BeymuiGt.js";
14
14
  import { LitElement, css, html, nothing, svg, unsafeCSS } from "lit";
15
15
  import { customElement, property, query, state } from "lit/decorators.js";
16
16
  import { unsafeHTML } from "lit/directives/unsafe-html.js";
@@ -753,9 +753,9 @@ var nlddButtonTranslations = { "components.button.opens-in-new-tab-label": "Open
753
753
  * `ariaDescribedByElements` takes the elements themselves and crosses the
754
754
  * boundary.
755
755
  */
756
- function applyDescribedBy(target, elements) {
756
+ function applyDescribedBy(target, elements, onSelf = false) {
757
757
  if (!target) return;
758
- if ("describedByElements" in target) {
758
+ if (!onSelf && "describedByElements" in target) {
759
759
  target.describedByElements = [...elements];
760
760
  return;
761
761
  }
@@ -774,9 +774,9 @@ function applyDescribedBy(target, elements) {
774
774
  * A component that wraps another one hands the state on, the same way it hands
775
775
  * on the description.
776
776
  */
777
- function applyInvalid(target, invalid) {
777
+ function applyInvalid(target, invalid, onSelf = false) {
778
778
  if (!target) return;
779
- if ("invalid" in target) {
779
+ if (!onSelf && "invalid" in target) {
780
780
  target.invalid = invalid;
781
781
  return;
782
782
  }
@@ -804,9 +804,10 @@ function DescribedBy(Base) {
804
804
  this.describedByElements = [];
805
805
  this.addController({ hostUpdated: () => {
806
806
  const target = this.describedTarget();
807
- applyDescribedBy(target, this.describedByElements);
807
+ const onSelf = target === this;
808
+ applyDescribedBy(target, this.describedByElements, onSelf);
808
809
  const invalid = this.invalid;
809
- if (invalid !== void 0) applyInvalid(target, invalid);
810
+ if (invalid !== void 0) applyInvalid(target, invalid, onSelf);
810
811
  } });
811
812
  }
812
813
  /**
@@ -8641,11 +8642,12 @@ var template$14 = ({ itemsLabel, hasToolbar, type, isEmpty, hasItems, hasEmptySt
8641
8642
  const skipItemsLabel = isNavigation || isListbox;
8642
8643
  const itemsRole = isListbox ? "listbox" : type === "tree" ? "tree" : type === "radiogroup" ? "radiogroup" : "list";
8643
8644
  const isNoResults = isEmpty && hasItems;
8645
+ const untouched = isListbox && listbox.searchValue === "";
8644
8646
  const noResultsFallsBack = isNoResults && !hasNoResultsState && hasEmptyState;
8645
- const showNoResults = isNoResults && hasNoResultsState && !(isListbox && listbox.searchValue === "");
8646
- const showEmpty = !isNoResults && isEmpty && hasEmptyState || noResultsFallsBack && !(isListbox && listbox.searchValue === "");
8647
+ const showNoResults = isNoResults && hasNoResultsState && !untouched;
8648
+ const showEmpty = (!isNoResults && isEmpty && hasEmptyState || noResultsFallsBack) && !untouched;
8647
8649
  const showMain = !isEmpty || showEmpty || showNoResults;
8648
- const showControls = hasItems;
8650
+ const showControls = hasItems || isListbox;
8649
8651
  return html`
8650
8652
  <div class="list">
8651
8653
  <div class="list__header">
@@ -9128,7 +9130,7 @@ var NLDDList = (_NLDDList2 = class NLDDList extends LitElement {
9128
9130
  this._hasItems = items.length > 0;
9129
9131
  this._hasEmptyState = !!this.querySelector(":scope > [slot=\"empty\"]");
9130
9132
  this._hasNoResultsState = !!this.querySelector(":scope > [slot=\"no-results\"]");
9131
- this.classList.toggle("is-blank", this._isEmpty && !this._hasItems && !this._hasEmptyState);
9133
+ this.classList.toggle("is-blank", this._isEmpty && !this._hasItems && !this._hasEmptyState && this.type !== "listbox");
9132
9134
  this._warnSilentEmpty();
9133
9135
  }
9134
9136
  /**
@@ -11580,16 +11582,16 @@ var nlddCodeMirrorTheme = [baseTheme, syntaxHighlighting(highlightStyle)];
11580
11582
  //#endregion
11581
11583
  //#region src/utilities/codemirror/languages.ts
11582
11584
  var loaders = {
11583
- json: async () => (await import("./dist-LX8LuMac.js")).json(),
11584
- yaml: async () => (await import("./dist-CSrO_mYe.js")).yaml(),
11585
- javascript: async () => (await import("./dist-ByH8hQze.js").then((n) => n.t)).javascript(),
11586
- typescript: async () => (await import("./dist-ByH8hQze.js").then((n) => n.t)).javascript({ typescript: true }),
11587
- css: async () => (await import("./dist-B_Mnmrj4.js").then((n) => n.r)).css(),
11588
- html: async () => (await import("./dist-CX3DK4i7.js").then((n) => n.t)).html(),
11589
- xml: async () => (await import("./dist-eVFegyjM.js")).xml(),
11590
- python: async () => (await import("./dist-CRwuSgMM.js")).python(),
11591
- rust: async () => (await import("./dist-CbCO0WCd.js")).rust(),
11592
- sql: async () => (await import("./dist-DXZ-cFAl.js")).sql(),
11585
+ json: async () => (await import("./dist-BLaE_n_F.js")).json(),
11586
+ yaml: async () => (await import("./dist-TCqV9dho.js")).yaml(),
11587
+ javascript: async () => (await import("./dist-Y54Lma86.js").then((n) => n.t)).javascript(),
11588
+ typescript: async () => (await import("./dist-Y54Lma86.js").then((n) => n.t)).javascript({ typescript: true }),
11589
+ css: async () => (await import("./dist-CYLM5ciE.js").then((n) => n.r)).css(),
11590
+ html: async () => (await import("./dist-BeymuiGt.js").then((n) => n.t)).html(),
11591
+ xml: async () => (await import("./dist-Dh_b9r4M.js")).xml(),
11592
+ python: async () => (await import("./dist-yyE8P2O6.js")).python(),
11593
+ rust: async () => (await import("./dist-CENYEz7C.js")).rust(),
11594
+ sql: async () => (await import("./dist-DkZlotgT.js")).sql(),
11593
11595
  markdown: async () => (await Promise.resolve().then(() => dist_exports)).markdown(),
11594
11596
  bash: async () => new LanguageSupport(StreamLanguage.define((await import("./shell-C61GCsJA.js")).shell)),
11595
11597
  toml: async () => new LanguageSupport(StreamLanguage.define((await import("./toml-C2HBbPgG.js")).toml)),
@@ -12575,7 +12577,7 @@ function imageTemplate(component) {
12575
12577
  <div class=${mediaClasses}
12576
12578
  style=${styleMap(mediaStyles)}
12577
12579
  >
12578
- <slot>${fallbackImg}</slot>
12580
+ <slot @slotchange=${component._onMediaSlotChange}>${fallbackImg}</slot>
12579
12581
  ${errorOverlay}
12580
12582
  ${liveRegion}
12581
12583
  </div>
@@ -12616,7 +12618,7 @@ var nlddImageTranslations = {
12616
12618
  * @element nldd-image
12617
12619
  *
12618
12620
  * @attr {string} src - Image URL
12619
- * @attr {string} alt - Alt text. Required unless `decorative`.
12621
+ * @attr {string} alt - Alt text for the image from `src`. Required unless `decorative`; slotted media carries its own.
12620
12622
  * @attr {string} srcset - Responsive source set
12621
12623
  * @attr {string} sizes - Source sizes hint
12622
12624
  * @attr {number|'full'} width - Display width. `full` (default) fills the parent.
@@ -12653,7 +12655,7 @@ var nlddImageTranslations = {
12653
12655
  * @attr {object} translations - Override translation keys (e.g. the message
12654
12656
  * shown when the image fails to load); unset keys fall back to Dutch.
12655
12657
  *
12656
- * @slot - Custom `<img>`, `<picture>` or inline `<svg>` (overrides the src-based default). An inline svg keeps its own colors and scales by its viewBox, so a drawing gets the same box, ratio and caption as a photo.
12658
+ * @slot - Custom `<img>`, `<picture>` or inline `<svg>` (overrides the src-based default). An inline svg keeps its own colors and scales by its viewBox, so a drawing gets the same box, ratio and caption as a photo. Slotted media carries its own text alternative: an `alt` on the img (empty when it conveys nothing), or `role="img"` with an `aria-label`, `aria-labelledby` or `<title>` on the svg.
12657
12659
  * The internal `error` listener is attached only to the built-in `<img>`, so
12658
12660
  * slotted content does not trigger the error-state overlay automatically.
12659
12661
  * Consumers slotting their own image are responsible for handling its
@@ -12686,6 +12688,9 @@ var NLDDImage = (_NLDDImage = class NLDDImage extends LitElement {
12686
12688
  this._imageErrored = false;
12687
12689
  this._warnedWidth = false;
12688
12690
  this._warnedAlt = false;
12691
+ this._onMediaSlotChange = () => {
12692
+ this._warnMissingAlt();
12693
+ };
12689
12694
  this._onImageLoad = () => {
12690
12695
  this._imageLoaded = true;
12691
12696
  this._imageErrored = false;
@@ -12762,9 +12767,31 @@ var NLDDImage = (_NLDDImage = class NLDDImage extends LitElement {
12762
12767
  if (n !== void 0) this.style.setProperty("--_max-width", `${n}px`);
12763
12768
  else this.style.removeProperty("--_max-width");
12764
12769
  }
12770
+ this._warnMissingAlt();
12765
12771
  this.toggleAttribute("loaded", this._imageLoaded);
12766
12772
  this.toggleAttribute("errored", this._imageErrored);
12767
12773
  }
12774
+ /**
12775
+ * Says in DEV when an image has no text alternative. A non-decorative image
12776
+ * without one is a silent a11y failure (WCAG H37), so this surfaces it
12777
+ * during build and Storybook and stays quiet in production.
12778
+ *
12779
+ * Slotted media replaces the built-in `<img>` and carries its own
12780
+ * alternative, so once something is slotted, that is what gets judged.
12781
+ * With neither slotted media nor a `src` or `srcset` there is no image yet:
12782
+ * a source or media that arrives later is judged when it does. The latch
12783
+ * resets once the problem is solved, so bringing it back warns again.
12784
+ */
12785
+ _warnMissingAlt() {}
12786
+ /**
12787
+ * What the consumer put in the default slot, followed through a forwarded
12788
+ * slot. The top level is deliberately not flattened: with nothing assigned,
12789
+ * flattening returns the built-in fallback `<img>`, which always has an
12790
+ * `alt` attribute and would pass every time.
12791
+ */
12792
+ _slottedMedia() {
12793
+ return ((this.shadowRoot?.querySelector("slot:not([name])"))?.assignedElements() ?? []).flatMap((el) => el instanceof HTMLSlotElement ? el.assignedElements({ flatten: true }) : [el]);
12794
+ }
12768
12795
  firstUpdated() {
12769
12796
  const img = this.shadowRoot?.querySelector(".image__img");
12770
12797
  if (img?.complete && img.naturalWidth > 0) this._imageLoaded = true;
@@ -16806,11 +16833,11 @@ var cursorLineBoundaryRight = (view) => moveSel(view, (range) => moveByLineBound
16806
16833
  /**
16807
16834
  Move the selection to the start of the line.
16808
16835
  */
16809
- var cursorLineStart = (view) => moveSel(view, (range) => EditorSelection.cursor(view.lineBlockAt(range.head).from, 1));
16836
+ var cursorLineStart = (view) => moveSel(view, (range) => view.moveToLineBoundary(range, false, false));
16810
16837
  /**
16811
16838
  Move the selection to the end of the line.
16812
16839
  */
16813
- var cursorLineEnd = (view) => moveSel(view, (range) => EditorSelection.cursor(view.lineBlockAt(range.head).to, -1));
16840
+ var cursorLineEnd = (view) => moveSel(view, (range) => view.moveToLineBoundary(range, true, false));
16814
16841
  function toMatchingBracket(state, dispatch, extend) {
16815
16842
  let found = false, selection = updateSel(state.selection, (range) => {
16816
16843
  let matching = matchBrackets(state, range.head, -1) || matchBrackets(state, range.head, 1) || range.head > 0 && matchBrackets(state, range.head - 1, 1) || range.head < state.doc.length && matchBrackets(state, range.head + 1, -1);
@@ -23821,12 +23848,12 @@ var radioButtonStyles = css`
23821
23848
 
23822
23849
  /* # Elements */
23823
23850
 
23824
- .radio-button__input {
23851
+ .radio-button__validation-input {
23825
23852
  position: absolute;
23826
23853
  inset: 0;
23827
23854
  opacity: 0;
23828
- z-index: 1;
23829
23855
  margin: 0;
23856
+ pointer-events: none;
23830
23857
  width: 100%;
23831
23858
  height: 100%;
23832
23859
  }
@@ -23853,61 +23880,55 @@ var radioButtonStyles = css`
23853
23880
  transform: translate(-50%, -50%) scale(0);
23854
23881
  }
23855
23882
 
23856
- .radio-button__input:checked ~ .radio-button__outer-shape {
23883
+ :host([checked]) .radio-button__outer-shape {
23857
23884
  border-color: var(--components-radio-button-is-selected-border-color);
23858
23885
  background-color: var(--components-radio-button-is-selected-background-color);
23859
23886
  }
23860
23887
 
23861
- .radio-button__input:checked ~ .radio-button__outer-shape .radio-button__inner-shape {
23862
- transform: translate(-50%, -50%) scale(1);
23863
- }
23864
-
23865
- /* Decorative has no input to hang :checked on, so the state comes from the
23866
- host attribute instead. */
23867
- :host([decorative][checked]) .radio-button__outer-shape {
23868
- border-color: var(--components-radio-button-is-selected-border-color);
23869
- background-color: var(--components-radio-button-is-selected-background-color);
23870
- }
23871
-
23872
- :host([decorative][checked]) .radio-button__inner-shape {
23888
+ :host([checked]) .radio-button__inner-shape {
23873
23889
  transform: translate(-50%, -50%) scale(1);
23874
23890
  }
23875
23891
 
23876
23892
  @media (hover: hover) {
23877
- .radio-button__input:hover:not(:disabled) ~ .radio-button__outer-shape {
23893
+ :host(:hover:not([disabled])) .radio-button__outer-shape {
23878
23894
  border-color: var(--components-radio-button-is-hovered-border-color);
23879
23895
  }
23880
23896
 
23881
- .radio-button__input:checked:hover:not(:disabled) ~ .radio-button__outer-shape {
23897
+ :host([checked]:hover:not([disabled])) .radio-button__outer-shape {
23882
23898
  border-color: var(--components-radio-button-is-selected-is-hovered-border-color);
23883
23899
  background-color: var(--components-radio-button-is-selected-is-hovered-background-color);
23884
23900
  }
23885
23901
 
23886
- .radio-button__input:checked:hover:not(:disabled) ~ .radio-button__outer-shape .radio-button__inner-shape {
23902
+ :host([checked]:hover:not([disabled])) .radio-button__inner-shape {
23887
23903
  border-color: var(--components-radio-button-is-selected-is-hovered-inner-shape-border-color);
23888
23904
  }
23889
23905
  }
23890
23906
 
23891
- .radio-button__input:active:not(:disabled) ~ .radio-button__outer-shape {
23907
+ :host(:active:not([disabled])) .radio-button__outer-shape {
23892
23908
  border-color: var(--components-radio-button-is-active-border-color);
23893
23909
  }
23894
23910
 
23895
- .radio-button__input:checked:active:not(:disabled) ~ .radio-button__outer-shape {
23911
+ :host([checked]:active:not([disabled])) .radio-button__outer-shape {
23896
23912
  border-color: var(--components-radio-button-is-selected-is-active-border-color);
23897
23913
  background-color: var(--components-radio-button-is-selected-is-active-background-color);
23898
23914
  }
23899
23915
 
23900
- .radio-button__input:checked:active:not(:disabled) ~ .radio-button__outer-shape .radio-button__inner-shape {
23916
+ :host([checked]:active:not([disabled])) .radio-button__inner-shape {
23901
23917
  border-color: var(--components-radio-button-is-selected-is-active-inner-shape-border-color);
23902
23918
  }
23903
23919
 
23904
- .radio-button__input:focus-visible ~ .radio-button__outer-shape {
23920
+ :host(:focus-visible) {
23921
+ outline: none;
23922
+ }
23923
+
23924
+ :host(:focus-visible) .radio-button__outer-shape,
23925
+ :host([focus-ring]) .radio-button__outer-shape {
23905
23926
  outline: var(--semantics-focus-ring-outline);
23906
23927
  outline-offset: var(--semantics-focus-ring-outline-offset);
23907
23928
  box-shadow: var(--semantics-focus-ring-box-shadow);
23908
23929
  }
23909
23930
 
23910
- .radio-button__input:disabled ~ .radio-button__outer-shape {
23931
+ :host([disabled]) .radio-button__outer-shape {
23911
23932
  opacity: var(--primitives-opacity-disabled);
23912
23933
  }
23913
23934
  `;
@@ -23920,16 +23941,14 @@ function radioButtonTemplate(component) {
23920
23941
  </div>
23921
23942
  `;
23922
23943
  return html`
23923
- <input class="radio-button__input"
23944
+ <input class="radio-button__validation-input"
23924
23945
  type="radio"
23925
- .checked=${component.checked}
23926
- ?disabled=${component.disabled}
23946
+ name="nldd-validation"
23927
23947
  ?required=${component.required}
23928
- name=${component.name || ""}
23929
- value=${component.value}
23930
- aria-label=${component.accessibleLabel || nothing}
23931
- tabindex=${component.noTab ? "-1" : nothing}
23932
- @change=${component._handleChange}
23948
+ ?disabled=${component.disabled}
23949
+ .checked=${component._groupHasSelection}
23950
+ tabindex="-1"
23951
+ aria-hidden="true"
23933
23952
  >
23934
23953
  <div class="radio-button__outer-shape"
23935
23954
  aria-hidden="true"
@@ -23939,32 +23958,167 @@ function radioButtonTemplate(component) {
23939
23958
  `;
23940
23959
  }
23941
23960
  //#endregion
23961
+ //#region src/utilities/radio-position.ts
23962
+ /** A position for every radio of a group, in the order given. */
23963
+ function radioPositions(radios, isChecked, isDisabled) {
23964
+ const enabled = radios.filter((radio) => !isDisabled(radio));
23965
+ const stop = enabled.find(isChecked) ?? enabled[0];
23966
+ return radios.map((radio, index) => ({
23967
+ posInSet: index + 1,
23968
+ setSize: radios.length,
23969
+ tabbable: radio === stop
23970
+ }));
23971
+ }
23972
+ //#endregion
23973
+ //#region src/utilities/radio-grouping.ts
23974
+ /**
23975
+ * Both tags in one list, because the platform groups on the name and not on the
23976
+ * element. A button and a field that share a name are one group to the browser,
23977
+ * and two lists would have them counting different sets.
23978
+ */
23979
+ var RADIO_TAGS = "nldd-radio-button, nldd-radio-button-field";
23980
+ var ARROW_STEPS = {
23981
+ ArrowDown: 1,
23982
+ ArrowRight: 1,
23983
+ ArrowUp: -1,
23984
+ ArrowLeft: -1
23985
+ };
23986
+ /** The controller of each radio, so a recount can reach its siblings. */
23987
+ var groupings = /* @__PURE__ */ new WeakMap();
23988
+ /** A radio that draws the shape for something else owns no state of its own. */
23989
+ function isMember(radio) {
23990
+ return !radio.decorative;
23991
+ }
23992
+ var RadioGrouping = class RadioGrouping {
23993
+ constructor(host) {
23994
+ this.host = host;
23995
+ this.position = null;
23996
+ this.answered = false;
23997
+ this._groupedIn = null;
23998
+ groupings.set(host, this);
23999
+ host.addController(this);
24000
+ }
24001
+ hostConnected() {
24002
+ queueMicrotask(() => this.regroup());
24003
+ }
24004
+ hostDisconnected() {
24005
+ this.regroup();
24006
+ }
24007
+ /** Reads which group this radio is in now, and recounts the one it left. */
24008
+ regroup() {
24009
+ const was = this._groupedIn;
24010
+ const now = this._key();
24011
+ this._groupedIn = now;
24012
+ const moved = !now || !was || was.root !== now.root || was.name !== now.name || was.form !== now.form;
24013
+ if (was && moved) RadioGrouping._recount(was);
24014
+ if (now) RadioGrouping._recount(now);
24015
+ else this._apply(null, this.host.checked);
24016
+ }
24017
+ /** Counts the group again, after this radio was checked or disabled. */
24018
+ refresh() {
24019
+ if (this._groupedIn) RadioGrouping._recount(this._groupedIn);
24020
+ else this._apply(null, this.host.checked);
24021
+ }
24022
+ /** Unchecks the others, as checking a native radio does. */
24023
+ uncheckOthers() {
24024
+ const key = this._key();
24025
+ if (!key) return;
24026
+ for (const radio of RadioGrouping._radiosIn(key)) {
24027
+ if (radio === this.host || !radio.checked) continue;
24028
+ radio.checked = false;
24029
+ radio.commitFormValue();
24030
+ }
24031
+ }
24032
+ /**
24033
+ * Moves to the next radio of the group and checks it, as the arrow keys do
24034
+ * for native radios. Disabled options are stepped over, and the ends wrap.
24035
+ *
24036
+ * @returns whether the key was handled.
24037
+ */
24038
+ moveWithArrow(e) {
24039
+ const step = ARROW_STEPS[e.key];
24040
+ const key = step ? this._key() : null;
24041
+ if (!key || !this.position) return false;
24042
+ const radios = RadioGrouping._radiosIn(key).filter((radio) => !radio.disabled);
24043
+ const at = radios.indexOf(this.host);
24044
+ if (at === -1) return false;
24045
+ e.preventDefault();
24046
+ const next = radios[(at + step + radios.length) % radios.length];
24047
+ next.focus();
24048
+ next.select();
24049
+ return true;
24050
+ }
24051
+ _key() {
24052
+ if (!this.host.isConnected || !isMember(this.host) || !this.host.name) return null;
24053
+ return {
24054
+ root: this.host.getRootNode(),
24055
+ name: this.host.name,
24056
+ form: this.host.internals.form
24057
+ };
24058
+ }
24059
+ /** Compared by value: a recount hands out new objects for the same place, and
24060
+ * asking for a render on every one of those is a render per radio per
24061
+ * keystroke, each of them scheduled from inside an update that had just
24062
+ * finished. */
24063
+ _apply(position, answered) {
24064
+ if (this.answered === answered && this.position?.posInSet === position?.posInSet && this.position?.setSize === position?.setSize && this.position?.tabbable === position?.tabbable) return;
24065
+ this.position = position;
24066
+ this.answered = answered;
24067
+ this.host.requestUpdate();
24068
+ }
24069
+ static _radiosIn({ root, name, form }) {
24070
+ const radios = root.querySelectorAll?.(RADIO_TAGS) ?? [];
24071
+ return Array.from(radios).filter((radio) => groupings.has(radio) && radio.isConnected && radio.name === name && isMember(radio) && radio.internals.form === form);
24072
+ }
24073
+ /** A radio on its own keeps the place the browser gives it: none. */
24074
+ static _recount(key) {
24075
+ const radios = RadioGrouping._radiosIn(key);
24076
+ const positions = radios.length > 1 ? radioPositions(radios, (radio) => radio.checked, (radio) => radio.disabled) : [];
24077
+ const answered = radios.some((radio) => radio.checked);
24078
+ radios.forEach((radio, index) => {
24079
+ groupings.get(radio)?._apply(positions[index] ?? null, answered);
24080
+ });
24081
+ }
24082
+ };
24083
+ //#endregion
23942
24084
  //#region src/components/inputs/radio-button/radio-button.ts
23943
24085
  /**
23944
24086
  * Nederlandse Digitale Dienst Radio Button Component (Lit + TypeScript)
23945
24087
  *
23946
- * WAI-ARIA: Wrap radio buttons in a <fieldset>/<legend> or a container with
23947
- * role="radiogroup" and aria-labelledby for proper group semantics.
24088
+ * WAI-ARIA: put the buttons of one group in a container with
24089
+ * role="radiogroup" and a name of its own. A screen reader counts the options
24090
+ * from that container, so a bare <fieldset> (which is a group, not a
24091
+ * radiogroup) leaves them uncounted. nldd-radio-button-group does this for you.
24092
+ *
24093
+ * Radio buttons with the same name, in the same form and the same tree, form
24094
+ * one group the way native radios do: checking one unchecks the others, the
24095
+ * arrow keys move between them, Tab stops at the group once, and a screen
24096
+ * reader hears each one's place in it. The element itself is the radio, because
24097
+ * an input per shadow root would leave every button a group of one.
24098
+ *
24099
+ * The shape is all this component draws. A radio with a label beside it is
24100
+ * nldd-radio-button-field.
23948
24101
  *
23949
24102
  * @example
23950
- * <fieldset>
23951
- * <legend>Kies een optie</legend>
23952
- * <nldd-radio-button name="options" value="1">Optie 1</nldd-radio-button>
23953
- * <nldd-radio-button name="options" value="2">Optie 2</nldd-radio-button>
24103
+ * <fieldset role="radiogroup" aria-labelledby="options-label">
24104
+ * <legend id="options-label">Kies een optie</legend>
24105
+ * <nldd-radio-button name="options" value="1" accessible-label="Optie 1"></nldd-radio-button>
24106
+ * <nldd-radio-button name="options" value="2" accessible-label="Optie 2"></nldd-radio-button>
23954
24107
  * </fieldset>
23955
24108
  *
23956
24109
  * @element nldd-radio-button
23957
24110
  * @attr {boolean} checked - Checked state
23958
24111
  * @attr {boolean} disabled - Disabled state
23959
24112
  * @attr {boolean} no-tab - Takes the control out of the tab order (tabindex="-1"), for a control owned by a roving container (a row of an nldd-list, where the arrow keys move between rows) that manages focus itself. Still mouse- and script-focusable.
23960
- * @attr {boolean} decorative - Renders the shape without the input: no focus, no
24113
+ * @attr {boolean} decorative - Renders the shape only: no role, no focus, no
23961
24114
  * name/value, nothing announced. For a control that owns the state elsewhere,
23962
- * such as a list row that is itself the radio; putting a real input in there
23963
- * would nest a control inside a control.
23964
- * @attr {boolean} required - Required state
24115
+ * such as a list row or an nldd-radio-button-field that is itself the radio;
24116
+ * putting a second radio in there would nest a control inside a control.
24117
+ * @attr {boolean} required - Required state. What it asks is whether anything in the group is checked, as a native radio does.
24118
+ * @attr {boolean} focus-ring - Draws the focus ring around the shape. Set by nldd-radio-button-field, which is the radio itself and holds the focus. Not part of the public API.
23965
24119
  * @attr {string} name - Radio group name for form submission; ties the buttons of one group together
23966
24120
  * @attr {string} value - Value submitted with the form when this radio button is checked
23967
- * @attr {string} accessible-label - Accessible label forwarded as aria-label to the native input.
24121
+ * @attr {string} accessible-label - Accessible label, set as aria-label on this element: it is the radio.
23968
24122
  * @attr {boolean} invalid - Marks the control as invalid. Announced with aria-invalid; nothing is drawn for it.
23969
24123
  * Note: aria-labelledby is not supported as IDREF resolution cannot cross shadow DOM boundaries.
23970
24124
  *
@@ -23980,14 +24134,102 @@ var NLDDRadioButton = (_NLDDRadioButton = class NLDDRadioButton extends Describe
23980
24134
  this.disabled = false;
23981
24135
  this.noTab = false;
23982
24136
  this.required = false;
24137
+ this.focusRing = false;
23983
24138
  this.name = "";
23984
24139
  this.value = "";
23985
24140
  this.accessibleLabel = "";
23986
24141
  this.invalid = false;
24142
+ this._grouping = new RadioGrouping(this);
24143
+ this._appliedLabel = null;
24144
+ this._onClick = () => {
24145
+ this.select();
24146
+ };
24147
+ this._onKeyDown = (e) => {
24148
+ if (this.disabled || this.decorative) return;
24149
+ if (e.key === " ") {
24150
+ e.preventDefault();
24151
+ this.select();
24152
+ return;
24153
+ }
24154
+ if (e.key === "Enter") {
24155
+ submitOnEnter(this, e);
24156
+ return;
24157
+ }
24158
+ if (!this.noTab) this._grouping.moveWithArrow(e);
24159
+ };
24160
+ }
24161
+ /** Whether anything in its group is checked, which is what `required` asks
24162
+ * of a radio: one of these, not this one. */
24163
+ get _groupHasSelection() {
24164
+ return this._grouping.answered;
24165
+ }
24166
+ connectedCallback() {
24167
+ super.connectedCallback();
24168
+ this.addEventListener("click", this._onClick);
24169
+ this.addEventListener("keydown", this._onKeyDown);
24170
+ }
24171
+ disconnectedCallback() {
24172
+ super.disconnectedCallback();
24173
+ this.removeEventListener("click", this._onClick);
24174
+ this.removeEventListener("keydown", this._onKeyDown);
23987
24175
  }
23988
24176
  firstUpdated() {
23989
24177
  this._initialChecked = this.checked;
23990
24178
  }
24179
+ /** Counting happens before the render, not after it: the place this radio is
24180
+ * given decides what `updated()` writes on it, and a count from there would
24181
+ * ask for a second render of what was just drawn. */
24182
+ willUpdate(changed) {
24183
+ if (changed.has("checked") && this.checked) this._grouping.uncheckOthers();
24184
+ if (changed.has("name") || changed.has("decorative")) this._grouping.regroup();
24185
+ else if (changed.has("checked") || changed.has("disabled")) this._grouping.refresh();
24186
+ }
24187
+ updated(changed) {
24188
+ super.updated(changed);
24189
+ this._syncHostAria();
24190
+ }
24191
+ /**
24192
+ * This element is the radio.
24193
+ *
24194
+ * A native `<input type="radio">` groups with the radios of the same name in
24195
+ * its own tree, and one per shadow root leaves every radio a group of one: a
24196
+ * screen reader announces "1 of 1" and Tab stops at each of them. The role
24197
+ * on the element itself puts every radio of a group in one tree again, where
24198
+ * the browser counts them together.
24199
+ */
24200
+ _syncHostAria() {
24201
+ if (this.decorative) {
24202
+ for (const name of [
24203
+ "role",
24204
+ "aria-checked",
24205
+ "aria-disabled",
24206
+ "aria-posinset",
24207
+ "aria-setsize",
24208
+ "tabindex"
24209
+ ]) this.removeAttribute(name);
24210
+ return;
24211
+ }
24212
+ this.setAttribute("role", "radio");
24213
+ this.setAttribute("aria-checked", String(this.checked));
24214
+ this._appliedLabel = setOwnedAttribute(this, "aria-label", this.accessibleLabel, this._appliedLabel);
24215
+ if (this.disabled) this.setAttribute("aria-disabled", "true");
24216
+ else this.removeAttribute("aria-disabled");
24217
+ const position = this._grouping.position;
24218
+ if (position) {
24219
+ this.setAttribute("aria-posinset", String(position.posInSet));
24220
+ this.setAttribute("aria-setsize", String(position.setSize));
24221
+ } else {
24222
+ this.removeAttribute("aria-posinset");
24223
+ this.removeAttribute("aria-setsize");
24224
+ }
24225
+ const tabbable = !this.disabled && !this.noTab && position?.tabbable !== false;
24226
+ this.setAttribute("tabindex", tabbable ? "0" : "-1");
24227
+ }
24228
+ /** The element assistive software meets is this one, so the description and
24229
+ * the invalid state belong here rather than on something inside. */
24230
+ describedTarget() {
24231
+ return this;
24232
+ }
23991
24233
  formValue() {
23992
24234
  return this.checked ? this.value : null;
23993
24235
  }
@@ -23997,20 +24239,15 @@ var NLDDRadioButton = (_NLDDRadioButton = class NLDDRadioButton extends Describe
23997
24239
  formStateRestoreCallback(state) {
23998
24240
  this.checked = state !== null;
23999
24241
  }
24242
+ /** Checks this radio, the way a click or the space bar does. */
24000
24243
  select() {
24001
- if (this.disabled || this.checked) return;
24002
- const input = this.shadowRoot?.querySelector("input");
24003
- if (!input) return;
24004
- input.checked = true;
24005
- input.dispatchEvent(new Event("change", { bubbles: true }));
24006
- }
24007
- _handleChange(e) {
24008
- const input = e.target;
24009
- this.checked = input.checked;
24244
+ if (this.disabled || this.decorative || this.checked) return;
24245
+ this.checked = true;
24010
24246
  this.commitFormValue();
24247
+ this._grouping.uncheckOthers();
24011
24248
  this.dispatchEvent(new CustomEvent("change", {
24012
24249
  detail: {
24013
- checked: this.checked,
24250
+ checked: true,
24014
24251
  value: this.value,
24015
24252
  name: this.name
24016
24253
  },
@@ -24018,13 +24255,6 @@ var NLDDRadioButton = (_NLDDRadioButton = class NLDDRadioButton extends Describe
24018
24255
  composed: true
24019
24256
  }));
24020
24257
  }
24021
- /**
24022
- * Delegates focus to the inner native radio `<input>`, so consumers can
24023
- * call `radioEl.focus()` without reaching into shadow DOM.
24024
- */
24025
- focus(options) {
24026
- this.shadowRoot?.querySelector(".radio-button__input")?.focus(options);
24027
- }
24028
24258
  render() {
24029
24259
  return radioButtonTemplate(this);
24030
24260
  }
@@ -24050,6 +24280,11 @@ __decorate([property({
24050
24280
  type: Boolean,
24051
24281
  reflect: true
24052
24282
  })], NLDDRadioButton.prototype, "required", void 0);
24283
+ __decorate([property({
24284
+ type: Boolean,
24285
+ reflect: true,
24286
+ attribute: "focus-ring"
24287
+ })], NLDDRadioButton.prototype, "focusRing", void 0);
24053
24288
  __decorate([property({
24054
24289
  reflect: true,
24055
24290
  converter: reflectNonDefault("")
@@ -24077,12 +24312,29 @@ var radioButtonFieldStyles = css`
24077
24312
  :host {
24078
24313
  ${inheritedTextReset}
24079
24314
  display: block;
24315
+ position: relative;
24316
+ cursor: default;
24317
+ -webkit-tap-highlight-color: transparent;
24080
24318
  }
24081
24319
 
24082
24320
  :host([hidden]) {
24083
24321
  display: none;
24084
24322
  }
24085
24323
 
24324
+ :host(:focus-visible) {
24325
+ outline: none;
24326
+ }
24327
+
24328
+ .radio-button-field__validation-input {
24329
+ position: absolute;
24330
+ inset: 0;
24331
+ opacity: 0;
24332
+ margin: 0;
24333
+ pointer-events: none;
24334
+ width: 100%;
24335
+ height: 100%;
24336
+ }
24337
+
24086
24338
 
24087
24339
  /* # Block */
24088
24340
 
@@ -24121,22 +24373,27 @@ var radioButtonFieldStyles = css`
24121
24373
  //#region src/components/inputs/radio-button-field/radio-button-field.template.ts
24122
24374
  function radioButtonFieldTemplate(component) {
24123
24375
  return html`
24124
- <div class="radio-button-field"
24125
- @click=${component._handleLabelClick}
24126
- >
24376
+ <div class="radio-button-field">
24377
+ <input class="radio-button-field__validation-input"
24378
+ type="radio"
24379
+ name="nldd-validation"
24380
+ ?required=${component.required}
24381
+ ?disabled=${component.disabled}
24382
+ .checked=${component._answered}
24383
+ tabindex="-1"
24384
+ aria-hidden="true"
24385
+ >
24127
24386
  <div class="radio-button-field__control">
24128
24387
  <nldd-radio-button
24129
- ?invalid=${component.invalid}
24388
+ decorative
24130
24389
  ?checked=${component.checked}
24131
24390
  ?disabled=${component.disabled}
24132
- ?required=${component.required}
24133
- name=${component.name || ""}
24134
- value=${component.value}
24135
- accessible-label=${component.label || nothing}
24136
- @change=${component._handleChange}
24391
+ ?focus-ring=${component._focusRing}
24137
24392
  ></nldd-radio-button>
24138
24393
  </div>
24139
- <span class="radio-button-field__label">${component.label}</span>
24394
+ <span class="radio-button-field__label">
24395
+ ${component.label}
24396
+ </span>
24140
24397
  </div>
24141
24398
  `;
24142
24399
  }
@@ -24145,20 +24402,29 @@ function radioButtonFieldTemplate(component) {
24145
24402
  /**
24146
24403
  * Nederlandse Digitale Dienst Radio Button Field Component (Lit + TypeScript)
24147
24404
  *
24148
- * A radio button with an inline label. Use inside nldd-radio-button-group
24149
- * for keyboard navigation and group semantics. The group sets the name.
24405
+ * A radio button with an inline label. Use inside nldd-radio-button-group,
24406
+ * which sets the name and labels the group.
24407
+ *
24408
+ * Fields that stand on their own group by name, the way native radios do:
24409
+ * checking one unchecks the others, the arrow keys move between them and Tab
24410
+ * stops at the group once. Put them in a container with role="radiogroup", or
24411
+ * a screen reader has nothing to count them from.
24412
+ *
24413
+ * The field is the radio: it carries the role, the state, the label it is
24414
+ * announced by and the focus, and the nldd-radio-button inside only draws the
24415
+ * shape. A radio of its own in there would be a second control inside a
24416
+ * control, and a group of one to count.
24150
24417
  *
24151
- * Form-associated: the checked field submits its `value` under `name` (the
24152
- * inner nldd-radio-button sits in the shadow root and never joins the
24153
- * consumer's form; the field submits on its behalf). Unchecking siblings is
24154
- * the group's job, so exactly one value per group reaches the form.
24418
+ * Form-associated: the checked field submits its `value` under `name`.
24419
+ * Unchecking siblings is the group's job, so exactly one value per group
24420
+ * reaches the form.
24155
24421
  *
24156
24422
  * @element nldd-radio-button-field
24157
24423
  * @attr {boolean} checked - Checked state
24158
24424
  * @attr {boolean} disabled - Disabled state
24159
24425
  * @attr {string} value - Value for form submission
24160
- * @attr {string} name - Radio group name for form submission, forwarded to the inner nldd-radio-button. Set automatically by nldd-radio-button-group.
24161
- * @attr {boolean} required - Required state, forwarded to the inner nldd-radio-button. Set automatically by nldd-radio-button-group.
24426
+ * @attr {string} name - Radio group name for form submission; ties the fields of one group together. Set automatically by nldd-radio-button-group.
24427
+ * @attr {boolean} required - Required state. What it asks is whether anything in the group is checked, as a native radio does. Set automatically by nldd-radio-button-group.
24162
24428
  * @attr {string} label - Label text for the radio button
24163
24429
  * @attr {boolean} invalid - Marks the control as invalid. Announced with aria-invalid; nothing is drawn for it.
24164
24430
  *
@@ -24176,10 +24442,98 @@ var NLDDRadioButtonField = (_NLDDRadioButtonField = class NLDDRadioButtonField e
24176
24442
  this.required = false;
24177
24443
  this.label = "";
24178
24444
  this.invalid = false;
24445
+ this._groupPosition = null;
24446
+ this._groupHasSelection = false;
24447
+ this._grouping = new RadioGrouping(this);
24448
+ this._appliedLabel = null;
24449
+ this._focusRing = false;
24450
+ this._onClick = () => {
24451
+ this.select();
24452
+ };
24453
+ this._onFocus = () => {
24454
+ this._focusRing = isKeyboardMode();
24455
+ };
24456
+ this._onBlur = () => {
24457
+ this._focusRing = false;
24458
+ };
24459
+ this._onKeyDown = (e) => {
24460
+ if (this.disabled) return;
24461
+ if (e.key === " ") {
24462
+ e.preventDefault();
24463
+ this.select();
24464
+ return;
24465
+ }
24466
+ if (e.key === "Enter") {
24467
+ submitOnEnter(this, e);
24468
+ return;
24469
+ }
24470
+ if (!this._groupPosition) this._grouping.moveWithArrow(e);
24471
+ };
24472
+ }
24473
+ /** The place it is announced with: from the group around it, or from the
24474
+ * radios it shares a name with. */
24475
+ get _position() {
24476
+ return this._groupPosition ?? this._grouping.position;
24477
+ }
24478
+ /** Whether anything in its group is checked. Either source saying so is
24479
+ * enough: they answer for the same set of radios. */
24480
+ get _answered() {
24481
+ return this._groupHasSelection || this._grouping.answered;
24482
+ }
24483
+ connectedCallback() {
24484
+ super.connectedCallback();
24485
+ this.addEventListener("click", this._onClick);
24486
+ this.addEventListener("keydown", this._onKeyDown);
24487
+ this.addEventListener("focus", this._onFocus);
24488
+ this.addEventListener("blur", this._onBlur);
24489
+ }
24490
+ disconnectedCallback() {
24491
+ super.disconnectedCallback();
24492
+ this.removeEventListener("click", this._onClick);
24493
+ this.removeEventListener("keydown", this._onKeyDown);
24494
+ this.removeEventListener("focus", this._onFocus);
24495
+ this.removeEventListener("blur", this._onBlur);
24179
24496
  }
24180
24497
  firstUpdated() {
24181
24498
  this._initialChecked = this.checked;
24182
24499
  }
24500
+ /** Counting happens before the render, not after it: the place this field is
24501
+ * given decides what `updated()` writes on it, and a count from there would
24502
+ * ask for a second render of what was just drawn. */
24503
+ willUpdate(changed) {
24504
+ if (changed.has("checked") && this.checked) this._grouping.uncheckOthers();
24505
+ if (changed.has("name")) this._grouping.regroup();
24506
+ else if (changed.has("checked") || changed.has("disabled")) this._grouping.refresh();
24507
+ }
24508
+ updated(changed) {
24509
+ super.updated(changed);
24510
+ this._syncHostAria();
24511
+ }
24512
+ /**
24513
+ * This element is the radio, label and all.
24514
+ *
24515
+ * A native radio in a shadow root of its own is a group of one, which a
24516
+ * screen reader counts as "1 of 1" and Tab stops at one by one. The role on
24517
+ * the element itself puts the whole group back in one tree, and the label it
24518
+ * renders is the name it is announced by.
24519
+ */
24520
+ _syncHostAria() {
24521
+ this.setAttribute("role", "radio");
24522
+ this.setAttribute("aria-checked", String(this.checked));
24523
+ this._appliedLabel = setOwnedAttribute(this, "aria-label", this.label, this._appliedLabel);
24524
+ if (this.disabled) this.setAttribute("aria-disabled", "true");
24525
+ else this.removeAttribute("aria-disabled");
24526
+ const position = this._position;
24527
+ if (position) {
24528
+ this.setAttribute("aria-posinset", String(position.posInSet));
24529
+ this.setAttribute("aria-setsize", String(position.setSize));
24530
+ } else {
24531
+ this.removeAttribute("aria-posinset");
24532
+ this.removeAttribute("aria-setsize");
24533
+ }
24534
+ const tabbable = !this.disabled && position?.tabbable !== false;
24535
+ this.setAttribute("tabindex", tabbable ? "0" : "-1");
24536
+ }
24183
24537
  /** An unchecked radio contributes nothing to the form. The group calls
24184
24538
  * `commitFormValue()` on the siblings it unchecks, so a listener that reads
24185
24539
  * the form while `change` is still propagating never sees two values. */
@@ -24192,34 +24546,27 @@ var NLDDRadioButtonField = (_NLDDRadioButtonField = class NLDDRadioButtonField e
24192
24546
  formStateRestoreCallback(state) {
24193
24547
  this.checked = state !== null;
24194
24548
  }
24195
- _handleLabelClick(e) {
24196
- if (this.disabled) return;
24197
- if (e.target.closest?.("nldd-radio-button")) return;
24198
- (this.shadowRoot?.querySelector("nldd-radio-button"))?.select();
24199
- }
24200
- _handleChange(e) {
24201
- this.checked = e.detail.checked;
24549
+ /** Checks this field, the way a click or the space bar does. The group hears
24550
+ * the change and unchecks the others; without a group the fields that share
24551
+ * its name are unchecked here. */
24552
+ select() {
24553
+ if (this.disabled || this.checked) return;
24554
+ this.checked = true;
24202
24555
  this.commitFormValue();
24556
+ this._grouping.uncheckOthers();
24203
24557
  this.dispatchEvent(new CustomEvent("change", {
24204
24558
  detail: {
24205
- checked: this.checked,
24559
+ checked: true,
24206
24560
  value: this.value
24207
24561
  },
24208
24562
  bubbles: true,
24209
24563
  composed: true
24210
24564
  }));
24211
24565
  }
24212
- /**
24213
- * Delegates focus to the inner `<nldd-radio-button>`, which in turn focuses
24214
- * its native input. Lets consumers call `radioFieldEl.focus()` without
24215
- * reaching into shadow DOM.
24216
- */
24217
- focus(options) {
24218
- this.shadowRoot?.querySelector("nldd-radio-button")?.focus(options);
24219
- }
24220
- /** The radio button it renders knows which element inside itself is the control. */
24566
+ /** The element assistive software meets is this one, so the description and
24567
+ * the invalid state belong here rather than on the shape inside. */
24221
24568
  describedTarget() {
24222
- return this.shadowRoot?.querySelector("nldd-radio-button") ?? null;
24569
+ return this;
24223
24570
  }
24224
24571
  render() {
24225
24572
  return radioButtonFieldTemplate(this);
@@ -24247,6 +24594,9 @@ __decorate([property({
24247
24594
  type: Boolean,
24248
24595
  reflect: true
24249
24596
  })], NLDDRadioButtonField.prototype, "invalid", void 0);
24597
+ __decorate([state()], NLDDRadioButtonField.prototype, "_groupPosition", void 0);
24598
+ __decorate([state()], NLDDRadioButtonField.prototype, "_groupHasSelection", void 0);
24599
+ __decorate([state()], NLDDRadioButtonField.prototype, "_focusRing", void 0);
24250
24600
  NLDDRadioButtonField = __decorate([customElement("nldd-radio-button-field")], NLDDRadioButtonField);
24251
24601
  //#endregion
24252
24602
  //#region src/components/inputs/radio-button-group/radio-button-group.styles.ts
@@ -24324,6 +24674,7 @@ var NLDDRadioButtonGroup = (_NLDDRadioButtonGroup = class NLDDRadioButtonGroup e
24324
24674
  field.checked = false;
24325
24675
  field.commitFormValue?.();
24326
24676
  });
24677
+ this._positionFields();
24327
24678
  };
24328
24679
  this._handleKeyDown = (e) => {
24329
24680
  if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
@@ -24338,7 +24689,8 @@ var NLDDRadioButtonGroup = (_NLDDRadioButtonGroup = class NLDDRadioButtonGroup e
24338
24689
  activeField.commitFormValue?.();
24339
24690
  nextField.checked = true;
24340
24691
  nextField.commitFormValue?.();
24341
- (nextField.shadowRoot?.querySelector("nldd-radio-button")?.shadowRoot?.querySelector("input"))?.focus();
24692
+ this._positionFields();
24693
+ nextField.focus();
24342
24694
  nextField.dispatchEvent(new CustomEvent("change", {
24343
24695
  detail: {
24344
24696
  checked: true,
@@ -24400,6 +24752,17 @@ var NLDDRadioButtonGroup = (_NLDDRadioButtonGroup = class NLDDRadioButtonGroup e
24400
24752
  field.disabled = false;
24401
24753
  }
24402
24754
  });
24755
+ this._positionFields();
24756
+ }
24757
+ /** Each field its place in the group, and the group one stop for Tab. A
24758
+ * field cannot count its siblings: it only sees itself. */
24759
+ _positionFields() {
24760
+ const fields = this._getFields();
24761
+ const answered = fields.some((field) => field.checked);
24762
+ radioPositions(fields, (field) => field.checked, (field) => field.disabled).forEach((position, index) => {
24763
+ fields[index]._groupPosition = position;
24764
+ fields[index]._groupHasSelection = answered;
24765
+ });
24403
24766
  }
24404
24767
  /**
24405
24768
  * Delegates focus to the checked option, or to the first enabled one when
@@ -24923,6 +25286,16 @@ var segmentedControlStyles = css`
24923
25286
 
24924
25287
  /* # Elements */
24925
25288
 
25289
+ .segmented-control__validation-input {
25290
+ position: absolute;
25291
+ inset: 0;
25292
+ opacity: 0;
25293
+ margin: 0;
25294
+ pointer-events: none;
25295
+ width: 100%;
25296
+ height: 100%;
25297
+ }
25298
+
24926
25299
  ::slotted(nldd-segmented-control-item[selected]) {
24927
25300
  position: relative;
24928
25301
  z-index: var(--_selected-z-index);
@@ -25077,12 +25450,18 @@ var segmentedControlItemStyles = css`
25077
25450
  opacity: var(--primitives-opacity-disabled);
25078
25451
  }
25079
25452
 
25453
+ :host(:focus-visible) {
25454
+ outline: none;
25455
+ }
25456
+
25457
+ :host(:focus-visible) .segmented-control__item,
25080
25458
  .segmented-control__item:has(:focus-visible) {
25081
25459
  outline: var(--semantics-focus-ring-outline);
25082
25460
  outline-offset: var(--semantics-focus-ring-outline-offset);
25083
25461
  box-shadow: var(--semantics-focus-ring-box-shadow);
25084
25462
  }
25085
25463
 
25464
+ :host([selected]:focus-visible) .segmented-control__item,
25086
25465
  :host([selected]) .segmented-control__item:has(:focus-visible) {
25087
25466
  box-shadow: var(--semantics-focus-ring-box-shadow), inset 0 0 0 var(--primitives-border-width-thin) var(--_highlight-border-color);
25088
25467
  }
@@ -25138,35 +25517,48 @@ var segmentedControlItemStyles = css`
25138
25517
  //#endregion
25139
25518
  //#region src/components/inputs/segmented-control/segmented-control.template.ts
25140
25519
  function segmentedControlTemplate(component) {
25141
- return html`<slot @slotchange=${component._onSlotChange}></slot>`;
25520
+ return html`
25521
+ <input class="segmented-control__validation-input"
25522
+ type="radio"
25523
+ name="nldd-validation"
25524
+ ?required=${component.required && component.type !== "checkbox"}
25525
+ ?disabled=${component.disabled}
25526
+ .checked=${!!component.value}
25527
+ tabindex="-1"
25528
+ aria-hidden="true"
25529
+ >
25530
+ <slot @slotchange=${component._onSlotChange}></slot>
25531
+ `;
25142
25532
  }
25143
25533
  function segmentedControlItemTemplate(component) {
25144
25534
  const isIcon = component.variant === "icon";
25145
25535
  const showPlaceholder = isIcon || component.variant === "icon-and-text";
25146
25536
  const labelText = component.text || nothing;
25147
- const label = html`
25148
- <label class="segmented-control__item">
25149
- <input class="segmented-control__item-input"
25150
- type=${component.inputType}
25151
- name=${component.groupName || nothing}
25152
- value=${component.value}
25153
- .checked=${component.selected}
25154
- ?disabled=${component.disabled}
25155
- ?required=${component.required}
25156
- aria-label=${isIcon ? labelText : nothing}
25157
- @change=${component._handleChange}
25158
- >
25159
- <span class="segmented-control__item-icon"
25160
- aria-hidden=${isIcon ? nothing : "true"}
25161
- >
25162
- ${component.icon ? html`<nldd-icon name=${component.icon}></nldd-icon>` : html`<slot name="icon">${showPlaceholder ? html`<nldd-icon name="icon-placeholder"></nldd-icon>` : nothing}</slot>`}
25163
- </span>
25164
- <span class="segmented-control__item-text"
25165
- aria-hidden=${isIcon ? "true" : nothing}
25166
- >
25167
- ${component.text}
25168
- </span>
25169
- </label>`;
25537
+ const content = html`
25538
+ <span class="segmented-control__item-icon"
25539
+ aria-hidden=${isIcon ? nothing : "true"}
25540
+ >
25541
+ ${component.icon ? html`<nldd-icon name=${component.icon}></nldd-icon>` : html`<slot name="icon">${showPlaceholder ? html`<nldd-icon name="icon-placeholder"></nldd-icon>` : nothing}</slot>`}
25542
+ </span>
25543
+ <span class="segmented-control__item-text"
25544
+ aria-hidden=${isIcon ? "true" : nothing}
25545
+ >
25546
+ ${component.text}
25547
+ </span>`;
25548
+ const label = component.inputType === "radio" ? html`<div class="segmented-control__item">${content}</div>` : html`
25549
+ <label class="segmented-control__item">
25550
+ <input class="segmented-control__item-input"
25551
+ type="checkbox"
25552
+ name=${component.groupName || nothing}
25553
+ value=${component.value}
25554
+ .checked=${component.selected}
25555
+ ?disabled=${component.disabled}
25556
+ ?required=${component.required}
25557
+ aria-label=${isIcon ? labelText : nothing}
25558
+ @change=${component._handleChange}
25559
+ >
25560
+ ${content}
25561
+ </label>`;
25170
25562
  if (isIcon && labelText) return html`<nldd-tooltip text=${labelText}>${label}</nldd-tooltip>`;
25171
25563
  return label;
25172
25564
  }
@@ -25182,11 +25574,11 @@ function segmentedControlItemTemplate(component) {
25182
25574
  * @attr {string} value - Selected value for radio type
25183
25575
  * @prop {string[]} values - Selected values for checkbox type (property binding only, not an attribute)
25184
25576
  * @attr {string} size - Control size: 'sm' | 'md' | 'lg' (default: 'md')
25185
- * @attr {string} type - Input type: 'radio' | 'checkbox' (default: 'radio').
25577
+ * @attr {string} type - Selection mode: 'radio' | 'checkbox' (default: 'radio').
25186
25578
  * @attr {string} variant - Content type for all items: 'text' | 'icon' | 'icon-and-text' (default: 'text')
25187
25579
  * @attr {boolean} disabled - Disabled state for all items
25188
25580
  * @attr {string} width - Width mode: 'full' (stretches to container), 'fit-content' (per-item content size), or any CSS length (e.g. '240px')
25189
- * @attr {string} name - Name for form submission, forwarded to native inputs
25581
+ * @attr {string} name - Name for form submission
25190
25582
  * @attr {string} accessible-label - Accessible name for the group, set as aria-label
25191
25583
  * @attr {string} accessible-labeled-by - Id of an external label element, set as aria-labelledby on the group
25192
25584
  * @attr {boolean} required - Marks the group as required. Enforced in radio mode; in checkbox mode only announced.
@@ -25206,9 +25598,9 @@ function segmentedControlItemTemplate(component) {
25206
25598
  * @attr {string} icon - Icon name for nldd-icon
25207
25599
  * @attr {string} size - Control size: 'sm' | 'md' | 'lg' (default: 'md'). Set by nldd-segmented-control.
25208
25600
  * @attr {string} variant - Content type: 'text' | 'icon' | 'icon-and-text' (default: 'text'). Set by nldd-segmented-control.
25209
- * @attr {string} input-type - Type of the native input: 'radio' | 'checkbox' (default: 'radio'). Set by nldd-segmented-control.
25210
- * @attr {string} group-name - Name of the group for form submission, put on the native input. Set by nldd-segmented-control.
25211
- * @attr {boolean} required - Required state. Set by nldd-segmented-control.
25601
+ * @attr {string} input-type - Selection mode: 'radio' | 'checkbox' (default: 'radio'). In radio mode the item is the radio itself, in checkbox mode it renders a native checkbox. Set by nldd-segmented-control.
25602
+ * @attr {string} group-name - Name of the group for form submission, put on the native checkbox. Set by nldd-segmented-control.
25603
+ * @attr {boolean} required - Required state of the native checkbox. In radio mode the group carries the constraint.
25212
25604
  *
25213
25605
  * @slot icon - Slot for a custom icon (e.g. custom SVG). Only used when icon attribute is not set.
25214
25606
  *
@@ -25230,6 +25622,102 @@ var NLDDSegmentedControlItem = (_NLDDSegmentedControlItem = class NLDDSegmentedC
25230
25622
  this.groupName = "";
25231
25623
  this.text = "";
25232
25624
  this.icon = "";
25625
+ this._groupPosition = null;
25626
+ this._appliedRadioLabel = null;
25627
+ this._onRadioFocus = () => {
25628
+ this._tooltip?._handleFocusIn();
25629
+ };
25630
+ this._onRadioBlur = () => {
25631
+ this._tooltip?._handleTriggerLeave();
25632
+ };
25633
+ this._onRadioClick = () => {
25634
+ if (this.inputType !== "radio") return;
25635
+ this._select();
25636
+ };
25637
+ this._onRadioKeyDown = (e) => {
25638
+ if (this.inputType !== "radio" || this.disabled) return;
25639
+ if (e.key === " ") {
25640
+ e.preventDefault();
25641
+ this._select();
25642
+ return;
25643
+ }
25644
+ if (e.key === "Escape") this._tooltip?._handleTriggerLeave();
25645
+ };
25646
+ }
25647
+ connectedCallback() {
25648
+ super.connectedCallback();
25649
+ this.addEventListener("click", this._onRadioClick);
25650
+ this.addEventListener("keydown", this._onRadioKeyDown);
25651
+ this.addEventListener("focus", this._onRadioFocus);
25652
+ this.addEventListener("blur", this._onRadioBlur);
25653
+ }
25654
+ disconnectedCallback() {
25655
+ super.disconnectedCallback();
25656
+ this.removeEventListener("click", this._onRadioClick);
25657
+ this.removeEventListener("keydown", this._onRadioKeyDown);
25658
+ this.removeEventListener("focus", this._onRadioFocus);
25659
+ this.removeEventListener("blur", this._onRadioBlur);
25660
+ }
25661
+ /**
25662
+ * The tooltip of the icon variant, which shows itself when focus moves into
25663
+ * it. In radio mode the focus is on this element, outside it, so it hears
25664
+ * about focus and Escape from here.
25665
+ */
25666
+ get _tooltip() {
25667
+ return this.inputType === "radio" ? this.shadowRoot?.querySelector("nldd-tooltip") ?? null : null;
25668
+ }
25669
+ updated() {
25670
+ this._syncRadioAria();
25671
+ }
25672
+ /**
25673
+ * In radio mode this element is the radio.
25674
+ *
25675
+ * A native radio in a shadow root of its own is a group of one: a screen
25676
+ * reader counts it as "1 of 1" and Tab stops at every option. The role on the
25677
+ * element itself puts the whole group back in one tree, under the radiogroup
25678
+ * the control carries. Checkbox mode keeps its native input.
25679
+ */
25680
+ _syncRadioAria() {
25681
+ if (this.inputType !== "radio") {
25682
+ for (const name of [
25683
+ "role",
25684
+ "aria-checked",
25685
+ "aria-disabled",
25686
+ "aria-posinset",
25687
+ "aria-setsize",
25688
+ "tabindex"
25689
+ ]) this.removeAttribute(name);
25690
+ this._appliedRadioLabel = setOwnedAttribute(this, "aria-label", "", this._appliedRadioLabel);
25691
+ return;
25692
+ }
25693
+ this.setAttribute("role", "radio");
25694
+ this.setAttribute("aria-checked", String(this.selected));
25695
+ this._appliedRadioLabel = setOwnedAttribute(this, "aria-label", this.text, this._appliedRadioLabel);
25696
+ if (this.disabled) this.setAttribute("aria-disabled", "true");
25697
+ else this.removeAttribute("aria-disabled");
25698
+ const position = this._groupPosition;
25699
+ if (position) {
25700
+ this.setAttribute("aria-posinset", String(position.posInSet));
25701
+ this.setAttribute("aria-setsize", String(position.setSize));
25702
+ } else {
25703
+ this.removeAttribute("aria-posinset");
25704
+ this.removeAttribute("aria-setsize");
25705
+ }
25706
+ const tabbable = !this.disabled && position?.tabbable !== false;
25707
+ this.setAttribute("tabindex", tabbable ? "0" : "-1");
25708
+ }
25709
+ /** Checked, the way a click or the space bar does it. A radio never
25710
+ * unchecks itself, so an option that is already selected says nothing. */
25711
+ _select() {
25712
+ if (this.disabled || this.selected) return;
25713
+ this.dispatchEvent(new CustomEvent("item-change", {
25714
+ detail: {
25715
+ value: this.value,
25716
+ checked: true
25717
+ },
25718
+ bubbles: true,
25719
+ composed: true
25720
+ }));
25233
25721
  }
25234
25722
  _handleChange(e) {
25235
25723
  const input = e.target;
@@ -25243,10 +25731,15 @@ var NLDDSegmentedControlItem = (_NLDDSegmentedControlItem = class NLDDSegmentedC
25243
25731
  }));
25244
25732
  }
25245
25733
  /**
25246
- * Delegates focus to the inner native `<input>`, so consumers can call
25734
+ * Delegates focus to whichever control this mode renders: the element itself
25735
+ * in radio mode, the native `<input>` in checkbox mode. Lets consumers call
25247
25736
  * `itemEl.focus()` without reaching into shadow DOM.
25248
25737
  */
25249
25738
  focus(options) {
25739
+ if (this.inputType === "radio") {
25740
+ super.focus(options);
25741
+ return;
25742
+ }
25250
25743
  this.shadowRoot?.querySelector(".segmented-control__item-input")?.focus(options);
25251
25744
  }
25252
25745
  render() {
@@ -25289,6 +25782,7 @@ __decorate([property({
25289
25782
  converter: reflectNonDefault("")
25290
25783
  })], NLDDSegmentedControlItem.prototype, "text", void 0);
25291
25784
  __decorate([property({ type: String })], NLDDSegmentedControlItem.prototype, "icon", void 0);
25785
+ __decorate([state()], NLDDSegmentedControlItem.prototype, "_groupPosition", void 0);
25292
25786
  NLDDSegmentedControlItem = __decorate([customElement("nldd-segmented-control-item")], NLDDSegmentedControlItem);
25293
25787
  var NLDDSegmentedControl = (_NLDDSegmentedControl2 = class NLDDSegmentedControl extends FormAssociated(LitElement) {
25294
25788
  constructor(..._args2) {
@@ -25338,6 +25832,10 @@ var NLDDSegmentedControl = (_NLDDSegmentedControl2 = class NLDDSegmentedControl
25338
25832
  };
25339
25833
  this._handleKeydown = (e) => {
25340
25834
  if (this.disabled || this.type === "checkbox") return;
25835
+ if (e.key === "Enter") {
25836
+ submitOnEnter(this, e);
25837
+ return;
25838
+ }
25341
25839
  const items = this._getItems().filter((item) => !item.disabled);
25342
25840
  if (items.length === 0) return;
25343
25841
  const currentIndex = items.findIndex((item) => item.selected);
@@ -25367,7 +25865,7 @@ var NLDDSegmentedControl = (_NLDDSegmentedControl2 = class NLDDSegmentedControl
25367
25865
  this.value = items[nextIndex].value;
25368
25866
  this._syncItems();
25369
25867
  this.commitFormValue();
25370
- items[nextIndex].shadowRoot?.querySelector("input")?.focus();
25868
+ items[nextIndex].focus();
25371
25869
  this.dispatchEvent(new CustomEvent("change", {
25372
25870
  detail: { value: this.value },
25373
25871
  bubbles: true,
@@ -25435,9 +25933,6 @@ var NLDDSegmentedControl = (_NLDDSegmentedControl2 = class NLDDSegmentedControl
25435
25933
  }
25436
25934
  _syncItems() {
25437
25935
  const items = this._getItems();
25438
- items.forEach((item) => {
25439
- item.required = this.required && this.type !== "checkbox";
25440
- });
25441
25936
  this.toggleAttribute("aria-required", this.required);
25442
25937
  if (this.invalid) this.setAttribute("aria-invalid", "true");
25443
25938
  else this.removeAttribute("aria-invalid");
@@ -25461,6 +25956,10 @@ var NLDDSegmentedControl = (_NLDDSegmentedControl2 = class NLDDSegmentedControl
25461
25956
  item.groupName = this.name || this._autoName;
25462
25957
  item.selected = this.type === "checkbox" ? selectedValues.includes(item.value) : item.value === this.value;
25463
25958
  });
25959
+ const positions = this.type === "radio" ? radioPositions(items, (item) => item.selected, (item) => item.disabled) : [];
25960
+ items.forEach((item, index) => {
25961
+ item._groupPosition = positions[index] ?? null;
25962
+ });
25464
25963
  }
25465
25964
  get _autoName() {
25466
25965
  if (!this._generatedName) this._generatedName = `nldd-segmented-${_NLDDSegmentedControl._idCounter++}`;
@@ -28881,7 +29380,7 @@ function completionSource(getTypeaheads, onChoose) {
28881
29380
  }
28882
29381
  var popupTheme = EditorView.theme({
28883
29382
  ".cm-tooltip.cm-tooltip-autocomplete": {
28884
- border: "1px solid var(--semantics-surfaces-tinted-border-color)",
29383
+ border: "none",
28885
29384
  borderRadius: "var(--semantics-overlays-corner-radius)",
28886
29385
  backgroundColor: "var(--semantics-surfaces-base-background-color)",
28887
29386
  boxShadow: "var(--components-menu-box-shadow)"
@@ -30556,11 +31055,13 @@ var stableCursor = [
30556
31055
  head,
30557
31056
  side: "pointer"
30558
31057
  };
30559
- else if (tr.isUserEvent("input") || tr.isUserEvent("delete.backward")) hint = {
30560
- head,
30561
- side: linkEndsAt(update.state, head) && !linkEndsAt(update.startState, oldHead) ? 1 : -1
30562
- };
30563
- else if (tr.isUserEvent("delete.forward")) hint = {
31058
+ else if (tr.isUserEvent("input") || tr.isUserEvent("delete.backward")) {
31059
+ const grew = tr.isUserEvent("input") && bareUrlEndsAt(update.state, head);
31060
+ hint = {
31061
+ head,
31062
+ side: linkEndsAt(update.state, head) && !linkEndsAt(update.startState, oldHead) && !grew ? 1 : -1
31063
+ };
31064
+ } else if (tr.isUserEvent("delete.forward")) hint = {
30564
31065
  head,
30565
31066
  side: 1
30566
31067
  };
@@ -34630,6 +35131,7 @@ var toggleButtonStyles = css`
34630
35131
 
34631
35132
  ${inheritedTextReset}
34632
35133
  display: inline-block;
35134
+ position: relative;
34633
35135
  -webkit-user-select: none;
34634
35136
  user-select: none;
34635
35137
  -webkit-tap-highlight-color: transparent;
@@ -34639,6 +35141,10 @@ var toggleButtonStyles = css`
34639
35141
  display: none;
34640
35142
  }
34641
35143
 
35144
+ :host(:focus-visible) {
35145
+ outline: none;
35146
+ }
35147
+
34642
35148
  :host([size="xs"]) {
34643
35149
  --_corner-radius: var(--semantics-controls-xs-corner-radius);
34644
35150
  --_min-size: var(--semantics-controls-xs-min-size);
@@ -34748,7 +35254,8 @@ var toggleButtonStyles = css`
34748
35254
  }
34749
35255
 
34750
35256
  .toggle-button:focus-visible,
34751
- .toggle-button:has(.toggle-button__input:focus-visible) {
35257
+ .toggle-button:has(.toggle-button__input:focus-visible),
35258
+ :host(:focus-visible) .toggle-button {
34752
35259
  outline: var(--semantics-focus-ring-outline);
34753
35260
  outline-offset: var(--semantics-focus-ring-outline-offset);
34754
35261
  box-shadow: var(--semantics-focus-ring-box-shadow), inset 0 0 0 var(--primitives-border-width-thin) var(--_highlight-border-color);
@@ -34808,6 +35315,16 @@ var toggleButtonStyles = css`
34808
35315
  width: 100%;
34809
35316
  height: 100%;
34810
35317
  }
35318
+
35319
+ .toggle-button__validation-input {
35320
+ position: absolute;
35321
+ inset: 0;
35322
+ opacity: 0;
35323
+ margin: 0;
35324
+ pointer-events: none;
35325
+ width: 100%;
35326
+ height: 100%;
35327
+ }
34811
35328
  `;
34812
35329
  //#endregion
34813
35330
  //#region src/components/inputs/toggle-button/toggle-button.template.ts
@@ -34826,10 +35343,25 @@ function toggleButtonTemplate(component) {
34826
35343
  ></nldd-icon>` : nothing}</slot>`;
34827
35344
  const textContent = showText ? html`<span class="toggle-button__text">${component.text}</span>` : nothing;
34828
35345
  let result;
34829
- if (component.type === "checkbox" || component.type === "radio") result = html`
35346
+ if (component.type === "radio") result = html`
35347
+ <div class="toggle-button">
35348
+ <input class="toggle-button__validation-input"
35349
+ type="radio"
35350
+ name="nldd-validation"
35351
+ ?required=${component.required}
35352
+ ?disabled=${component.disabled}
35353
+ .checked=${component._groupHasSelection}
35354
+ tabindex="-1"
35355
+ aria-hidden="true"
35356
+ >
35357
+ ${icon}
35358
+ ${textContent}
35359
+ </div>
35360
+ `;
35361
+ else if (component.type === "checkbox") result = html`
34830
35362
  <label class="toggle-button">
34831
35363
  <input class="toggle-button__input"
34832
- type=${component.type}
35364
+ type="checkbox"
34833
35365
  .checked=${component.selected}
34834
35366
  ?disabled=${component.disabled}
34835
35367
  ?required=${component.required}
@@ -34865,11 +35397,15 @@ function toggleButtonTemplate(component) {
34865
35397
  * Nederlandse Digitale Dienst Toggle Button Component (Lit + TypeScript)
34866
35398
  *
34867
35399
  * A selectable button that toggles between selected and unselected.
34868
- * Available as a button (aria-pressed), checkbox, or radio input.
35400
+ * Available as a button (aria-pressed), a checkbox, or a radio.
35401
+ *
35402
+ * In radio mode the button itself is the radio: it carries the role, the state
35403
+ * and its place in the group. A native radio in a shadow root of its own would
35404
+ * be a group of one, counted as "1 of 1" and stopped at by Tab.
34869
35405
  *
34870
35406
  * @element nldd-toggle-button
34871
35407
  *
34872
- * @attr {'button' | 'checkbox' | 'radio'} type - Underlying element (default: 'button')
35408
+ * @attr {'button' | 'checkbox' | 'radio'} type - What the button is: a button with aria-pressed, a native checkbox, or a radio (default: 'button')
34873
35409
  * @attr {'xs' | 'sm' | 'md' | 'lg'} size - Button size (default: 'md')
34874
35410
  * @attr {boolean} selected - Selected state
34875
35411
  * @attr {boolean} disabled - Disabled state
@@ -34907,6 +35443,32 @@ var NLDDToggleButton = (_NLDDToggleButton = class NLDDToggleButton extends Descr
34907
35443
  this._warnedA11y = false;
34908
35444
  this._warnedEmptyIcon = false;
34909
35445
  this.invalid = false;
35446
+ this._groupPosition = null;
35447
+ this._groupHasSelection = false;
35448
+ this._appliedRadioLabel = null;
35449
+ this._onRadioClick = () => {
35450
+ if (this.type !== "radio") return;
35451
+ this.toggle();
35452
+ };
35453
+ this._onRadioKeyDown = (e) => {
35454
+ if (this.type !== "radio" || this.disabled) return;
35455
+ if (e.key === " ") {
35456
+ e.preventDefault();
35457
+ this.toggle();
35458
+ return;
35459
+ }
35460
+ if (e.key === "Enter") {
35461
+ submitOnEnter(this, e);
35462
+ return;
35463
+ }
35464
+ if (e.key === "Escape") this._tooltip?._handleTriggerLeave();
35465
+ };
35466
+ this._onRadioFocus = () => {
35467
+ this._tooltip?._handleFocusIn();
35468
+ };
35469
+ this._onRadioBlur = () => {
35470
+ this._tooltip?._handleTriggerLeave();
35471
+ };
34910
35472
  }
34911
35473
  /** Whether an icon is present via attribute or slot. */
34912
35474
  get _hasIcon() {
@@ -34922,6 +35484,44 @@ var NLDDToggleButton = (_NLDDToggleButton = class NLDDToggleButton extends Descr
34922
35484
  if (!(!this.text && !this.accessibleLabel)) this._warnedA11y = false;
34923
35485
  if (this._hasIcon || this.variant !== "icon") this._warnedEmptyIcon = false;
34924
35486
  if (changed.has("selected") || changed.has("value") || changed.has("type")) this.commitFormValue();
35487
+ this._syncRadioAria();
35488
+ }
35489
+ /**
35490
+ * In radio mode this element is the radio.
35491
+ *
35492
+ * A native radio in a shadow root of its own is a group of one: a screen
35493
+ * reader counts it as "1 of 1" and Tab stops at every option. The role on the
35494
+ * element itself puts the group back in one tree. The other two types keep
35495
+ * the control they render: a checkbox input, or a button with aria-pressed.
35496
+ */
35497
+ _syncRadioAria() {
35498
+ if (this.type !== "radio") {
35499
+ for (const name of [
35500
+ "role",
35501
+ "aria-checked",
35502
+ "aria-disabled",
35503
+ "aria-posinset",
35504
+ "aria-setsize",
35505
+ "tabindex"
35506
+ ]) this.removeAttribute(name);
35507
+ this._appliedRadioLabel = setOwnedAttribute(this, "aria-label", "", this._appliedRadioLabel);
35508
+ return;
35509
+ }
35510
+ this.setAttribute("role", "radio");
35511
+ this.setAttribute("aria-checked", String(this.selected));
35512
+ this._appliedRadioLabel = setOwnedAttribute(this, "aria-label", this.accessibleLabel || this.text, this._appliedRadioLabel);
35513
+ if (this.disabled) this.setAttribute("aria-disabled", "true");
35514
+ else this.removeAttribute("aria-disabled");
35515
+ const position = this._groupPosition;
35516
+ if (position) {
35517
+ this.setAttribute("aria-posinset", String(position.posInSet));
35518
+ this.setAttribute("aria-setsize", String(position.setSize));
35519
+ } else {
35520
+ this.removeAttribute("aria-posinset");
35521
+ this.removeAttribute("aria-setsize");
35522
+ }
35523
+ const tabbable = !this.disabled && !this.noTab && position?.tabbable !== false;
35524
+ this.setAttribute("tabindex", tabbable ? "0" : "-1");
34925
35525
  }
34926
35526
  formValue() {
34927
35527
  return (this.type === "checkbox" || this.type === "radio") && this.selected ? this.value : null;
@@ -34932,6 +35532,28 @@ var NLDDToggleButton = (_NLDDToggleButton = class NLDDToggleButton extends Descr
34932
35532
  formStateRestoreCallback(state) {
34933
35533
  this.selected = state !== null;
34934
35534
  }
35535
+ connectedCallback() {
35536
+ super.connectedCallback();
35537
+ this.addEventListener("click", this._onRadioClick);
35538
+ this.addEventListener("keydown", this._onRadioKeyDown);
35539
+ this.addEventListener("focus", this._onRadioFocus);
35540
+ this.addEventListener("blur", this._onRadioBlur);
35541
+ }
35542
+ disconnectedCallback() {
35543
+ super.disconnectedCallback();
35544
+ this.removeEventListener("click", this._onRadioClick);
35545
+ this.removeEventListener("keydown", this._onRadioKeyDown);
35546
+ this.removeEventListener("focus", this._onRadioFocus);
35547
+ this.removeEventListener("blur", this._onRadioBlur);
35548
+ }
35549
+ /**
35550
+ * The tooltip of an icon-only button, which shows itself when focus moves
35551
+ * into it. In radio mode the focus is on this element, outside it, so it
35552
+ * hears about focus and Escape from here.
35553
+ */
35554
+ get _tooltip() {
35555
+ return this.type === "radio" ? this.shadowRoot?.querySelector("nldd-tooltip") ?? null : null;
35556
+ }
34935
35557
  _handleButtonClick() {
34936
35558
  if (this.disabled) return;
34937
35559
  this._toggle();
@@ -34972,6 +35594,10 @@ var NLDDToggleButton = (_NLDDToggleButton = class NLDDToggleButton extends Descr
34972
35594
  * reaching into shadow DOM.
34973
35595
  */
34974
35596
  focus(options) {
35597
+ if (this.type === "radio") {
35598
+ super.focus(options);
35599
+ return;
35600
+ }
34975
35601
  this.shadowRoot?.querySelector(".toggle-button__input, button.toggle-button")?.focus(options);
34976
35602
  }
34977
35603
  /** The button is the control, not the input that carries the value. */
@@ -35029,6 +35655,8 @@ __decorate([property({
35029
35655
  type: Boolean,
35030
35656
  reflect: true
35031
35657
  })], NLDDToggleButton.prototype, "invalid", void 0);
35658
+ __decorate([state()], NLDDToggleButton.prototype, "_groupPosition", void 0);
35659
+ __decorate([state()], NLDDToggleButton.prototype, "_groupHasSelection", void 0);
35032
35660
  NLDDToggleButton = __decorate([customElement("nldd-toggle-button")], NLDDToggleButton);
35033
35661
  //#endregion
35034
35662
  //#region src/components/inputs/toggle-button-group/toggle-button-group.styles.ts
@@ -35125,6 +35753,7 @@ var NLDDToggleButtonGroup = (_NLDDToggleButtonGroup = class NLDDToggleButtonGrou
35125
35753
  button.selected = false;
35126
35754
  button.commitFormValue?.();
35127
35755
  });
35756
+ this._positionButtons();
35128
35757
  };
35129
35758
  this._handleKeyDown = (e) => {
35130
35759
  if (this.type !== "radio") return;
@@ -35147,7 +35776,8 @@ var NLDDToggleButtonGroup = (_NLDDToggleButtonGroup = class NLDDToggleButtonGrou
35147
35776
  }
35148
35777
  nextButton.selected = true;
35149
35778
  nextButton.commitFormValue?.();
35150
- (nextButton.shadowRoot?.querySelector(".toggle-button__input"))?.focus();
35779
+ this._positionButtons();
35780
+ nextButton.focus();
35151
35781
  nextButton.dispatchEvent(new CustomEvent("change", {
35152
35782
  detail: {
35153
35783
  selected: true,
@@ -35210,6 +35840,24 @@ var NLDDToggleButtonGroup = (_NLDDToggleButtonGroup = class NLDDToggleButtonGrou
35210
35840
  button.disabled = false;
35211
35841
  }
35212
35842
  });
35843
+ this._positionButtons();
35844
+ }
35845
+ /** In radio mode, each button its place in the group and the group one stop
35846
+ * for Tab. Every button renders its input in a shadow root of its own, so
35847
+ * the browser would count each as a group of one. */
35848
+ _positionButtons() {
35849
+ const buttons = this._getButtons();
35850
+ if (this.type !== "radio") {
35851
+ buttons.forEach((button) => {
35852
+ button._groupPosition = null;
35853
+ });
35854
+ return;
35855
+ }
35856
+ const answered = buttons.some((button) => button.selected);
35857
+ radioPositions(buttons, (button) => button.selected, (button) => button.disabled).forEach((position, index) => {
35858
+ buttons[index]._groupPosition = position;
35859
+ buttons[index]._groupHasSelection = answered;
35860
+ });
35213
35861
  }
35214
35862
  /**
35215
35863
  * Delegates focus to the first selected button, or to the first enabled one
@@ -38398,6 +39046,7 @@ var pageStyles = css`
38398
39046
  --_outer-inset-bottom: var(--context-inset-bottom, 0px);
38399
39047
  --_header-height: 0px;
38400
39048
  --_footer-height: 0px;
39049
+ --_header-full-height: 0px;
38401
39050
  /* Set from JS while the page owns the scroller. initial, not a length, so
38402
39051
  the fallback stands when the document scrolls instead. */
38403
39052
  --_scroll-height: initial;
@@ -38551,6 +39200,7 @@ var pageStyles = css`
38551
39200
  :host(:not([data-scroll="root"])) .page__scroll {
38552
39201
  --context-inset-top: var(--_header-height);
38553
39202
  --context-inset-bottom: var(--_footer-height);
39203
+ padding-top: var(--_header-full-height);
38554
39204
  }
38555
39205
 
38556
39206
  :host([sticky-header]) .page__scroll {
@@ -38626,8 +39276,9 @@ function pageTemplate(component) {
38626
39276
  * A page layout with optional sticky header and footer.
38627
39277
  * Without sticky-header, the host is the scroll container and the header
38628
39278
  * is in normal flow. With sticky-header, the header becomes absolute and
38629
- * .page__scroll takes over scrolling. A ResizeObserver on the header
38630
- * sets padding-top on the scroll wrapper (only when not scrolled).
39279
+ * .page__scroll takes over scrolling, padded by the measured header height.
39280
+ * That padding freezes once you scroll, so a collapsing bar cannot drag the
39281
+ * content up under the cursor.
38631
39282
  *
38632
39283
  * In root-scroll mode (--context-scroll-mode: root, derived upstream by
38633
39284
  * nldd-app-view) the page stops owning a scroller: the document scrolls and the
@@ -38665,8 +39316,8 @@ var NLDDPage = (_NLDDPage = class NLDDPage extends LitElement {
38665
39316
  this._scrollMode = "nested";
38666
39317
  this._scrollTarget = null;
38667
39318
  this._scrollProvider = null;
38668
- this._headerObserver = null;
38669
39319
  this._insetObserver = null;
39320
+ this._headerFullHeight = 0;
38670
39321
  this._mainSlot = null;
38671
39322
  this._resizeRaf = 0;
38672
39323
  this._onResize = () => {
@@ -38741,7 +39392,6 @@ var NLDDPage = (_NLDDPage = class NLDDPage extends LitElement {
38741
39392
  this._scrollProvider = null;
38742
39393
  if (this._resizeRaf) cancelAnimationFrame(this._resizeRaf);
38743
39394
  this._teardownScrollListener();
38744
- this._teardownHeaderObserver();
38745
39395
  this._teardownInsetObserver();
38746
39396
  this._teardownMainSlotListener();
38747
39397
  }
@@ -38769,10 +39419,8 @@ var NLDDPage = (_NLDDPage = class NLDDPage extends LitElement {
38769
39419
  }
38770
39420
  _configureScroll() {
38771
39421
  this._teardownScrollListener();
38772
- this._teardownHeaderObserver();
38773
- if (this._scrollEl) this._scrollEl.style.paddingTop = "";
38774
39422
  this._setupScrollListener();
38775
- if (this.stickyHeader && !this._isRoot) this._setupHeaderObserver();
39423
+ this._headerFullHeight = 0;
38776
39424
  this._setupInsetObserver();
38777
39425
  this._onScroll();
38778
39426
  }
@@ -38784,6 +39432,11 @@ var NLDDPage = (_NLDDPage = class NLDDPage extends LitElement {
38784
39432
  * its anchor, and a consumer that had written the number down would be wrong
38785
39433
  * from that moment on. A bar that is not sticky scrolls away and adds
38786
39434
  * nothing, so it stays at zero.
39435
+ *
39436
+ * It also measures the padding the scroll wrapper reserves for an absolute
39437
+ * sticky header. One observer owns both, because two of them writing styles
39438
+ * that resize what the other watches is what leaves ResizeObserver
39439
+ * notifications undelivered.
38787
39440
  */
38788
39441
  _setupInsetObserver() {
38789
39442
  this._teardownInsetObserver();
@@ -38791,10 +39444,11 @@ var NLDDPage = (_NLDDPage = class NLDDPage extends LitElement {
38791
39444
  const footer = this._footerEl;
38792
39445
  if (!header || !footer) return;
38793
39446
  const publish = () => {
38794
- this.style.setProperty("--_header-height", `${this.stickyHeader ? header.offsetHeight : 0}px`);
38795
- this.style.setProperty("--_footer-height", `${this.stickyFooter ? footer.offsetHeight : 0}px`);
39447
+ this._publish("--_header-height", `${this.stickyHeader ? header.offsetHeight : 0}px`);
39448
+ this._publish("--_footer-height", `${this.stickyFooter ? footer.offsetHeight : 0}px`);
39449
+ this._publish("--_header-full-height", `${this._measureHeaderFullHeight(header)}px`);
38796
39450
  if (this._isRoot) this.style.removeProperty("--_scroll-height");
38797
- else this.style.setProperty("--_scroll-height", `${this._scrollEl?.clientHeight ?? 0}px`);
39451
+ else this._publish("--_scroll-height", `${this._scrollEl?.clientHeight ?? 0}px`);
38798
39452
  };
38799
39453
  publish();
38800
39454
  this._insetObserver = new ResizeObserver(publish);
@@ -38808,6 +39462,27 @@ var NLDDPage = (_NLDDPage = class NLDDPage extends LitElement {
38808
39462
  this._insetObserver = null;
38809
39463
  }
38810
39464
  }
39465
+ /**
39466
+ * Writes a published variable only when it changes. These values size the
39467
+ * elements the observer watches, so a rewrite feeds the callback its own
39468
+ * next notification, which is what Chromium reports as "ResizeObserver
39469
+ * loop completed with undelivered notifications".
39470
+ */
39471
+ _publish(name, value) {
39472
+ if (this.style.getPropertyValue(name) === value) return;
39473
+ this.style.setProperty(name, value);
39474
+ }
39475
+ /**
39476
+ * The header at its full height: the last measurement taken at scroll top.
39477
+ * A top title bar shrinks as you pass its anchor, and the space reserved for
39478
+ * it must not shrink along, or the content it starts under is dragged up
39479
+ * from under the cursor. Which mode reserves that space is the CSS's call.
39480
+ */
39481
+ _measureHeaderFullHeight(header) {
39482
+ if (!this.stickyHeader) return 0;
39483
+ if (this.scrollTarget.scrollTop === 0) this._headerFullHeight = header.offsetHeight;
39484
+ return this._headerFullHeight;
39485
+ }
38811
39486
  _setupScrollListener() {
38812
39487
  const target = this.scrollEventTarget;
38813
39488
  target.addEventListener("scroll", this._onScroll, { passive: true });
@@ -38819,23 +39494,6 @@ var NLDDPage = (_NLDDPage = class NLDDPage extends LitElement {
38819
39494
  this._scrollTarget = null;
38820
39495
  }
38821
39496
  }
38822
- _setupHeaderObserver() {
38823
- this._teardownHeaderObserver();
38824
- const header = this._headerEl;
38825
- const scroll = this._scrollEl;
38826
- if (!header || !scroll) return;
38827
- this._headerObserver = new ResizeObserver(() => {
38828
- if (scroll.scrollTop > 0) return;
38829
- scroll.style.paddingTop = `${header.offsetHeight}px`;
38830
- });
38831
- this._headerObserver.observe(header);
38832
- }
38833
- _teardownHeaderObserver() {
38834
- if (this._headerObserver) {
38835
- this._headerObserver.disconnect();
38836
- this._headerObserver = null;
38837
- }
38838
- }
38839
39497
  _setupMainSlotListener() {
38840
39498
  this._teardownMainSlotListener();
38841
39499
  const slot = this.shadowRoot?.querySelector("slot:not([name])") ?? null;
@@ -41155,6 +41813,9 @@ function sheetTemplate(component) {
41155
41813
  <div class="sheet__body">
41156
41814
  <slot></slot>
41157
41815
  </div>
41816
+ <!-- Inside the dialog, so it escapes the inertness a modal imposes on
41817
+ everything outside it. Plumbing, not consumer API. -->
41818
+ <slot name="notifications"></slot>
41158
41819
  </dialog>
41159
41820
  `;
41160
41821
  }
@@ -42611,6 +43272,9 @@ function windowTemplate(component) {
42611
43272
  <div class="window__body">
42612
43273
  <slot></slot>
42613
43274
  </div>
43275
+ <!-- Inside the dialog, so it escapes the inertness a modal imposes on
43276
+ everything outside it. Plumbing, not consumer API. -->
43277
+ <slot name="notifications"></slot>
42614
43278
  </dialog>
42615
43279
  `;
42616
43280
  }
@@ -45997,8 +46661,10 @@ var NLDDMenuBar = (_NLDDMenuBar = class NLDDMenuBar extends withTranslations(Lit
45997
46661
  }
45998
46662
  /**
45999
46663
  * Calculate which slotted items overflow and hide them behind an overflow button.
46000
- * Note: not unit-tested — JSDOM lacks layout support (offsetWidth, clientWidth).
46001
- * Covered by visual/E2E testing via Storybook stories.
46664
+ * Tested both ways in menu-bar.test.ts: with real layout for the whole path, and
46665
+ * with mocked widths for the arithmetic. The space reserved for the button needs
46666
+ * the mocks — the button is a shrinkable flex item, so the rendered layout
46667
+ * absorbs whatever the arithmetic leaves over and always looks right.
46002
46668
  */
46003
46669
  _updateOverflow() {
46004
46670
  const overflowButton = this._overflowButton;
@@ -49992,6 +50658,9 @@ function modalDialogTemplate(component) {
49992
50658
  <slot></slot>
49993
50659
  <slot slot="actions" name="actions"></slot>
49994
50660
  </nldd-inline-dialog>
50661
+ <!-- Inside the dialog, so it escapes the inertness a modal imposes on
50662
+ everything outside it. Plumbing, not consumer API. -->
50663
+ <slot name="notifications"></slot>
49995
50664
  </dialog>
49996
50665
  `;
49997
50666
  }
@@ -50617,6 +51286,12 @@ var notificationStyles = css`
50617
51286
  display: none;
50618
51287
  }
50619
51288
 
51289
+ /* Off for good once it has arrived: a move between overlays reinserts the
51290
+ element, and the browser would play the arrival again. */
51291
+ :host([data-arrived]) {
51292
+ animation: none;
51293
+ }
51294
+
50620
51295
  @media (prefers-reduced-motion: reduce) {
50621
51296
  :host {
50622
51297
  transition: none;
@@ -50793,11 +51468,12 @@ function notificationTemplate(component) {
50793
51468
  * ## Opening it
50794
51469
  * Under the front notification sits a strip as wide as the deck and as tall as
50795
51470
  * the deck is when it fans out. That strip is the handle: pointing at it fans
50796
- * the deck out to fill it, clicking it lays the deck out as a list. Moving
50797
- * focus into the region does the same, and clicking or tabbing away puts it
50798
- * back. Nothing counts down while the list is open, because open means someone
50799
- * is reading. The notification itself is not a button, so a click on the
50800
- * message you are reading does nothing, which is what it says it does.
51471
+ * the deck out to fill it, clicking it lays the deck out as a list. Tabbing
51472
+ * into the region does the same, and clicking outside it or tabbing away puts
51473
+ * it back. Dismissing one keeps the list open for the rest. Nothing counts down
51474
+ * while the list is open, because open means someone is reading. The
51475
+ * notification itself is not a button, so a click on the message you are
51476
+ * reading does nothing, which is what it says it does.
50801
51477
  */
50802
51478
  var REGION_ID = "nldd-notification-region";
50803
51479
  /** Past this a notification adds no edge of its own: it would sit exactly
@@ -50806,6 +51482,78 @@ var REGION_ID = "nldd-notification-region";
50806
51482
  var MAX_DEPTH = 2;
50807
51483
  var frontObserver = null;
50808
51484
  var expanded = false;
51485
+ /**
51486
+ * A modal overlay paints in the browser's top layer, above the whole document
51487
+ * and out of reach of any z-index, and while one is open everything outside it
51488
+ * is inert: a region left on the body is not merely behind the sheet, it is
51489
+ * unclickable even once you put it in the top layer yourself. So the region
51490
+ * travels. It lives in the topmost open overlay and sinks back as they close,
51491
+ * which is also what makes a notification raised before the sheet opened come
51492
+ * along rather than stay behind it.
51493
+ *
51494
+ * Innermost last. Entries are verified on read rather than trusted, so an
51495
+ * overlay torn out of the DOM without closing cannot strand the region.
51496
+ */
51497
+ var overlays = [];
51498
+ var overlayWatch = null;
51499
+ /** Open as a modal, and with a slot to take the region in. A modal without one
51500
+ * would swallow the region, which is worse than leaving it behind. */
51501
+ function isOpenOverlay(el) {
51502
+ const root = el.shadowRoot;
51503
+ if (!root?.querySelector("slot[name=\"notifications\"]")) return false;
51504
+ return root.querySelector("dialog")?.matches(":modal") ?? false;
51505
+ }
51506
+ function regionHost() {
51507
+ while (overlays.length) {
51508
+ const top = overlays[overlays.length - 1];
51509
+ if (top.isConnected && isOpenOverlay(top)) return top;
51510
+ overlays.pop();
51511
+ }
51512
+ return document.body;
51513
+ }
51514
+ function placeRegion(region) {
51515
+ const host = regionHost();
51516
+ if (region.parentElement === host) return;
51517
+ const inOverlay = host !== document.body;
51518
+ if (inOverlay) region.setAttribute("slot", "notifications");
51519
+ else region.removeAttribute("slot");
51520
+ if (inOverlay) region.setAttribute("popover", "manual");
51521
+ else region.removeAttribute("popover");
51522
+ const carried = notifications(region);
51523
+ carried.forEach((item) => item._setMoving?.(true));
51524
+ host.appendChild(region);
51525
+ if (inOverlay) region.showPopover();
51526
+ carried.forEach((item) => item._setMoving?.(false));
51527
+ }
51528
+ function relocateRegion() {
51529
+ const region = document.getElementById(REGION_ID);
51530
+ if (region) placeRegion(region);
51531
+ }
51532
+ function watchOverlays() {
51533
+ if (overlayWatch) return;
51534
+ overlayWatch = new AbortController();
51535
+ const { signal } = overlayWatch;
51536
+ document.querySelectorAll("*").forEach((el) => {
51537
+ if (isOpenOverlay(el)) overlays.push(el);
51538
+ });
51539
+ document.addEventListener("open", (event) => {
51540
+ const host = event.target;
51541
+ if (!host || !isOpenOverlay(host)) return;
51542
+ if (!overlays.includes(host)) overlays.push(host);
51543
+ relocateRegion();
51544
+ }, { signal });
51545
+ document.addEventListener("close", (event) => {
51546
+ const host = event.target;
51547
+ const at = host ? overlays.indexOf(host) : -1;
51548
+ if (at === -1) return;
51549
+ overlays.splice(at, 1);
51550
+ relocateRegion();
51551
+ }, {
51552
+ capture: true,
51553
+ signal
51554
+ });
51555
+ }
51556
+ if (typeof document !== "undefined") watchOverlays();
50809
51557
  function notifications(region) {
50810
51558
  return Array.from(region.querySelectorAll(":scope > nldd-notification"));
50811
51559
  }
@@ -50823,22 +51571,33 @@ function ensureRegion(label) {
50823
51571
  "z-index: 1000",
50824
51572
  "top: max(var(--semantics-overlays-inset), env(safe-area-inset-top))",
50825
51573
  "right: max(var(--semantics-overlays-inset), env(safe-area-inset-right))",
51574
+ "bottom: auto",
50826
51575
  "left: auto",
50827
51576
  "display: grid",
51577
+ "align-content: start",
50828
51578
  "align-items: start",
51579
+ "margin: 0",
51580
+ "border: none",
51581
+ "background: none",
50829
51582
  "pointer-events: none",
50830
- "max-height: calc(100dvh - 2 * var(--semantics-overlays-inset))"
51583
+ "max-height: calc(100dvh - 2 * var(--semantics-overlays-inset))",
51584
+ "overflow: visible",
51585
+ "padding: 0",
51586
+ "color: inherit"
50831
51587
  ].join(";");
50832
51588
  region.addEventListener("pointerdown", () => {}, { passive: true });
50833
- region.addEventListener("focusin", () => setExpanded(region, true));
51589
+ region.addEventListener("focusin", () => {
51590
+ if (isKeyboardMode()) setExpanded(region, true);
51591
+ });
50834
51592
  region.addEventListener("focusout", (e) => {
50835
- if (region.contains(e.relatedTarget)) return;
51593
+ const next = e.relatedTarget;
51594
+ if (!next || !isKeyboardMode() || region.contains(next)) return;
50836
51595
  setExpanded(region, false);
50837
51596
  });
50838
51597
  region.setAttribute("role", "region");
50839
51598
  region.setAttribute("aria-label", label);
50840
51599
  region.appendChild(makeExpander(region));
50841
- document.body.appendChild(region);
51600
+ placeRegion(region);
50842
51601
  return region;
50843
51602
  }
50844
51603
  /**
@@ -50846,19 +51605,22 @@ function ensureRegion(label) {
50846
51605
  * behind it, which on their own are a few pixels tall and no target at all, and
50847
51606
  * it stays exactly as tall as the deck is when it fans out, so what you point
50848
51607
  * at is what you get.
51608
+ *
51609
+ * A second row of the region's grid rather than hung below the region, so it
51610
+ * follows the deck and not the region's edge. A region taller than its deck,
51611
+ * as Safari made it inside an overlay while the rows still stretched, would
51612
+ * otherwise put the strip far from the deck it opens.
50849
51613
  */
50850
51614
  function makeExpander(region) {
50851
51615
  const strip = document.createElement("div");
50852
51616
  strip.dataset.expander = "";
50853
51617
  strip.style.cssText = [
50854
- "position: absolute",
50855
- "z-index: 1000",
50856
- "top: 100%",
50857
- "right: 0",
50858
- "left: 0",
50859
51618
  "display: none",
50860
- "height: var(--primitives-space-24)",
50861
- "pointer-events: auto"
51619
+ "position: relative",
51620
+ "grid-area: 2 / 1",
51621
+ "z-index: 1000",
51622
+ "pointer-events: auto",
51623
+ "height: var(--primitives-space-24)"
50862
51624
  ].join(";");
50863
51625
  strip.addEventListener("pointerenter", () => setFanned(region, true));
50864
51626
  strip.addEventListener("pointerleave", () => setFanned(region, false));
@@ -50868,7 +51630,9 @@ function makeExpander(region) {
50868
51630
  /** Newest in front: what just happened is what you want to read, and the older
50869
51631
  * ones slide back and downwards behind it. */
50870
51632
  function joinRegion(notification, label) {
51633
+ watchOverlays();
50871
51634
  const region = ensureRegion(label);
51635
+ placeRegion(region);
50872
51636
  notification.style.pointerEvents = "auto";
50873
51637
  region.prepend(notification);
50874
51638
  syncStack(region);
@@ -50936,10 +51700,10 @@ function applyOverflow(region) {
50936
51700
  function setScrolling(region, on) {
50937
51701
  const inset = "var(--semantics-overlays-inset)";
50938
51702
  const shadow = "var(--primitives-space-48)";
50939
- region.style.overflow = on ? "auto" : "";
51703
+ region.style.overflow = on ? "auto" : "visible";
50940
51704
  region.style.overscrollBehavior = on ? "contain" : "";
50941
- region.style.padding = on ? `${inset} ${inset} ${shadow} ${shadow}` : "";
50942
- region.style.margin = on ? `calc(-1 * ${inset}) calc(-1 * ${inset}) 0 calc(-1 * ${shadow})` : "";
51705
+ region.style.padding = on ? `${inset} ${inset} ${shadow} ${shadow}` : "0";
51706
+ region.style.margin = on ? `calc(-1 * ${inset}) calc(-1 * ${inset}) 0 calc(-1 * ${shadow})` : "0";
50943
51707
  region.style.maxHeight = on ? "100dvh" : `calc(100dvh - 2 * ${inset})`;
50944
51708
  }
50945
51709
  function setExpanded(region, next) {
@@ -51003,6 +51767,13 @@ var nlddNotificationTranslations = {
51003
51767
  * Nothing about the position is settable, so notifications from anywhere in an
51004
51768
  * application land in the same place and stack in the same order.
51005
51769
  *
51770
+ * A modal overlay is the one thing that moves it. An nldd-sheet, nldd-window or
51771
+ * nldd-modal-dialog paints in the browser's top layer, above the whole page and
51772
+ * out of reach of any z-index, and while one is open everything outside it is
51773
+ * inert. So the region goes in: into the topmost overlay that is open, and back
51774
+ * down as they close. A notification already on screen travels with it, and one
51775
+ * raised from inside a sheet is readable and reachable where it is raised.
51776
+ *
51006
51777
  * ## The stack
51007
51778
  * More than one is a deck, not a list: the front one is readable and the older
51008
51779
  * ones peek out below it, so a burst of messages takes the room of roughly one.
@@ -51087,6 +51858,10 @@ var NLDDNotification = (_NLDDNotification = class NLDDNotification extends withT
51087
51858
  this._pointerInside = false;
51088
51859
  this._resumeTimer();
51089
51860
  };
51861
+ this._onAnimationEnd = (e) => {
51862
+ if (e.target !== this || e.animationName !== "notification-arrive") return;
51863
+ this.toggleAttribute("data-arrived", true);
51864
+ };
51090
51865
  this._onFocusIn = () => {
51091
51866
  this._focusInside = true;
51092
51867
  this._clearTimer();
@@ -51107,6 +51882,7 @@ var NLDDNotification = (_NLDDNotification = class NLDDNotification extends withT
51107
51882
  };
51108
51883
  this._onKeyDown = (e) => {
51109
51884
  if (e.key !== "Escape") return;
51885
+ e.preventDefault();
51110
51886
  e.stopPropagation();
51111
51887
  this._dismiss();
51112
51888
  };
@@ -51130,6 +51906,7 @@ var NLDDNotification = (_NLDDNotification = class NLDDNotification extends withT
51130
51906
  this.addEventListener("focusin", this._onFocusIn);
51131
51907
  this.addEventListener("focusout", this._onFocusOut);
51132
51908
  this.addEventListener("keydown", this._onKeyDown);
51909
+ this.addEventListener("animationend", this._onAnimationEnd);
51133
51910
  queueMicrotask(() => {
51134
51911
  if (!this.isConnected || this._moving) return;
51135
51912
  const region = document.getElementById("nldd-notification-region");
@@ -51148,6 +51925,8 @@ var NLDDNotification = (_NLDDNotification = class NLDDNotification extends withT
51148
51925
  this.removeEventListener("focusin", this._onFocusIn);
51149
51926
  this.removeEventListener("focusout", this._onFocusOut);
51150
51927
  this.removeEventListener("keydown", this._onKeyDown);
51928
+ this.removeEventListener("animationend", this._onAnimationEnd);
51929
+ this.removeAttribute("data-arrived");
51151
51930
  leaveRegion(this);
51152
51931
  }
51153
51932
  willUpdate(changed) {
@@ -51155,6 +51934,16 @@ var NLDDNotification = (_NLDDNotification = class NLDDNotification extends withT
51155
51934
  if (changed.has("variant")) this.setAttribute("role", this.variant === "critical" ? "alert" : "status");
51156
51935
  if (changed.has("duration")) this._remainingDuration = this.duration;
51157
51936
  }
51937
+ /** Called by the region while it carries this notification to another
51938
+ * parent. A move disconnects the element, and without this the
51939
+ * notification would read its own disconnect as having been dismissed.
51940
+ * It also counts as having arrived: the browser starts a CSS animation over
51941
+ * when its element comes back, and one caught halfway in would slide in
51942
+ * from off the screen a second time. */
51943
+ _setMoving(moving) {
51944
+ this._moving = moving;
51945
+ if (moving) this.toggleAttribute("data-arrived", true);
51946
+ }
51158
51947
  /** Called by the region. Starting the clock here rather than on connect is
51159
51948
  * the whole point: a notification three deep in the deck is not counting. */
51160
51949
  _setFront(isFront) {