@nldd/design-system 0.8.43 → 0.8.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/dist/components/actions/button/button.d.ts +19 -1
  2. package/dist/components/actions/button/button.d.ts.map +1 -1
  3. package/dist/components/actions/button/button.js +25 -1
  4. package/dist/components/actions/button/button.js.map +1 -1
  5. package/dist/components/actions/button/button.styles.js +28 -28
  6. package/dist/components/actions/button/button.template.d.ts.map +1 -1
  7. package/dist/components/actions/button/button.template.js +7 -0
  8. package/dist/components/actions/button/button.template.js.map +1 -1
  9. package/dist/components/actions/button-bar/button-bar.styles.js +4 -4
  10. package/dist/components/actions/button-group/button-group.styles.js +1 -1
  11. package/dist/components/actions/icon-button/icon-button.d.ts +28 -7
  12. package/dist/components/actions/icon-button/icon-button.d.ts.map +1 -1
  13. package/dist/components/actions/icon-button/icon-button.js +36 -9
  14. package/dist/components/actions/icon-button/icon-button.js.map +1 -1
  15. package/dist/components/actions/icon-button/icon-button.styles.js +25 -25
  16. package/dist/components/actions/icon-button/icon-button.template.d.ts.map +1 -1
  17. package/dist/components/actions/icon-button/icon-button.template.js +9 -2
  18. package/dist/components/actions/icon-button/icon-button.template.js.map +1 -1
  19. package/dist/components/actions/split-button/split-button.d.ts +5 -5
  20. package/dist/components/actions/split-button/split-button.d.ts.map +1 -1
  21. package/dist/components/actions/split-button/split-button.js +19 -15
  22. package/dist/components/actions/split-button/split-button.js.map +1 -1
  23. package/dist/components/actions/split-button/split-button.styles.js +1 -1
  24. package/dist/components/actions/split-button/split-button.template.d.ts.map +1 -1
  25. package/dist/components/actions/split-button/split-button.template.js +14 -12
  26. package/dist/components/actions/split-button/split-button.template.js.map +1 -1
  27. package/dist/components/actions/toolbar/toolbar.d.ts +0 -2
  28. package/dist/components/actions/toolbar/toolbar.d.ts.map +1 -1
  29. package/dist/components/actions/toolbar/toolbar.js +20 -17
  30. package/dist/components/actions/toolbar/toolbar.js.map +1 -1
  31. package/dist/components/actions/toolbar/toolbar.styles.js +17 -17
  32. package/dist/components/actions/toolbar/toolbar.template.d.ts +1 -1
  33. package/dist/components/actions/toolbar/toolbar.template.d.ts.map +1 -1
  34. package/dist/components/actions/toolbar/toolbar.template.js +13 -5
  35. package/dist/components/actions/toolbar/toolbar.template.js.map +1 -1
  36. package/dist/components/components.js +1 -1
  37. package/dist/components/content/blockquote/blockquote.styles.js +5 -5
  38. package/dist/components/content/code/code.styles.js +3 -3
  39. package/dist/components/content/icon/icon-aliases.d.ts.map +1 -1
  40. package/dist/components/content/icon/icon-aliases.js +15 -0
  41. package/dist/components/content/icon/icon-aliases.js.map +1 -1
  42. package/dist/components/content/icon/icon-registry.d.ts.map +1 -1
  43. package/dist/components/content/icon/icon-registry.js +11 -5
  44. package/dist/components/content/icon/icon-registry.js.map +1 -1
  45. package/dist/components/content/icon/icon.styles.js +1 -1
  46. package/dist/components/content/keyboard-shortcut/keyboard-shortcut.d.ts +22 -0
  47. package/dist/components/content/keyboard-shortcut/keyboard-shortcut.d.ts.map +1 -1
  48. package/dist/components/content/keyboard-shortcut/keyboard-shortcut.js +46 -2
  49. package/dist/components/content/keyboard-shortcut/keyboard-shortcut.js.map +1 -1
  50. package/dist/components/content/keyboard-shortcut/keyboard-shortcut.styles.js +5 -5
  51. package/dist/components/content/rich-text/rich-text.d.ts +3 -1
  52. package/dist/components/content/rich-text/rich-text.d.ts.map +1 -1
  53. package/dist/components/content/rich-text/rich-text.js +6 -1
  54. package/dist/components/content/rich-text/rich-text.js.map +1 -1
  55. package/dist/components/content/tag/tag.styles.js +6 -6
  56. package/dist/components/content/title/title.styles.js +2 -2
  57. package/dist/components/content/tooltip/tooltip.styles.js +8 -8
  58. package/dist/components/forms/form-actions/form-actions.styles.js +2 -2
  59. package/dist/components/forms/form-field/form-field.styles.js +6 -6
  60. package/dist/components/{index-By2jYHEw.js → index-1v33GjGX.js} +2950 -2632
  61. package/dist/components/index.d.ts +1 -1
  62. package/dist/components/index.d.ts.map +1 -1
  63. package/dist/components/index.js +1 -1
  64. package/dist/components/index.js.map +1 -1
  65. package/dist/components/inputs/checkbox/checkbox.styles.js +9 -9
  66. package/dist/components/inputs/checkbox-field/checkbox-field.styles.js +4 -4
  67. package/dist/components/inputs/code-editor/code-editor.styles.js +14 -14
  68. package/dist/components/inputs/combo-box/combo-box.styles.d.ts.map +1 -1
  69. package/dist/components/inputs/combo-box/combo-box.styles.js +15 -14
  70. package/dist/components/inputs/combo-box/combo-box.styles.js.map +1 -1
  71. package/dist/components/inputs/combo-box/combo-box.template.js +2 -2
  72. package/dist/components/inputs/dropdown/dropdown.d.ts +7 -5
  73. package/dist/components/inputs/dropdown/dropdown.d.ts.map +1 -1
  74. package/dist/components/inputs/dropdown/dropdown.js +11 -9
  75. package/dist/components/inputs/dropdown/dropdown.js.map +1 -1
  76. package/dist/components/inputs/dropdown/dropdown.styles.d.ts.map +1 -1
  77. package/dist/components/inputs/dropdown/dropdown.styles.js +23 -17
  78. package/dist/components/inputs/dropdown/dropdown.styles.js.map +1 -1
  79. package/dist/components/inputs/multi-line-text-field/multi-line-text-field.styles.d.ts.map +1 -1
  80. package/dist/components/inputs/multi-line-text-field/multi-line-text-field.styles.js +12 -11
  81. package/dist/components/inputs/multi-line-text-field/multi-line-text-field.styles.js.map +1 -1
  82. package/dist/components/inputs/number-field/number-field.styles.d.ts.map +1 -1
  83. package/dist/components/inputs/number-field/number-field.styles.js +16 -15
  84. package/dist/components/inputs/number-field/number-field.styles.js.map +1 -1
  85. package/dist/components/inputs/number-field/number-field.template.js +2 -2
  86. package/dist/components/inputs/password-field/password-field.d.ts +8 -8
  87. package/dist/components/inputs/password-field/password-field.d.ts.map +1 -1
  88. package/dist/components/inputs/password-field/password-field.js +16 -16
  89. package/dist/components/inputs/password-field/password-field.js.map +1 -1
  90. package/dist/components/inputs/password-field/password-field.styles.d.ts.map +1 -1
  91. package/dist/components/inputs/password-field/password-field.styles.js +18 -17
  92. package/dist/components/inputs/password-field/password-field.styles.js.map +1 -1
  93. package/dist/components/inputs/password-field/password-field.template.js +3 -3
  94. package/dist/components/inputs/password-field/password-field.template.js.map +1 -1
  95. package/dist/components/inputs/radio-button/radio-button.styles.js +11 -11
  96. package/dist/components/inputs/radio-button-field/radio-button-field.styles.js +4 -4
  97. package/dist/components/inputs/search-field/search-field.styles.d.ts.map +1 -1
  98. package/dist/components/inputs/search-field/search-field.styles.js +17 -16
  99. package/dist/components/inputs/search-field/search-field.styles.js.map +1 -1
  100. package/dist/components/inputs/segmented-control/segmented-control.styles.d.ts.map +1 -1
  101. package/dist/components/inputs/segmented-control/segmented-control.styles.js +37 -67
  102. package/dist/components/inputs/segmented-control/segmented-control.styles.js.map +1 -1
  103. package/dist/components/inputs/stepper/stepper.styles.js +2 -2
  104. package/dist/components/inputs/stepper/stepper.template.js +2 -2
  105. package/dist/components/inputs/switch/switch.styles.js +13 -13
  106. package/dist/components/inputs/switch-field/switch-field.styles.js +4 -4
  107. package/dist/components/inputs/text-field/text-field.styles.d.ts.map +1 -1
  108. package/dist/components/inputs/text-field/text-field.styles.js +17 -16
  109. package/dist/components/inputs/text-field/text-field.styles.js.map +1 -1
  110. package/dist/components/inputs/toggle-button/toggle-button.styles.js +12 -12
  111. package/dist/components/inputs/token/token.styles.js +5 -5
  112. package/dist/components/inputs/token/token.template.js +1 -1
  113. package/dist/components/layout/app-view/app-view.styles.js +4 -4
  114. package/dist/components/layout/box/box.styles.js +2 -2
  115. package/dist/components/layout/card/card.styles.js +4 -4
  116. package/dist/components/layout/collection/collection.styles.js +6 -6
  117. package/dist/components/layout/divider/divider.styles.js +2 -2
  118. package/dist/components/layout/page/page.styles.js +9 -9
  119. package/dist/components/layout/page-sections/full-bleed-section/full-bleed-section.styles.d.ts.map +1 -1
  120. package/dist/components/layout/page-sections/full-bleed-section/full-bleed-section.styles.js +15 -14
  121. package/dist/components/layout/page-sections/full-bleed-section/full-bleed-section.styles.js.map +1 -1
  122. package/dist/components/layout/page-sections/one-half-one-half-section/one-half-one-half-section.styles.js +6 -6
  123. package/dist/components/layout/page-sections/one-third-two-thirds-section/one-third-two-thirds-section.styles.js +6 -6
  124. package/dist/components/layout/page-sections/simple-section/simple-section.styles.js +14 -14
  125. package/dist/components/layout/page-sections/simple-section/simple-section.styles.js.map +1 -1
  126. package/dist/components/layout/page-sections/two-thirds-one-third-section/two-thirds-one-third-section.styles.js +6 -6
  127. package/dist/components/layout/popover/popover.styles.js +4 -4
  128. package/dist/components/layout/sheet/sheet.d.ts +17 -4
  129. package/dist/components/layout/sheet/sheet.d.ts.map +1 -1
  130. package/dist/components/layout/sheet/sheet.js +40 -6
  131. package/dist/components/layout/sheet/sheet.js.map +1 -1
  132. package/dist/components/layout/sheet/sheet.styles.d.ts.map +1 -1
  133. package/dist/components/layout/sheet/sheet.styles.js +28 -35
  134. package/dist/components/layout/sheet/sheet.styles.js.map +1 -1
  135. package/dist/components/layout/spacer/spacer.styles.js +5 -5
  136. package/dist/components/layout/split-views/bar-split-view/bar-split-view.styles.js +9 -9
  137. package/dist/components/layout/split-views/navigation-split-view/navigation-split-view.styles.js +34 -34
  138. package/dist/components/layout/split-views/side-by-side-split-view/side-by-side-split-view.styles.js +6 -6
  139. package/dist/components/layout/split-views/split-view-divider/split-view-divider.styles.js +4 -4
  140. package/dist/components/layout/split-views/split-view-pane/split-view-pane.styles.js +3 -3
  141. package/dist/components/layout/split-views/stacked-split-view/stacked-split-view.styles.js +7 -7
  142. package/dist/components/layout/window/window.styles.js +8 -8
  143. package/dist/components/lists-and-menus/cells/cell/cell.styles.js +5 -5
  144. package/dist/components/lists-and-menus/cells/description-cell/description-cell.styles.js +5 -5
  145. package/dist/components/lists-and-menus/cells/drag-handle-cell/drag-handle-cell.styles.js +9 -9
  146. package/dist/components/lists-and-menus/cells/spacer-cell/spacer-cell.styles.js +1 -1
  147. package/dist/components/lists-and-menus/cells/text-cell/text-cell.styles.js +6 -6
  148. package/dist/components/lists-and-menus/cells/title-cell/title-cell.styles.js +6 -6
  149. package/dist/components/lists-and-menus/list/list.styles.js +13 -13
  150. package/dist/components/lists-and-menus/list-item/list-item.styles.js +21 -21
  151. package/dist/components/lists-and-menus/menu/menu.d.ts +160 -29
  152. package/dist/components/lists-and-menus/menu/menu.d.ts.map +1 -1
  153. package/dist/components/lists-and-menus/menu/menu.i18n.d.ts +2 -0
  154. package/dist/components/lists-and-menus/menu/menu.i18n.d.ts.map +1 -1
  155. package/dist/components/lists-and-menus/menu/menu.i18n.js +2 -0
  156. package/dist/components/lists-and-menus/menu/menu.i18n.js.map +1 -1
  157. package/dist/components/lists-and-menus/menu/menu.js +534 -102
  158. package/dist/components/lists-and-menus/menu/menu.js.map +1 -1
  159. package/dist/components/lists-and-menus/menu/menu.styles.d.ts.map +1 -1
  160. package/dist/components/lists-and-menus/menu/menu.styles.js +93 -32
  161. package/dist/components/lists-and-menus/menu/menu.styles.js.map +1 -1
  162. package/dist/components/lists-and-menus/menu/menu.template.d.ts.map +1 -1
  163. package/dist/components/lists-and-menus/menu/menu.template.js +13 -0
  164. package/dist/components/lists-and-menus/menu/menu.template.js.map +1 -1
  165. package/dist/components/navigation/document-tab-bar/document-tab-bar.d.ts +0 -2
  166. package/dist/components/navigation/document-tab-bar/document-tab-bar.d.ts.map +1 -1
  167. package/dist/components/navigation/document-tab-bar/document-tab-bar.js +20 -22
  168. package/dist/components/navigation/document-tab-bar/document-tab-bar.js.map +1 -1
  169. package/dist/components/navigation/document-tab-bar/document-tab-bar.styles.js +48 -48
  170. package/dist/components/navigation/document-tab-bar/document-tab-bar.template.d.ts.map +1 -1
  171. package/dist/components/navigation/document-tab-bar/document-tab-bar.template.js +11 -9
  172. package/dist/components/navigation/document-tab-bar/document-tab-bar.template.js.map +1 -1
  173. package/dist/components/navigation/link/link.d.ts.map +1 -0
  174. package/dist/components/navigation/link/link.js.map +1 -0
  175. package/dist/components/navigation/link/link.styles.d.ts.map +1 -0
  176. package/dist/components/{actions → navigation}/link/link.styles.js +4 -4
  177. package/dist/components/navigation/link/link.styles.js.map +1 -0
  178. package/dist/components/navigation/link/link.template.d.ts.map +1 -0
  179. package/dist/components/navigation/link/link.template.js.map +1 -0
  180. package/dist/components/navigation/menu-bar/menu-bar.d.ts +17 -5
  181. package/dist/components/navigation/menu-bar/menu-bar.d.ts.map +1 -1
  182. package/dist/components/navigation/menu-bar/menu-bar.js +79 -34
  183. package/dist/components/navigation/menu-bar/menu-bar.js.map +1 -1
  184. package/dist/components/navigation/menu-bar/menu-bar.styles.js +2 -2
  185. package/dist/components/navigation/menu-bar/menu-bar.template.d.ts.map +1 -1
  186. package/dist/components/navigation/menu-bar/menu-bar.template.js +12 -2
  187. package/dist/components/navigation/menu-bar/menu-bar.template.js.map +1 -1
  188. package/dist/components/navigation/menu-bar-item/menu-bar-item.d.ts +16 -0
  189. package/dist/components/navigation/menu-bar-item/menu-bar-item.d.ts.map +1 -1
  190. package/dist/components/navigation/menu-bar-item/menu-bar-item.js +22 -0
  191. package/dist/components/navigation/menu-bar-item/menu-bar-item.js.map +1 -1
  192. package/dist/components/navigation/menu-bar-item/menu-bar-item.styles.js +19 -19
  193. package/dist/components/navigation/menu-bar-item/menu-bar-item.template.d.ts.map +1 -1
  194. package/dist/components/navigation/menu-bar-item/menu-bar-item.template.js +2 -0
  195. package/dist/components/navigation/menu-bar-item/menu-bar-item.template.js.map +1 -1
  196. package/dist/components/navigation/pagination/pagination.d.ts +12 -0
  197. package/dist/components/navigation/pagination/pagination.d.ts.map +1 -1
  198. package/dist/components/navigation/pagination/pagination.js +19 -0
  199. package/dist/components/navigation/pagination/pagination.js.map +1 -1
  200. package/dist/components/navigation/pagination/pagination.styles.d.ts.map +1 -1
  201. package/dist/components/navigation/pagination/pagination.styles.js +47 -77
  202. package/dist/components/navigation/pagination/pagination.styles.js.map +1 -1
  203. package/dist/components/navigation/pagination/pagination.template.d.ts.map +1 -1
  204. package/dist/components/navigation/pagination/pagination.template.js +23 -16
  205. package/dist/components/navigation/pagination/pagination.template.js.map +1 -1
  206. package/dist/components/navigation/skip-link/skip-link.styles.js +9 -9
  207. package/dist/components/navigation/tab-bar/tab-bar.styles.d.ts.map +1 -1
  208. package/dist/components/navigation/tab-bar/tab-bar.styles.js +28 -51
  209. package/dist/components/navigation/tab-bar/tab-bar.styles.js.map +1 -1
  210. package/dist/components/navigation/top-navigation-bar/top-navigation-bar.styles.d.ts.map +1 -1
  211. package/dist/components/navigation/top-navigation-bar/top-navigation-bar.styles.js +57 -70
  212. package/dist/components/navigation/top-navigation-bar/top-navigation-bar.styles.js.map +1 -1
  213. package/dist/components/navigation/top-title-bar/top-title-bar.styles.js +15 -15
  214. package/dist/components/{prism-typescript-BlIir5CV.js → prism-typescript-D2EqsCqV.js} +1 -1
  215. package/dist/components/status-and-feedback/badge/badge.styles.js +7 -7
  216. package/dist/components/status-and-feedback/inline-dialog/inline-dialog.styles.js +7 -7
  217. package/dist/components/status-and-feedback/modal-dialog/modal-dialog.styles.js +5 -5
  218. package/dist/css/rich-text.css +85 -58
  219. package/dist/css/settings.css +36 -33
  220. package/dist/utilities/os.d.ts +27 -0
  221. package/dist/utilities/os.d.ts.map +1 -0
  222. package/dist/utilities/os.js +60 -0
  223. package/dist/utilities/os.js.map +1 -0
  224. package/package.json +5 -5
  225. package/dist/components/actions/link/link.d.ts.map +0 -1
  226. package/dist/components/actions/link/link.js.map +0 -1
  227. package/dist/components/actions/link/link.styles.d.ts.map +0 -1
  228. package/dist/components/actions/link/link.styles.js.map +0 -1
  229. package/dist/components/actions/link/link.template.d.ts.map +0 -1
  230. package/dist/components/actions/link/link.template.js.map +0 -1
  231. /package/dist/components/{actions → navigation}/link/link.d.ts +0 -0
  232. /package/dist/components/{actions → navigation}/link/link.js +0 -0
  233. /package/dist/components/{actions → navigation}/link/link.styles.d.ts +0 -0
  234. /package/dist/components/{actions → navigation}/link/link.template.d.ts +0 -0
  235. /package/dist/components/{actions → navigation}/link/link.template.js +0 -0
@@ -15,7 +15,7 @@ import '../../lists-and-menus/cells/spacer-cell/spacer-cell.js';
15
15
  import '../../lists-and-menus/cells/text-cell/text-cell.js';
16
16
  import '../../content/icon/icon.js';
17
17
  import '../../status-and-feedback/inline-dialog/inline-dialog.js';
18
- import { isKeyboardMode } from '../../../utilities/input-modality.js';
18
+ import { isKeyboardMode, isTouchMode } from '../../../utilities/input-modality.js';
19
19
  import { POPOVER_REOPEN_GUARD_MS } from '../../../utilities/popover-guard.js';
20
20
  import { breakpoints } from '../../../assets/styles/breakpoints.js';
21
21
  // # nldd-menu-divider
@@ -183,12 +183,35 @@ export class NLDDMenuItem extends LitElement {
183
183
  this._submenuMutationObserver?.disconnect();
184
184
  this._submenuMutationObserver = null;
185
185
  }
186
- _handleClick() {
186
+ _handleClick(event) {
187
187
  if (this.disabled)
188
188
  return;
189
189
  // Submenu items don't fire `select` — they open their submenu instead.
190
190
  // Item is either an action OR a submenu opener, not both.
191
191
  if (this._hasSubmenu) {
192
+ // The button is wired to the submenu via the popoverTargetElement
193
+ // IDL property (set in the template). That marks it as the
194
+ // submenu's invoker so the browser excludes the opener from
195
+ // popover light-dismiss — clicking the opener no longer closes
196
+ // the submenu underneath it. The flip side: the browser would
197
+ // also auto-show the popover with its default positioning,
198
+ // which races our anchor + placement set in `_handleSubmenuOpen`.
199
+ // preventDefault stops the browser action and lets our flow
200
+ // own positioning.
201
+ //
202
+ // We use the IDL property rather than the `popovertarget`
203
+ // attribute because the button lives in this menu-item's shadow
204
+ // root while the submenu is a light-DOM child — attribute-based
205
+ // ID resolution is tree-scoped and would silently fail to find
206
+ // the cross-tree target. The IDL property takes a direct element
207
+ // reference and works across shadow boundaries.
208
+ event?.preventDefault();
209
+ // Click on an already-open opener: keep the submenu visible and
210
+ // the opener visually "active" (aria-expanded + :hover). A second
211
+ // click should not toggle it closed — that conflicts with the
212
+ // hover-open semantics of cascade mode.
213
+ if (this._submenuOpen)
214
+ return;
192
215
  this.dispatchEvent(new CustomEvent('submenu-open', {
193
216
  detail: { submenu: this._submenuEl, item: this },
194
217
  bubbles: true,
@@ -331,6 +354,30 @@ export class NLDDMenu extends LitElement {
331
354
  * by one of this menu's items). null when no submenu is open. */
332
355
  this._activeSubmenu = null;
333
356
  this._activeSubmenuOpener = null;
357
+ /** Cleanup closure for the currently open child submenu. Invoked by the
358
+ * submenu's `toggle→closed` event, but also callable directly from a
359
+ * chain-close walk so the upper levels can be torn down without waiting
360
+ * for stale toggle events that will never fire (e.g. a hidden drill-in
361
+ * parent whose popover state is already 'closed'). */
362
+ this._activeSubmenuCleanup = null;
363
+ /** Signal that this menu is hiding itself to make room for a deeper drill-in
364
+ * level — its parent's onToggle should skip the normal close cleanup
365
+ * (re-parent + re-show) so the chain stays in deeper-level state. */
366
+ this._drillInHidingForDeeper = false;
367
+ /** Root-owned registry of every submenu currently open in this chain,
368
+ * in open order. Lives on the root menu only; submenus reach it via
369
+ * `_chainRoot`. Drives `_collapseChain()` so collapsing the whole
370
+ * stack never depends on `_activeSubmenu`/`_parentMenu` threading
371
+ * (which real interaction can leave stale). @internal */
372
+ this._openChain = [];
373
+ /** Back-reference to the chain's root, set on every submenu when it
374
+ * opens. Lets `_collapseChain()` find the registry without walking
375
+ * the (possibly stale) parent links. null on the root. @internal */
376
+ this._chainRoot = null;
377
+ /** Force-teardown for this submenu (detach toggle listener, hide,
378
+ * restore DOM, reset state — no parent re-show). Set when the submenu
379
+ * opens; called by `_collapseChain()`. @internal */
380
+ this._collapseSelf = null;
334
381
  /** When this menu is itself a submenu, points to the parent menu that
335
382
  * opened it. Set by the parent's _handleSubmenuOpen. null on the root.
336
383
  * @internal */
@@ -341,17 +388,60 @@ export class NLDDMenu extends LitElement {
341
388
  this._parentItem = null;
342
389
  this._isOpen = false;
343
390
  this._closedAt = 0;
391
+ /** Set on the root when a pointer gesture (outside/anchor pointerdown
392
+ * or confirmed tap) collapsed the chain. The trailing `click` of that
393
+ * same gesture consumes it in `_handleDocumentClick` and must NOT
394
+ * reopen — gesture-correlated, so it's reliable on touch where the
395
+ * pointerup→click gap exceeds the time-based reopen guard. @internal */
396
+ this._collapsedByPointerGesture = false;
344
397
  this._cleanupAutoUpdate = null;
398
+ // — Status messages (WCAG 4.1.3) ———————————————————————————————————————————
399
+ //
400
+ // Drill-in mode swaps the whole view (parent hides, submenu shows, or
401
+ // vice versa). Focus moves to the new view's first item / opener, so AT
402
+ // announces *an* element — but not that the view itself changed. The
403
+ // polite live region in the shadow DOM (see menu.template) carries that
404
+ // explicit status. Cascade mode keeps both views visible: nothing is
405
+ // swapped, so it never announces.
406
+ /** Double-rAF handle for the announcement awaiting flush, so a rapid
407
+ * follow-up transition can cancel the stale one instead of stacking it. */
408
+ this._announceRaf = 0;
409
+ /** Message currently queued (not yet flushed to the region). Used to
410
+ * drop a doubled event in the same tick while still allowing the same
411
+ * text to be re-announced on a later, genuine transition. */
412
+ this._pendingAnnouncement = '';
345
413
  // — Event handlers ————————————————————————————————————————————————————————
346
414
  this._handleDocumentClick = (event) => {
347
415
  if (this.anchorElement)
348
416
  return;
417
+ // Consume the same-gesture collapse marker (set when an outside or
418
+ // anchor pointer gesture collapsed the chain). Read+clear for every
419
+ // click so it never leaks past a single gesture.
420
+ const collapsedByGesture = this._collapsedByPointerGesture;
421
+ this._collapsedByPointerGesture = false;
349
422
  const anchorEl = this._getAnchorEl();
350
423
  if (!anchorEl)
351
424
  return;
352
425
  const path = event.composedPath();
353
426
  if (!path.includes(anchorEl))
354
427
  return;
428
+ // This click is the tail of the very gesture that just collapsed the
429
+ // chain (e.g. tapping the anchor from deep in a drill-in submenu:
430
+ // the submenu's pointerdown/tap already collapsed). It must not
431
+ // reopen. Gesture-correlated, so reliable even when the
432
+ // pointerup→click gap exceeds the time-based reopen guard on touch.
433
+ if (collapsedByGesture)
434
+ return;
435
+ // Drill-in: the root is hidden while a submenu shows, so `_isOpen`
436
+ // is false on the root even though the menu is visibly open. A
437
+ // plain toggle would `showPopover()` the root — i.e. bounce back
438
+ // to the main level. The anchor is a toggle: if anything in the
439
+ // chain is open, clicking it collapses the whole stack (registry-
440
+ // driven, so it works even if chain links went stale).
441
+ if (this._drillInMode && (this._isOpen || this._openChain.length > 0)) {
442
+ this._collapseChain();
443
+ return;
444
+ }
355
445
  if (this._isOpen) {
356
446
  this.hidePopover();
357
447
  }
@@ -369,10 +459,13 @@ export class NLDDMenu extends LitElement {
369
459
  // right edge if floating-ui flipped it to the other side). The wedge
370
460
  // stays fixed until the cursor enters the submenu, so the user has a
371
461
  // predictable, stable area to traverse without losing the submenu.
372
- // While the cursor is inside the triangle, peer items don't activate
373
- // and the linger-close timer is held off.
462
+ // While the cursor is inside the triangle, peer items don't activate.
463
+ //
464
+ // We deliberately do NOT auto-close on hover-out: moving the cursor to
465
+ // empty space outside every menu leaves the chain open. The native
466
+ // popover light-dismiss takes care of click-outside, which is the only
467
+ // gesture that should collapse the chain.
374
468
  this._hoverOpenTimer = null;
375
- this._hoverCloseTimer = null;
376
469
  this._safeTriangleListener = null;
377
470
  this._safeTriangleStallTimer = null;
378
471
  this._lastCursorPos = null;
@@ -380,6 +473,10 @@ export class NLDDMenu extends LitElement {
380
473
  this._safeTriangleSubmenu = null;
381
474
  this._movingTowardSubmenu = false;
382
475
  this._handleMenuItemMouseenter = (event) => {
476
+ // Touch suppress: synthesised mouseenter from touch (e.g. lifting
477
+ // after a scroll gesture on a long menu) shouldn't paint a highlight.
478
+ if (isTouchMode())
479
+ return;
383
480
  const item = event.target.closest('nldd-menu-item');
384
481
  if (!item)
385
482
  return;
@@ -389,8 +486,9 @@ export class NLDDMenu extends LitElement {
389
486
  if (this.variant !== 'listbox')
390
487
  this._clearHighlight();
391
488
  this._cancelHoverOpen();
392
- // The hover-guard takes over close behaviour while the cursor is
393
- // outside both the parent menu and the active submenu.
489
+ // No auto-close on hover-out: leaving the menu rect leaves the chain
490
+ // open. Click-outside (popover light-dismiss) is the only gesture
491
+ // that collapses the chain.
394
492
  };
395
493
  // — Debug overlay ————————————————————————————————————————————————————
396
494
  this._safeTriangleOverlay = null;
@@ -433,6 +531,13 @@ export class NLDDMenu extends LitElement {
433
531
  }
434
532
  submenu._parentMenu = this;
435
533
  submenu._parentItem = item;
534
+ // Register this submenu in the root's open-chain so `_collapseChain()`
535
+ // can tear the whole stack down without relying on parent links being
536
+ // intact at collapse time. Captured here, while links are fresh.
537
+ const chainRoot = this._rootMenu;
538
+ submenu._chainRoot = chainRoot;
539
+ if (!chainRoot._openChain.includes(submenu))
540
+ chainRoot._openChain.push(submenu);
436
541
  if (this._drillInMode) {
437
542
  // Drill-in: anchor to the root's anchor so all submenus open at the
438
543
  // same screen position — visually stacks. Inherit root placement
@@ -456,18 +561,58 @@ export class NLDDMenu extends LitElement {
456
561
  item.removeAttribute('highlighted');
457
562
  // Listen once for the submenu's close so we can clear state, ARIA and
458
563
  // any leftover safe-triangle plumbing from the cascade open path.
459
- const onToggle = (e) => {
460
- const tg = e;
461
- if (tg.newState !== 'closed')
462
- return;
564
+ const wasDrillIn = this._drillInMode;
565
+ // Shared teardown — runs from `toggle→closed` in the normal flow, and
566
+ // invoked directly from a select-chain-close walk for hidden ancestors
567
+ // whose toggle event will never fire (they were already closed when
568
+ // the deeper level took over the screen). `skipReshow` lets the chain
569
+ // walk suppress the parent-restore step so the whole stack collapses.
570
+ const cleanup = (skipReshow) => {
463
571
  submenu.removeEventListener('toggle', onToggle);
464
572
  if (this._activeSubmenu === submenu) {
465
573
  this._activeSubmenu = null;
466
574
  this._activeSubmenuOpener = null;
575
+ this._activeSubmenuCleanup = null;
467
576
  }
468
577
  submenu._parentMenu = null;
469
578
  submenu._parentItem = null;
579
+ // Deregister from the root's open-chain registry.
580
+ const ci = chainRoot._openChain.indexOf(submenu);
581
+ if (ci !== -1)
582
+ chainRoot._openChain.splice(ci, 1);
583
+ submenu._collapseSelf = null;
584
+ submenu._chainRoot = null;
470
585
  item._submenuOpen = false;
586
+ // Drill-in: restore the submenu to its original DOM position and
587
+ // re-show the parent so the user returns to the previous view.
588
+ // Captured at open-time so a viewport flip during navigation
589
+ // doesn't leave the chain in an inconsistent state.
590
+ if (wasDrillIn) {
591
+ if (drillInOriginalParent && drillInOriginalParent.isConnected) {
592
+ drillInOriginalParent.insertBefore(submenu, drillInNextSibling);
593
+ }
594
+ else {
595
+ // Original parent was removed from the DOM while the
596
+ // submenu was reparented to <body> (SPA navigation,
597
+ // story teardown, etc.). There's nowhere to restore it
598
+ // to — drop the orphaned node instead of leaking a
599
+ // detached-but-connected element in <body> forever.
600
+ submenu.remove();
601
+ }
602
+ if (!skipReshow && this.isConnected && !this.matches(':popover-open')) {
603
+ this.showPopover?.();
604
+ }
605
+ // One level back (back button / ArrowLeft / Esc) — announce the
606
+ // destination view on the now-visible parent. `skipReshow` is a
607
+ // collapse-the-whole-chain teardown (select / outside-tap /
608
+ // resize): that's a dismiss, not a back-nav, so it stays silent.
609
+ if (!skipReshow && this.isConnected) {
610
+ const dest = this._parentItem?.text;
611
+ this._announce(dest
612
+ ? this._t('components.menu.submenu-back-action', { title: dest })
613
+ : this._t('components.menu.back'));
614
+ }
615
+ }
471
616
  // Drop any safe-triangle "in transit" highlight on the opener —
472
617
  // without this, an opener whose submenu was closed via stall-
473
618
  // dismissal or programmatic hidePopover keeps the bold accent
@@ -480,7 +625,6 @@ export class NLDDMenu extends LitElement {
480
625
  if (!item.hasAttribute('data-focused')) {
481
626
  item.removeAttribute('highlighted');
482
627
  }
483
- this._cancelHoverClose();
484
628
  this._stopSafeTriangle();
485
629
  // Counterpart to `submenu-open` — consumers tracking submenu state
486
630
  // via declarative event listeners (rather than reaching into our
@@ -491,12 +635,59 @@ export class NLDDMenu extends LitElement {
491
635
  composed: false,
492
636
  }));
493
637
  };
638
+ const onToggle = (e) => {
639
+ const tg = e;
640
+ if (tg.newState !== 'closed')
641
+ return;
642
+ // Submenu is hiding to make room for a deeper drill — leave the
643
+ // chain state alone, keep the listener around for the eventual
644
+ // real close.
645
+ if (submenu._drillInHidingForDeeper) {
646
+ submenu._drillInHidingForDeeper = false;
647
+ return;
648
+ }
649
+ // A real toggle-close = one level back: tear down and re-show
650
+ // the parent. Collapsing the whole chain doesn't come through
651
+ // here — `_collapseChain()` drives `_collapseSelf` directly.
652
+ cleanup(false);
653
+ };
494
654
  submenu.addEventListener('toggle', onToggle);
655
+ this._activeSubmenuCleanup = cleanup;
656
+ // Force-teardown used by `_collapseChain()`: detach the toggle
657
+ // listener (so this hide doesn't also run the back-nav onToggle),
658
+ // hide, then run the shared cleanup with no parent re-show.
659
+ submenu._collapseSelf = () => {
660
+ submenu.removeEventListener('toggle', onToggle);
661
+ submenu.hidePopover?.();
662
+ cleanup(true);
663
+ };
664
+ // Drill-in: re-parent the submenu to <body> before show so it isn't
665
+ // a popover-stack descendant of the parent — that lets us close the
666
+ // parent without cascading the submenu closed. Original placement is
667
+ // restored when the submenu closes.
668
+ let drillInOriginalParent = null;
669
+ let drillInNextSibling = null;
670
+ if (wasDrillIn) {
671
+ drillInOriginalParent = submenu.parentElement;
672
+ drillInNextSibling = submenu.nextSibling;
673
+ document.body.appendChild(submenu);
674
+ }
495
675
  submenu.showPopover?.();
496
- // Start the hover triangle tracker for cascade-mode submenus only.
676
+ if (wasDrillIn) {
677
+ // View just swapped to the submenu — announce the level we
678
+ // entered (the opener's label is this view's title, same string
679
+ // the back button shows). Fires on the now-visible submenu.
680
+ submenu._announce(submenu._t('components.menu.submenu-title', { title: item.text }));
681
+ // Signal to our own parent's onToggle (if any) that this hide is
682
+ // "intentional, making room for a deeper drill" — skip the normal
683
+ // close cleanup that would re-parent us back and re-open them.
684
+ this._drillInHidingForDeeper = true;
685
+ this.hidePopover?.();
686
+ }
687
+ // Start the safe-triangle tracker for cascade-mode submenus only.
497
688
  // Drill-in mode replaces the parent view, so there's no "moving toward"
498
689
  // path between two visible menus to protect.
499
- if (!this._drillInMode) {
690
+ if (!wasDrillIn) {
500
691
  // Wait one frame so the submenu's getBoundingClientRect reflects its
501
692
  // final position (computePosition runs in the toggle handler).
502
693
  requestAnimationFrame(() => {
@@ -520,34 +711,162 @@ export class NLDDMenu extends LitElement {
520
711
  this._handleBackMouseenter = () => {
521
712
  this._clearHighlight();
522
713
  };
523
- /** Last cached drill-in mode value, so we only act when it actually
524
- * changes across a resize event. */
525
- this._lastDrillInMode = null;
526
- /** Close any open submenu when the cascade ↔ drill-in threshold is
527
- * crossed during a resize. Switching the rendering of an already-open
528
- * submenu mid-flight (anchor + placement + back button) is more
529
- * disorienting than a clean reset to the root view. */
530
- this._handleViewportResize = () => {
531
- const current = this._drillInMode;
532
- if (this._lastDrillInMode === null) {
533
- this._lastDrillInMode = current;
714
+ this._touchStartX = 0;
715
+ this._touchStartY = 0;
716
+ /** @internal */
717
+ this._handleMenuTouchStart = (event) => {
718
+ const t = event.touches[0];
719
+ if (!t)
534
720
  return;
535
- }
536
- if (this._lastDrillInMode === current)
721
+ this._touchStartX = t.clientX;
722
+ this._touchStartY = t.clientY;
723
+ this.removeAttribute('scroll-active');
724
+ };
725
+ /** @internal */
726
+ this._handleMenuTouchMove = (event) => {
727
+ if (this.hasAttribute('scroll-active'))
537
728
  return;
538
- this._lastDrillInMode = current;
539
- if (this._activeSubmenu) {
540
- this._activeSubmenu.hidePopover?.();
729
+ const t = event.touches[0];
730
+ if (!t)
731
+ return;
732
+ const dx = t.clientX - this._touchStartX;
733
+ const dy = t.clientY - this._touchStartY;
734
+ if (Math.hypot(dx, dy) > NLDDMenu._TOUCH_SCROLL_THRESHOLD_PX) {
735
+ this.setAttribute('scroll-active', '');
541
736
  }
542
737
  };
738
+ /** @internal */
739
+ this._handleMenuTouchEnd = () => {
740
+ this.removeAttribute('scroll-active');
741
+ };
742
+ /** Close the menu on a window resize. Floating-UI's autoUpdate keeps
743
+ * the position in sync with scrolls (where users genuinely expect the
744
+ * menu to follow the anchor), but a full window resize is a layout-
745
+ * shifting interaction the user isn't currently tracking — better to
746
+ * dismiss with a clean slate than to chase the anchor across a
747
+ * reflow, repaint a flipped placement, or get stuck mid-flight when
748
+ * crossing the cascade ↔ drill-in viewport threshold. The window
749
+ * listener is wired only while we're open and torn down on close, so
750
+ * idle menus pay nothing. */
751
+ this._handleWindowResize = () => {
752
+ this._collapseChain();
753
+ };
543
754
  /**
544
- * Close this menu when a `select` event bubbles up selecting an item
545
- * anywhere in the menu (or any descendant submenu) closes the entire
546
- * popover chain so the action feels final. The select event is dispatched
547
- * with `composed: true` so it crosses every ancestor menu in the chain.
755
+ * Collapse the entire menu chain at once root + every open submenu
756
+ * with no "back one level" re-show. Used when the action is final:
757
+ * selecting an item, clicking the anchor, clicking/tapping outside, or
758
+ * a viewport resize.
759
+ *
760
+ * Driven by the root's `_openChain` registry and each submenu's
761
+ * `_collapseSelf`, so it never depends on `_activeSubmenu` /
762
+ * `_parentMenu` threading being intact at collapse time — real
763
+ * interaction can leave those stale, which was the source of the
764
+ * recurring "anchor click doesn't close after the 2nd navigation"
765
+ * bug. Idempotent: every chain level's `select` listener may call it,
766
+ * but the first run empties the registry so the rest are no-ops.
548
767
  */
549
- this._handleSelectChainClose = () => {
550
- this.hidePopover?.();
768
+ this._collapseChain = () => {
769
+ const root = this._chainRoot ?? this;
770
+ root._closedAt = Date.now();
771
+ // Snapshot + clear first so the re-entrant cleanup splices are
772
+ // no-ops and a trailing same-gesture call finds nothing to do.
773
+ const chain = root._openChain;
774
+ root._openChain = [];
775
+ for (let i = chain.length - 1; i >= 0; i--)
776
+ chain[i]._collapseSelf?.();
777
+ if (root._isOpen)
778
+ root.hidePopover?.();
779
+ };
780
+ /**
781
+ * Drill-in click-outside: collapse the entire chain.
782
+ *
783
+ * In drill-in mode only the deepest level is `:popover-open` — popover
784
+ * light-dismiss would close just that level, and our onToggle cleanup
785
+ * would helpfully re-show the previous level. That's the wrong outcome
786
+ * for a click outside the menu: the user is dismissing the whole menu,
787
+ * not navigating one step back. Detect the outside click here and route
788
+ * to `_collapseChain`, which collapses every level at once.
789
+ *
790
+ * Cascade mode falls back to the native popover stack behaviour: a click
791
+ * outside light-dismisses every popover down to the click target's
792
+ * closest ancestor popover, so no extra handling is needed there.
793
+ *
794
+ * For mouse/pen, pointerdown (capture) is the right hook: it fires
795
+ * before light-dismiss does its work, so by the time the toggle event
796
+ * fires our chain walk has already detached listeners and prevented
797
+ * the parent re-show.
798
+ *
799
+ * For touch, an outside pointerdown is ambiguous: it's also how a page
800
+ * scroll begins. Closing the chain on the scroll-start would be
801
+ * inconsistent with a root menu (which native light-dismiss leaves
802
+ * open while you scroll outside it) and annoying. So on touch we defer
803
+ * the decision: track the pointer and only chain-close on a confirmed
804
+ * tap (pointerup without crossing the scroll threshold); a scroll
805
+ * leaves the chain untouched, matching root-menu behaviour.
806
+ */
807
+ this._handleDocumentPointerdown = (event) => {
808
+ if (!this._isOpen)
809
+ return;
810
+ if (!this._drillInMode)
811
+ return;
812
+ // Only nested drill-in submenus need this: they form a hidden chain
813
+ // of ancestors that won't auto-collapse on outside click. A root
814
+ // menu has nothing to chain-close — popover light-dismiss + the
815
+ // invoker's `popovertargetaction` handle outside / anchor clicks
816
+ // natively. Without this guard a click on the anchor of a root
817
+ // drill-in menu would fire chain-close → hide → auto-sync flips
818
+ // `popovertargetaction` to 'show' → click default action reopens
819
+ // (the "menu sluit en opent consistent weer" bug on small screens).
820
+ if (!this._isSubmenu)
821
+ return;
822
+ // Walk this menu + every parent in the drill-in chain. If the
823
+ // pointerdown landed inside any of them, it's an in-chain click
824
+ // (e.g. on the back button, on the menu background, on an item)
825
+ // and the existing handlers take care of it.
826
+ const path = event.composedPath();
827
+ let menu = this;
828
+ while (menu) {
829
+ if (path.includes(menu))
830
+ return;
831
+ menu = menu._parentMenu;
832
+ }
833
+ // Touch: defer to a confirmed tap so a page-scroll started outside
834
+ // the chain doesn't collapse it (consistent with the root menu,
835
+ // which stays open while scrolling outside).
836
+ if (event.pointerType === 'touch') {
837
+ this._outsideTapStartX = event.clientX;
838
+ this._outsideTapStartY = event.clientY;
839
+ this._teardownOutsideTap();
840
+ this._outsideTapTracking = true;
841
+ document.addEventListener('pointermove', this._outsideTapMove, true);
842
+ document.addEventListener('pointerup', this._outsideTapEnd, true);
843
+ document.addEventListener('pointercancel', this._outsideTapCancel, true);
844
+ return;
845
+ }
846
+ (this._chainRoot ?? this)._collapsedByPointerGesture = true;
847
+ this._collapseChain();
848
+ };
849
+ this._outsideTapStartX = 0;
850
+ this._outsideTapStartY = 0;
851
+ this._outsideTapTracking = false;
852
+ this._outsideTapMove = (e) => {
853
+ const dx = e.clientX - this._outsideTapStartX;
854
+ const dy = e.clientY - this._outsideTapStartY;
855
+ if (Math.hypot(dx, dy) > NLDDMenu._TOUCH_SCROLL_THRESHOLD_PX) {
856
+ // Became a scroll — leave the chain open.
857
+ this._teardownOutsideTap();
858
+ }
859
+ };
860
+ this._outsideTapEnd = () => {
861
+ const wasTap = this._outsideTapTracking;
862
+ this._teardownOutsideTap();
863
+ if (wasTap) {
864
+ (this._chainRoot ?? this)._collapsedByPointerGesture = true;
865
+ this._collapseChain();
866
+ }
867
+ };
868
+ this._outsideTapCancel = () => {
869
+ this._teardownOutsideTap();
551
870
  };
552
871
  // — Private handlers ——————————————————————————————————————————————————————
553
872
  this._handleKeydown = (event) => {
@@ -645,10 +964,22 @@ export class NLDDMenu extends LitElement {
645
964
  this._handleToggle = async (event) => {
646
965
  const toggleEvent = event;
647
966
  this._isOpen = toggleEvent.newState === 'open';
967
+ // Sync the anchor's "is-expanded" state so an anchor button (or any
968
+ // element exposing an `expanded` property) shows the active visual
969
+ // while we're open. Skip menu-items as anchors — submenu openers
970
+ // manage their own `_submenuOpen` lifecycle and don't have an
971
+ // `expanded` prop. Also opt the anchor into menu/listbox semantics
972
+ // via `popupType` when it supports it and the consumer hasn't
973
+ // already chosen a value, so screen readers announce the role.
974
+ this._syncAnchorPopupState(this._isOpen);
648
975
  if (toggleEvent.newState !== 'open') {
649
976
  this._closedAt = Date.now();
650
977
  this._cleanupAutoUpdate?.();
651
978
  this._cleanupAutoUpdate = null;
979
+ window.removeEventListener('resize', this._handleWindowResize);
980
+ document.removeEventListener('pointerdown', this._handleDocumentPointerdown, true);
981
+ this._teardownOutsideTap();
982
+ this._clearHighlight();
652
983
  return;
653
984
  }
654
985
  this._updateDividerVisibility();
@@ -662,6 +993,14 @@ export class NLDDMenu extends LitElement {
662
993
  if (anchorEl) {
663
994
  this._cleanupAutoUpdate = autoUpdate(anchorEl, this, () => this.reposition());
664
995
  }
996
+ // Wired only while open so idle menus pay nothing and we don't
997
+ // accidentally close other menus on the page during a resize or
998
+ // during an unrelated pointerdown elsewhere.
999
+ window.addEventListener('resize', this._handleWindowResize, { passive: true });
1000
+ // Capture so we run before the popover light-dismiss algorithm
1001
+ // processes the pointerdown — gives us a chance to redirect the
1002
+ // dismissal into a chain-close in drill-in mode.
1003
+ document.addEventListener('pointerdown', this._handleDocumentPointerdown, true);
665
1004
  await this.updateComplete;
666
1005
  if (this.variant !== 'listbox') {
667
1006
  const keyboard = isKeyboardMode();
@@ -679,8 +1018,14 @@ export class NLDDMenu extends LitElement {
679
1018
  };
680
1019
  }
681
1020
  // — i18n ——————————————————————————————————————————————————————————————————
682
- _t(key) {
683
- return this.translations[key] ?? nlddMenuTranslations[key];
1021
+ _t(key, vars) {
1022
+ let str = this.translations[key] ?? nlddMenuTranslations[key];
1023
+ if (vars) {
1024
+ for (const [k, v] of Object.entries(vars)) {
1025
+ str = str.replace(`{${k}}`, String(v));
1026
+ }
1027
+ }
1028
+ return str;
684
1029
  }
685
1030
  /** Resolved empty text: emptyText attribute takes precedence, then i18n fallback. */
686
1031
  get _resolvedEmptyText() {
@@ -695,6 +1040,38 @@ export class NLDDMenu extends LitElement {
695
1040
  const parent = this._parentItem?.text ?? '';
696
1041
  return parent ? `${back}: ${parent}` : back;
697
1042
  }
1043
+ /**
1044
+ * Write `message` to this menu's polite live region. Clears first, then
1045
+ * sets on a double rAF — the empty→text transition is what makes screen
1046
+ * readers reliably re-announce unchanged-looking content across engines.
1047
+ *
1048
+ * Spam control without muting real navigation: a doubled event in the
1049
+ * same tick (identical message still pending) is dropped; a *different*
1050
+ * message arriving first cancels the stale pending one (rapid navigation
1051
+ * announces only the level you land on); once flushed, the same text may
1052
+ * be announced again for a later genuine transition (e.g. re-entering a
1053
+ * submenu after going back).
1054
+ */
1055
+ _announce(message) {
1056
+ if (!message)
1057
+ return;
1058
+ if (this._announceRaf && message === this._pendingAnnouncement)
1059
+ return;
1060
+ const region = this.shadowRoot?.querySelector('.menu__live-region');
1061
+ if (!region)
1062
+ return;
1063
+ if (this._announceRaf)
1064
+ cancelAnimationFrame(this._announceRaf);
1065
+ this._pendingAnnouncement = message;
1066
+ region.textContent = '';
1067
+ this._announceRaf = requestAnimationFrame(() => {
1068
+ this._announceRaf = requestAnimationFrame(() => {
1069
+ this._announceRaf = 0;
1070
+ this._pendingAnnouncement = '';
1071
+ region.textContent = message;
1072
+ });
1073
+ });
1074
+ }
698
1075
  // — Lifecycle ——————————————————————————————————————————————————————————————
699
1076
  updated(changedProperties) {
700
1077
  if (changedProperties.has('width')) {
@@ -727,6 +1104,66 @@ export class NLDDMenu extends LitElement {
727
1104
  return document.getElementById(this.anchor);
728
1105
  return null;
729
1106
  }
1107
+ /**
1108
+ * Pick the drill-in side (above vs below the anchor) purely from the
1109
+ * available viewport space around the anchor — never from content
1110
+ * size. Content-independent so every level of the chain resolves to
1111
+ * the same side and a bigger submenu scrolls internally rather than
1112
+ * flipping the whole stack. Keeps the configured alignment suffix
1113
+ * (`-start` / `-end`) from `placement`.
1114
+ */
1115
+ _resolveDrillInPlacement(anchorEl) {
1116
+ const base = this.placement || 'bottom-start';
1117
+ const align = base.includes('-') ? base.slice(base.indexOf('-')) : '';
1118
+ const rect = anchorEl.getBoundingClientRect();
1119
+ const margin = this._cssPx('--_viewport-margin');
1120
+ const spaceBelow = window.innerHeight - rect.bottom - margin;
1121
+ const spaceAbove = rect.top - margin;
1122
+ const side = spaceBelow >= spaceAbove ? 'bottom' : 'top';
1123
+ return `${side}${align}`;
1124
+ }
1125
+ /**
1126
+ * Reflect this menu's open state on the anchor so an anchor button (or
1127
+ * any element exposing an `expanded` IDL property) shows the active
1128
+ * "is-expanded" visual while we're open. Skip menu-items as anchors —
1129
+ * they manage their own `_submenuOpen` lifecycle and don't have an
1130
+ * `expanded` property. Also default `popupType` to 'menu' / 'listbox'
1131
+ * (matching this menu's variant) when the anchor supports it and the
1132
+ * consumer hasn't already chosen a value, so screen readers announce
1133
+ * the popup role without each consumer having to set it manually.
1134
+ *
1135
+ * Finally, drive the anchor's `popoverTargetAction` so the browser's
1136
+ * native invoker action always matches the current state explicitly:
1137
+ * `'hide'` while open, `'show'` while closed. We avoid `'toggle'` on
1138
+ * purpose — toggle re-evaluates the popover's live state at click time
1139
+ * and would re-open the menu if it had just been light-dismissed
1140
+ * milliseconds earlier (the source of the "menu briefly closes and
1141
+ * reopens" bug). With explicit show/hide, the worst case is a no-op
1142
+ * (e.g. `'hide'` on an already-closed menu) — never a spurious open.
1143
+ *
1144
+ * The invoker association (`popoverTargetElement`) is set elsewhere
1145
+ * — persistently on anchor change rather than per-toggle — so the
1146
+ * browser's light-dismiss exclusion is in place from the very first
1147
+ * click instead of only after the menu has already opened once.
1148
+ */
1149
+ _syncAnchorPopupState(isOpen) {
1150
+ const anchor = this._getAnchorEl();
1151
+ if (!anchor)
1152
+ return;
1153
+ if ('expanded' in anchor)
1154
+ anchor.expanded = isOpen;
1155
+ // Seed a valid aria-haspopup when the anchor has no usable popup
1156
+ // type. Falsy covers unset (undefined/null) AND `popup-type=""` —
1157
+ // an empty string is not a valid aria-haspopup token, so it must
1158
+ // still be seeded rather than left invalid. An intentional opt-out
1159
+ // is `popup-type="false"`, which is truthy and preserved here.
1160
+ if (isOpen && 'popupType' in anchor && !anchor.popupType) {
1161
+ anchor.popupType = this.variant === 'listbox' ? 'listbox' : 'menu';
1162
+ }
1163
+ if ('popoverTargetAction' in anchor) {
1164
+ anchor.popoverTargetAction = isOpen ? 'hide' : 'show';
1165
+ }
1166
+ }
730
1167
  /**
731
1168
  * Item-activation logic shared between the native `mouseenter` capture
732
1169
  * handler and the safe-triangle recovery paths (sideways exit, direction
@@ -753,15 +1190,11 @@ export class NLDDMenu extends LitElement {
753
1190
  if (this._movingTowardSubmenu && item !== this._activeSubmenuOpener)
754
1191
  return;
755
1192
  this._setHighlight(item);
756
- // Cursor is on a real item now (not in transit) — cancel any pending
757
- // linger-close from a previous sweep through.
758
- this._cancelHoverClose();
759
1193
  if (this._drillInMode)
760
1194
  return; // Touch / narrow viewport: no hover-open.
761
1195
  // Settled on a peer item (not the active submenu's opener) — close the
762
1196
  // active submenu immediately. If this peer itself has a submenu, the
763
- // hover-open below will then schedule its own. We don't wait for the
764
- // linger-close: the user has already committed to a different item.
1197
+ // hover-open below will then schedule its own.
765
1198
  if (this._activeSubmenu && item !== this._activeSubmenuOpener) {
766
1199
  this._activeSubmenu.hidePopover?.();
767
1200
  }
@@ -824,9 +1257,9 @@ export class NLDDMenu extends LitElement {
824
1257
  this._safeTrianglePinApex(p, openerRect);
825
1258
  }
826
1259
  else {
827
- // 3c. Exited sideways — no protection, fall through to
828
- // linger-close so the submenu closes if cursor leaves
829
- // the menu entirely.
1260
+ // 3c. Exited sideways — no protection. Drop the apex and
1261
+ // activate whatever item is under the cursor so peers
1262
+ // take over immediately.
830
1263
  this._safeTriangleSidewaysExit(p, wasMoving);
831
1264
  }
832
1265
  }
@@ -835,15 +1268,10 @@ export class NLDDMenu extends LitElement {
835
1268
  this._safeTrianglePolygonTest(p, r);
836
1269
  }
837
1270
  this._lastCursorPos = p;
838
- // 5. Common per-frame work: highlight, recovery, timers.
1271
+ // 5. Common per-frame work: highlight, recovery, stall timer.
839
1272
  this._safeTriangleSyncOpenerHighlight();
840
1273
  this._safeTriangleDirectionReversalRecovery(p, wasMoving);
841
1274
  this._safeTriangleScheduleStall();
842
- if (this._movingTowardSubmenu) {
843
- this._cancelHoverClose();
844
- return;
845
- }
846
- this._safeTriangleMaybeScheduleLingerClose(p);
847
1275
  };
848
1276
  window.addEventListener('mousemove', this._safeTriangleListener);
849
1277
  }
@@ -854,7 +1282,6 @@ export class NLDDMenu extends LitElement {
854
1282
  * return trip to the opener and re-pin a fresh apex for another exit. */
855
1283
  _safeTriangleArrived(p) {
856
1284
  this._movingTowardSubmenu = false;
857
- this._cancelHoverClose();
858
1285
  this._lastCursorPos = p;
859
1286
  this._safeTriangleApex = null;
860
1287
  this._activeSubmenuOpener?.removeAttribute('highlighted');
@@ -963,24 +1390,6 @@ export class NLDDMenu extends LitElement {
963
1390
  this._activateItemAt(this._lastCursorPos);
964
1391
  }, NLDDMenu._SAFE_TRIANGLE_STALL_DISMISS_MS);
965
1392
  }
966
- /** Schedule submenu close when cursor sits outside the parent menu rect.
967
- * No-op if cursor is inside the menu (peer-hover logic handles it) or
968
- * if a close is already scheduled. */
969
- _safeTriangleMaybeScheduleLingerClose(p) {
970
- const submenu = this._safeTriangleSubmenu;
971
- if (!submenu)
972
- return;
973
- const rootRect = this.getBoundingClientRect();
974
- const inRoot = NLDDMenu._rectContainsPoint(rootRect, p);
975
- if (!inRoot && this._hoverCloseTimer === null) {
976
- this._hoverCloseTimer = window.setTimeout(() => {
977
- this._hoverCloseTimer = null;
978
- if (this._activeSubmenu === submenu) {
979
- submenu.hidePopover?.();
980
- }
981
- }, NLDDMenu._SUBMENU_LINGER_CLOSE_MS);
982
- }
983
- }
984
1393
  /** True when the rect contains the point (inclusive). Returns false for
985
1394
  * a missing rect or point — convenient for the optional-chained call
986
1395
  * sites in the safe-triangle logic. */
@@ -1038,17 +1447,11 @@ export class NLDDMenu extends LitElement {
1038
1447
  this._hoverOpenTimer = null;
1039
1448
  }
1040
1449
  }
1041
- _cancelHoverClose() {
1042
- if (this._hoverCloseTimer !== null) {
1043
- clearTimeout(this._hoverCloseTimer);
1044
- this._hoverCloseTimer = null;
1045
- }
1046
- }
1047
1450
  /** Recursively checks whether a point sits inside the given menu OR any of
1048
- * its descendant submenus. Used by the hover guard so a cursor deep inside
1049
- * a nested submenu still reads as "in the safe area" for ancestor menus —
1050
- * without this, an ancestor's linger-close fires and cascades the whole
1051
- * chain shut. */
1451
+ * its descendant submenus. Used by the safe-triangle "arrived" check so a
1452
+ * cursor that has continued past the immediate submenu into a deeper
1453
+ * nested submenu still reads as "in the safe area" — without the recursive
1454
+ * walk we'd treat the deeper hover as an exit and re-pin a stale apex. */
1052
1455
  static _isPointInMenuTree(p, menu) {
1053
1456
  const r = menu.getBoundingClientRect();
1054
1457
  if (p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom)
@@ -1100,6 +1503,12 @@ export class NLDDMenu extends LitElement {
1100
1503
  get _isSubmenu() {
1101
1504
  return this._parentMenu !== null;
1102
1505
  }
1506
+ _teardownOutsideTap() {
1507
+ this._outsideTapTracking = false;
1508
+ document.removeEventListener('pointermove', this._outsideTapMove, true);
1509
+ document.removeEventListener('pointerup', this._outsideTapEnd, true);
1510
+ document.removeEventListener('pointercancel', this._outsideTapCancel, true);
1511
+ }
1103
1512
  connectedCallback() {
1104
1513
  super.connectedCallback();
1105
1514
  if (!this.id) {
@@ -1115,15 +1524,8 @@ export class NLDDMenu extends LitElement {
1115
1524
  this.addEventListener('mouseleave', this._handleMouseleave);
1116
1525
  this.addEventListener('menu-item-focused', this._handleMenuItemFocused);
1117
1526
  this.addEventListener('submenu-open', this._handleSubmenuOpen);
1118
- this.addEventListener('select', this._handleSelectChainClose);
1527
+ this.addEventListener('select', this._collapseChain);
1119
1528
  document.addEventListener('click', this._handleDocumentClick);
1120
- // Close any open submenu when the viewport crosses the cascade ↔ drill-in
1121
- // threshold mid-session — re-rendering between modes mid-flight would
1122
- // require recomputing anchors and is more disorienting than a clean reset.
1123
- // Subscribe to the MediaQueryList's `change` event rather than every
1124
- // `window.resize` frame: it fires only when the threshold is actually
1125
- // crossed and matches the comment above the cached query.
1126
- NLDDMenu._getDrillInModeQuery().addEventListener('change', this._handleViewportResize);
1127
1529
  }
1128
1530
  firstUpdated() {
1129
1531
  this._updateEmptyState();
@@ -1144,11 +1546,15 @@ export class NLDDMenu extends LitElement {
1144
1546
  this.removeEventListener('mouseleave', this._handleMouseleave);
1145
1547
  this.removeEventListener('menu-item-focused', this._handleMenuItemFocused);
1146
1548
  this.removeEventListener('submenu-open', this._handleSubmenuOpen);
1147
- this.removeEventListener('select', this._handleSelectChainClose);
1549
+ this.removeEventListener('select', this._collapseChain);
1148
1550
  document.removeEventListener('click', this._handleDocumentClick);
1149
- NLDDMenu._getDrillInModeQuery().removeEventListener('change', this._handleViewportResize);
1551
+ // Defensive: the pointerdown + resize listeners are added on open
1552
+ // and removed on close, but if the menu is torn down mid-open we'd
1553
+ // otherwise leak the global listeners.
1554
+ document.removeEventListener('pointerdown', this._handleDocumentPointerdown, true);
1555
+ this._teardownOutsideTap();
1556
+ window.removeEventListener('resize', this._handleWindowResize);
1150
1557
  this._cancelHoverOpen();
1151
- this._cancelHoverClose();
1152
1558
  this._stopSafeTriangle();
1153
1559
  if (this._typeaheadTimer !== null) {
1154
1560
  clearTimeout(this._typeaheadTimer);
@@ -1294,24 +1700,43 @@ export class NLDDMenu extends LitElement {
1294
1700
  getHighlightedId() {
1295
1701
  return this.getHighlighted()?.id ?? '';
1296
1702
  }
1703
+ /** Read a px-valued CSS custom property as a number. `parseFloat`
1704
+ * keeps fractional pixels; an unset/NaN value falls back to 0. */
1705
+ _cssPx(name) {
1706
+ const v = parseFloat(getComputedStyle(this).getPropertyValue(name));
1707
+ return Number.isNaN(v) ? 0 : v;
1708
+ }
1297
1709
  /** Recalculate position and size relative to the anchor element. */
1298
1710
  async reposition() {
1299
1711
  const anchorEl = this._getAnchorEl();
1300
1712
  if (!anchorEl || !this._isOpen)
1301
1713
  return;
1302
- const viewportMargin = parseInt(getComputedStyle(this).getPropertyValue('--_viewport-margin'));
1714
+ const viewportMargin = this._cssPx('--_viewport-margin');
1303
1715
  // Cascade-mode submenus: shift up by the menu's own padding so the
1304
1716
  // first submenu item lines up vertically with the parent opener item.
1305
1717
  // Without this, the submenu's top edge aligns with the opener and the
1306
1718
  // inner padding pushes the first item down, leaving a visible step.
1307
1719
  const submenuPadding = (this._isSubmenu && !this._drillInMode)
1308
- ? parseInt(getComputedStyle(this).getPropertyValue('--_menu-padding')) || 0
1720
+ ? this._cssPx('--_menu-padding')
1309
1721
  : 0;
1722
+ // Drill-in: pick the side from the available space around the
1723
+ // anchor — recomputed every reposition, but content-independent so
1724
+ // a larger submenu never changes it (no mid-chain jump). `flip` is
1725
+ // content-dependent and WOULD jump the stack when a bigger submenu
1726
+ // doesn't fit, so it's dropped here; `size` caps the height and
1727
+ // the menu scrolls internally instead. Re-resolving each time (vs
1728
+ // freezing) keeps it following the anchor on scroll and re-picking
1729
+ // the roomiest side after a close + reopen. Cascade keeps `flip`
1730
+ // (submenus open beside the opener, content-fit matters there).
1731
+ const drillIn = this._drillInMode;
1732
+ const effectivePlacement = drillIn
1733
+ ? this._resolveDrillInPlacement(anchorEl)
1734
+ : this.placement;
1310
1735
  const { x, y } = await computePosition(anchorEl, this, {
1311
- placement: this.placement,
1736
+ placement: effectivePlacement,
1312
1737
  middleware: [
1313
1738
  offset({ mainAxis: 0, alignmentAxis: -submenuPadding }),
1314
- flip({ padding: viewportMargin }),
1739
+ ...(drillIn ? [] : [flip({ padding: viewportMargin })]),
1315
1740
  shift({ padding: viewportMargin }),
1316
1741
  size({
1317
1742
  padding: viewportMargin,
@@ -1367,14 +1792,11 @@ export class NLDDMenu extends LitElement {
1367
1792
  }
1368
1793
  }
1369
1794
  NLDDMenu.styles = menuStyles;
1370
- /** Wait this long after cursor stops being on a path toward the submenu
1371
- * before closing. Smooths over brief cursor stalls and accidental nudges. */
1372
- NLDDMenu._SUBMENU_LINGER_CLOSE_MS = 300;
1373
1795
  /** When the cursor sits motionless inside the safe triangle for this
1374
1796
  * long, dismiss the triangle and let whatever item is under the cursor
1375
1797
  * become interactive. Without this, a paused cursor would stay
1376
1798
  * "protected" indefinitely and the user couldn't peer-hover. */
1377
- NLDDMenu._SAFE_TRIANGLE_STALL_DISMISS_MS = 750;
1799
+ NLDDMenu._SAFE_TRIANGLE_STALL_DISMISS_MS = 500;
1378
1800
  /** Pull the wedge apex this many pixels away from the submenu edge (i.e.
1379
1801
  * to the left when the submenu is on the right). Widens the wedge near
1380
1802
  * the cursor so brief diagonal wobble doesn't pop the cursor out at the
@@ -1395,10 +1817,20 @@ NLDDMenu._SAFE_TRIANGLE_APEX_X_OFFSET = 4;
1395
1817
  *
1396
1818
  * Cached MediaQueryList shared across instances — `matchMedia()` returns
1397
1819
  * a live object and re-evaluating it for every mouseenter/submenu-open
1398
- * is wasteful. Instances also subscribe to `change` so resize events
1399
- * naturally route through `_handleViewportResize`.
1820
+ * is wasteful.
1400
1821
  */
1401
1822
  NLDDMenu._drillInModeQuery = null;
1823
+ // — Touch-scroll press-flash suppression ————————————————————————————————
1824
+ //
1825
+ // On touch, pressing an item sets :active and the synthetic sticky
1826
+ // :hover lands on it, so `.menu__item:active:hover` flashes. If the
1827
+ // gesture becomes an internal scroll (cramped menu), :active lingers
1828
+ // until the browser recognises the pan and the highlight stays for
1829
+ // the whole scroll. We detect a scroll from the touch delta and set
1830
+ // `scroll-active` on the host; the styles neutralise the press flash
1831
+ // while it's set. A pure tap never moves past the threshold, so its
1832
+ // brief press feedback is preserved.
1833
+ NLDDMenu._TOUCH_SCROLL_THRESHOLD_PX = 8;
1402
1834
  // — Lifecycle callbacks ————————————————————————————————————————————————————
1403
1835
  NLDDMenu._menuIdCounter = 0;
1404
1836
  /** Reset the typeahead buffer after this much idle time. 500ms is the