@nldd/design-system 0.8.40 → 0.8.42

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 (300) hide show
  1. package/README.md +18 -1
  2. package/dist/assets/styles/tokens.d.ts +25 -0
  3. package/dist/assets/styles/tokens.d.ts.map +1 -0
  4. package/dist/assets/styles/tokens.js +25 -0
  5. package/dist/assets/styles/tokens.js.map +1 -0
  6. package/dist/components/actions/button/button.d.ts +5 -0
  7. package/dist/components/actions/button/button.d.ts.map +1 -1
  8. package/dist/components/actions/button/button.js +7 -0
  9. package/dist/components/actions/button/button.js.map +1 -1
  10. package/dist/components/actions/button/button.styles.d.ts.map +1 -1
  11. package/dist/components/actions/button/button.styles.js +32 -20
  12. package/dist/components/actions/button/button.styles.js.map +1 -1
  13. package/dist/components/actions/button-bar/button-bar.styles.d.ts.map +1 -1
  14. package/dist/components/actions/button-bar/button-bar.styles.js +22 -0
  15. package/dist/components/actions/button-bar/button-bar.styles.js.map +1 -1
  16. package/dist/components/actions/button-group/button-group.styles.d.ts.map +1 -1
  17. package/dist/components/actions/button-group/button-group.styles.js +0 -1
  18. package/dist/components/actions/button-group/button-group.styles.js.map +1 -1
  19. package/dist/components/actions/icon-button/icon-button.d.ts +11 -0
  20. package/dist/components/actions/icon-button/icon-button.d.ts.map +1 -1
  21. package/dist/components/actions/icon-button/icon-button.js +16 -0
  22. package/dist/components/actions/icon-button/icon-button.js.map +1 -1
  23. package/dist/components/actions/icon-button/icon-button.styles.d.ts.map +1 -1
  24. package/dist/components/actions/icon-button/icon-button.styles.js +33 -21
  25. package/dist/components/actions/icon-button/icon-button.styles.js.map +1 -1
  26. package/dist/components/actions/icon-button/icon-button.template.js +1 -1
  27. package/dist/components/actions/icon-button/icon-button.template.js.map +1 -1
  28. package/dist/components/actions/link/link.d.ts +17 -6
  29. package/dist/components/actions/link/link.d.ts.map +1 -1
  30. package/dist/components/actions/link/link.js +15 -5
  31. package/dist/components/actions/link/link.js.map +1 -1
  32. package/dist/components/actions/link/link.styles.d.ts.map +1 -1
  33. package/dist/components/actions/link/link.styles.js +48 -8
  34. package/dist/components/actions/link/link.styles.js.map +1 -1
  35. package/dist/components/actions/link/link.template.d.ts.map +1 -1
  36. package/dist/components/actions/link/link.template.js +7 -9
  37. package/dist/components/actions/link/link.template.js.map +1 -1
  38. package/dist/components/actions/split-button/split-button.styles.d.ts.map +1 -1
  39. package/dist/components/actions/split-button/split-button.styles.js +14 -1
  40. package/dist/components/actions/split-button/split-button.styles.js.map +1 -1
  41. package/dist/components/actions/split-button/split-button.template.d.ts.map +1 -1
  42. package/dist/components/actions/split-button/split-button.template.js +1 -0
  43. package/dist/components/actions/split-button/split-button.template.js.map +1 -1
  44. package/dist/components/actions/toolbar/toolbar.styles.d.ts.map +1 -1
  45. package/dist/components/actions/toolbar/toolbar.styles.js +3 -2
  46. package/dist/components/actions/toolbar/toolbar.styles.js.map +1 -1
  47. package/dist/components/components.js +83 -24795
  48. package/dist/components/content/blockquote/blockquote.styles.d.ts.map +1 -1
  49. package/dist/components/content/blockquote/blockquote.styles.js +1 -0
  50. package/dist/components/content/blockquote/blockquote.styles.js.map +1 -1
  51. package/dist/components/content/code/code.d.ts +58 -0
  52. package/dist/components/content/code/code.d.ts.map +1 -0
  53. package/dist/components/content/code/code.js +155 -0
  54. package/dist/components/content/code/code.js.map +1 -0
  55. package/dist/components/content/code/code.styles.d.ts +2 -0
  56. package/dist/components/content/code/code.styles.d.ts.map +1 -0
  57. package/dist/components/content/code/code.styles.js +105 -0
  58. package/dist/components/content/code/code.styles.js.map +1 -0
  59. package/dist/components/content/code/code.template.d.ts +4 -0
  60. package/dist/components/content/code/code.template.d.ts.map +1 -0
  61. package/dist/components/content/code/code.template.js +18 -0
  62. package/dist/components/content/code/code.template.js.map +1 -0
  63. package/dist/components/content/icon/icon-aliases.d.ts.map +1 -1
  64. package/dist/components/content/icon/icon-aliases.js +6 -1
  65. package/dist/components/content/icon/icon-aliases.js.map +1 -1
  66. package/dist/components/content/icon/icon-registry.d.ts.map +1 -1
  67. package/dist/components/content/icon/icon-registry.js +11 -7
  68. package/dist/components/content/icon/icon-registry.js.map +1 -1
  69. package/dist/components/content/keyboard-shortcut/keyboard-shortcut.styles.d.ts.map +1 -1
  70. package/dist/components/content/keyboard-shortcut/keyboard-shortcut.styles.js +1 -0
  71. package/dist/components/content/keyboard-shortcut/keyboard-shortcut.styles.js.map +1 -1
  72. package/dist/components/content/tag/tag.styles.d.ts.map +1 -1
  73. package/dist/components/content/tag/tag.styles.js +1 -0
  74. package/dist/components/content/tag/tag.styles.js.map +1 -1
  75. package/dist/components/content/tooltip/tooltip.styles.d.ts.map +1 -1
  76. package/dist/components/content/tooltip/tooltip.styles.js +2 -1
  77. package/dist/components/content/tooltip/tooltip.styles.js.map +1 -1
  78. package/dist/components/forms/form-field/form-field.styles.d.ts.map +1 -1
  79. package/dist/components/forms/form-field/form-field.styles.js +9 -5
  80. package/dist/components/forms/form-field/form-field.styles.js.map +1 -1
  81. package/dist/components/index-CJ3mdHHo.js +28089 -0
  82. package/dist/components/index.d.ts +2 -0
  83. package/dist/components/index.d.ts.map +1 -1
  84. package/dist/components/index.js +2 -0
  85. package/dist/components/index.js.map +1 -1
  86. package/dist/components/inputs/checkbox/checkbox.styles.d.ts.map +1 -1
  87. package/dist/components/inputs/checkbox/checkbox.styles.js +11 -9
  88. package/dist/components/inputs/checkbox/checkbox.styles.js.map +1 -1
  89. package/dist/components/inputs/code-editor/code-editor.d.ts +77 -0
  90. package/dist/components/inputs/code-editor/code-editor.d.ts.map +1 -0
  91. package/dist/components/inputs/code-editor/code-editor.js +163 -0
  92. package/dist/components/inputs/code-editor/code-editor.js.map +1 -0
  93. package/dist/components/inputs/code-editor/code-editor.styles.d.ts +2 -0
  94. package/dist/components/inputs/code-editor/code-editor.styles.d.ts.map +1 -0
  95. package/dist/components/inputs/code-editor/code-editor.styles.js +110 -0
  96. package/dist/components/inputs/code-editor/code-editor.styles.js.map +1 -0
  97. package/dist/components/inputs/code-editor/code-editor.template.d.ts +4 -0
  98. package/dist/components/inputs/code-editor/code-editor.template.d.ts.map +1 -0
  99. package/dist/components/inputs/code-editor/code-editor.template.js +24 -0
  100. package/dist/components/inputs/code-editor/code-editor.template.js.map +1 -0
  101. package/dist/components/inputs/combo-box/combo-box.d.ts +27 -3
  102. package/dist/components/inputs/combo-box/combo-box.d.ts.map +1 -1
  103. package/dist/components/inputs/combo-box/combo-box.js +78 -21
  104. package/dist/components/inputs/combo-box/combo-box.js.map +1 -1
  105. package/dist/components/inputs/combo-box/combo-box.styles.d.ts.map +1 -1
  106. package/dist/components/inputs/combo-box/combo-box.styles.js +2 -1
  107. package/dist/components/inputs/combo-box/combo-box.styles.js.map +1 -1
  108. package/dist/components/inputs/combo-box/combo-box.template.d.ts.map +1 -1
  109. package/dist/components/inputs/combo-box/combo-box.template.js +4 -2
  110. package/dist/components/inputs/combo-box/combo-box.template.js.map +1 -1
  111. package/dist/components/inputs/dropdown/dropdown.styles.d.ts.map +1 -1
  112. package/dist/components/inputs/dropdown/dropdown.styles.js +2 -1
  113. package/dist/components/inputs/dropdown/dropdown.styles.js.map +1 -1
  114. package/dist/components/inputs/dropdown/dropdown.template.js +1 -1
  115. package/dist/components/inputs/multi-line-text-field/multi-line-text-field.styles.d.ts.map +1 -1
  116. package/dist/components/inputs/multi-line-text-field/multi-line-text-field.styles.js +2 -1
  117. package/dist/components/inputs/multi-line-text-field/multi-line-text-field.styles.js.map +1 -1
  118. package/dist/components/inputs/number-field/number-field.d.ts +5 -0
  119. package/dist/components/inputs/number-field/number-field.d.ts.map +1 -1
  120. package/dist/components/inputs/number-field/number-field.js +7 -0
  121. package/dist/components/inputs/number-field/number-field.js.map +1 -1
  122. package/dist/components/inputs/number-field/number-field.styles.d.ts.map +1 -1
  123. package/dist/components/inputs/number-field/number-field.styles.js +2 -1
  124. package/dist/components/inputs/number-field/number-field.styles.js.map +1 -1
  125. package/dist/components/inputs/number-field/number-field.template.d.ts.map +1 -1
  126. package/dist/components/inputs/number-field/number-field.template.js +2 -0
  127. package/dist/components/inputs/number-field/number-field.template.js.map +1 -1
  128. package/dist/components/inputs/password-field/password-field.styles.d.ts.map +1 -1
  129. package/dist/components/inputs/password-field/password-field.styles.js +2 -1
  130. package/dist/components/inputs/password-field/password-field.styles.js.map +1 -1
  131. package/dist/components/inputs/radio-button/radio-button.styles.d.ts.map +1 -1
  132. package/dist/components/inputs/radio-button/radio-button.styles.js +13 -11
  133. package/dist/components/inputs/radio-button/radio-button.styles.js.map +1 -1
  134. package/dist/components/inputs/search-field/search-field.d.ts +5 -0
  135. package/dist/components/inputs/search-field/search-field.d.ts.map +1 -1
  136. package/dist/components/inputs/search-field/search-field.js +7 -0
  137. package/dist/components/inputs/search-field/search-field.js.map +1 -1
  138. package/dist/components/inputs/segmented-control/segmented-control.styles.d.ts.map +1 -1
  139. package/dist/components/inputs/segmented-control/segmented-control.styles.js +8 -5
  140. package/dist/components/inputs/segmented-control/segmented-control.styles.js.map +1 -1
  141. package/dist/components/inputs/stepper/stepper.template.d.ts.map +1 -1
  142. package/dist/components/inputs/stepper/stepper.template.js +2 -0
  143. package/dist/components/inputs/stepper/stepper.template.js.map +1 -1
  144. package/dist/components/inputs/switch/switch.d.ts +5 -0
  145. package/dist/components/inputs/switch/switch.d.ts.map +1 -1
  146. package/dist/components/inputs/switch/switch.js +7 -0
  147. package/dist/components/inputs/switch/switch.js.map +1 -1
  148. package/dist/components/inputs/switch/switch.styles.d.ts.map +1 -1
  149. package/dist/components/inputs/switch/switch.styles.js +4 -3
  150. package/dist/components/inputs/switch/switch.styles.js.map +1 -1
  151. package/dist/components/inputs/switch-field/switch-field.d.ts +6 -0
  152. package/dist/components/inputs/switch-field/switch-field.d.ts.map +1 -1
  153. package/dist/components/inputs/switch-field/switch-field.js +8 -0
  154. package/dist/components/inputs/switch-field/switch-field.js.map +1 -1
  155. package/dist/components/inputs/text-field/text-field.d.ts +5 -1
  156. package/dist/components/inputs/text-field/text-field.d.ts.map +1 -1
  157. package/dist/components/inputs/text-field/text-field.js +7 -3
  158. package/dist/components/inputs/text-field/text-field.js.map +1 -1
  159. package/dist/components/inputs/text-field/text-field.styles.d.ts.map +1 -1
  160. package/dist/components/inputs/text-field/text-field.styles.js +2 -1
  161. package/dist/components/inputs/text-field/text-field.styles.js.map +1 -1
  162. package/dist/components/inputs/toggle-button/toggle-button.styles.d.ts.map +1 -1
  163. package/dist/components/inputs/toggle-button/toggle-button.styles.js +12 -8
  164. package/dist/components/inputs/toggle-button/toggle-button.styles.js.map +1 -1
  165. package/dist/components/inputs/token/token.styles.d.ts.map +1 -1
  166. package/dist/components/inputs/token/token.styles.js +10 -6
  167. package/dist/components/inputs/token/token.styles.js.map +1 -1
  168. package/dist/components/inputs/token/token.template.d.ts.map +1 -1
  169. package/dist/components/inputs/token/token.template.js +1 -0
  170. package/dist/components/inputs/token/token.template.js.map +1 -1
  171. package/dist/components/layout/app-view/app-view.d.ts +7 -0
  172. package/dist/components/layout/app-view/app-view.d.ts.map +1 -1
  173. package/dist/components/layout/app-view/app-view.js +13 -1
  174. package/dist/components/layout/app-view/app-view.js.map +1 -1
  175. package/dist/components/layout/page/page.styles.d.ts.map +1 -1
  176. package/dist/components/layout/page/page.styles.js +2 -0
  177. package/dist/components/layout/page/page.styles.js.map +1 -1
  178. package/dist/components/layout/sheet/sheet.d.ts +4 -0
  179. package/dist/components/layout/sheet/sheet.d.ts.map +1 -1
  180. package/dist/components/layout/sheet/sheet.js +7 -0
  181. package/dist/components/layout/sheet/sheet.js.map +1 -1
  182. package/dist/components/layout/sheet/sheet.styles.d.ts.map +1 -1
  183. package/dist/components/layout/sheet/sheet.styles.js +20 -1
  184. package/dist/components/layout/sheet/sheet.styles.js.map +1 -1
  185. package/dist/components/layout/spacer/spacer.d.ts +28 -3
  186. package/dist/components/layout/spacer/spacer.d.ts.map +1 -1
  187. package/dist/components/layout/spacer/spacer.js +33 -2
  188. package/dist/components/layout/spacer/spacer.js.map +1 -1
  189. package/dist/components/layout/spacer/spacer.styles.d.ts.map +1 -1
  190. package/dist/components/layout/spacer/spacer.styles.js +157 -110
  191. package/dist/components/layout/spacer/spacer.styles.js.map +1 -1
  192. package/dist/components/lists-and-menus/cells/cell/cell.styles.d.ts.map +1 -1
  193. package/dist/components/lists-and-menus/cells/cell/cell.styles.js +10 -5
  194. package/dist/components/lists-and-menus/cells/cell/cell.styles.js.map +1 -1
  195. package/dist/components/lists-and-menus/cells/description-cell/description-cell.styles.d.ts.map +1 -1
  196. package/dist/components/lists-and-menus/cells/description-cell/description-cell.styles.js +9 -4
  197. package/dist/components/lists-and-menus/cells/description-cell/description-cell.styles.js.map +1 -1
  198. package/dist/components/lists-and-menus/cells/icon-cell/icon-cell.d.ts +2 -2
  199. package/dist/components/lists-and-menus/cells/icon-cell/icon-cell.d.ts.map +1 -1
  200. package/dist/components/lists-and-menus/cells/icon-cell/icon-cell.js +1 -1
  201. package/dist/components/lists-and-menus/cells/icon-cell/icon-cell.styles.d.ts.map +1 -1
  202. package/dist/components/lists-and-menus/cells/icon-cell/icon-cell.styles.js +50 -14
  203. package/dist/components/lists-and-menus/cells/icon-cell/icon-cell.styles.js.map +1 -1
  204. package/dist/components/lists-and-menus/cells/text-cell/text-cell.d.ts +28 -5
  205. package/dist/components/lists-and-menus/cells/text-cell/text-cell.d.ts.map +1 -1
  206. package/dist/components/lists-and-menus/cells/text-cell/text-cell.js +58 -5
  207. package/dist/components/lists-and-menus/cells/text-cell/text-cell.js.map +1 -1
  208. package/dist/components/lists-and-menus/cells/text-cell/text-cell.styles.d.ts.map +1 -1
  209. package/dist/components/lists-and-menus/cells/text-cell/text-cell.styles.js +38 -9
  210. package/dist/components/lists-and-menus/cells/text-cell/text-cell.styles.js.map +1 -1
  211. package/dist/components/lists-and-menus/cells/text-cell/text-cell.template.d.ts.map +1 -1
  212. package/dist/components/lists-and-menus/cells/text-cell/text-cell.template.js +33 -3
  213. package/dist/components/lists-and-menus/cells/text-cell/text-cell.template.js.map +1 -1
  214. package/dist/components/lists-and-menus/cells/title-cell/title-cell.d.ts +28 -5
  215. package/dist/components/lists-and-menus/cells/title-cell/title-cell.d.ts.map +1 -1
  216. package/dist/components/lists-and-menus/cells/title-cell/title-cell.js +58 -5
  217. package/dist/components/lists-and-menus/cells/title-cell/title-cell.js.map +1 -1
  218. package/dist/components/lists-and-menus/cells/title-cell/title-cell.styles.d.ts.map +1 -1
  219. package/dist/components/lists-and-menus/cells/title-cell/title-cell.styles.js +57 -9
  220. package/dist/components/lists-and-menus/cells/title-cell/title-cell.styles.js.map +1 -1
  221. package/dist/components/lists-and-menus/cells/title-cell/title-cell.template.d.ts.map +1 -1
  222. package/dist/components/lists-and-menus/cells/title-cell/title-cell.template.js +36 -7
  223. package/dist/components/lists-and-menus/cells/title-cell/title-cell.template.js.map +1 -1
  224. package/dist/components/lists-and-menus/list/list.styles.d.ts.map +1 -1
  225. package/dist/components/lists-and-menus/list/list.styles.js +4 -3
  226. package/dist/components/lists-and-menus/list/list.styles.js.map +1 -1
  227. package/dist/components/lists-and-menus/list-item/list-item.d.ts +5 -0
  228. package/dist/components/lists-and-menus/list-item/list-item.d.ts.map +1 -1
  229. package/dist/components/lists-and-menus/list-item/list-item.js +7 -0
  230. package/dist/components/lists-and-menus/list-item/list-item.js.map +1 -1
  231. package/dist/components/lists-and-menus/list-item/list-item.styles.d.ts.map +1 -1
  232. package/dist/components/lists-and-menus/list-item/list-item.styles.js +24 -19
  233. package/dist/components/lists-and-menus/list-item/list-item.styles.js.map +1 -1
  234. package/dist/components/lists-and-menus/menu/menu.d.ts +254 -0
  235. package/dist/components/lists-and-menus/menu/menu.d.ts.map +1 -1
  236. package/dist/components/lists-and-menus/menu/menu.i18n.d.ts +1 -0
  237. package/dist/components/lists-and-menus/menu/menu.i18n.d.ts.map +1 -1
  238. package/dist/components/lists-and-menus/menu/menu.i18n.js +1 -0
  239. package/dist/components/lists-and-menus/menu/menu.i18n.js.map +1 -1
  240. package/dist/components/lists-and-menus/menu/menu.js +906 -15
  241. package/dist/components/lists-and-menus/menu/menu.js.map +1 -1
  242. package/dist/components/lists-and-menus/menu/menu.styles.d.ts +1 -0
  243. package/dist/components/lists-and-menus/menu/menu.styles.d.ts.map +1 -1
  244. package/dist/components/lists-and-menus/menu/menu.styles.js +150 -9
  245. package/dist/components/lists-and-menus/menu/menu.styles.js.map +1 -1
  246. package/dist/components/lists-and-menus/menu/menu.template.d.ts +2 -1
  247. package/dist/components/lists-and-menus/menu/menu.template.d.ts.map +1 -1
  248. package/dist/components/lists-and-menus/menu/menu.template.js +60 -0
  249. package/dist/components/lists-and-menus/menu/menu.template.js.map +1 -1
  250. package/dist/components/navigation/document-tab-bar/document-tab-bar.styles.d.ts.map +1 -1
  251. package/dist/components/navigation/document-tab-bar/document-tab-bar.styles.js +21 -12
  252. package/dist/components/navigation/document-tab-bar/document-tab-bar.styles.js.map +1 -1
  253. package/dist/components/navigation/menu-bar-item/menu-bar-item.styles.d.ts.map +1 -1
  254. package/dist/components/navigation/menu-bar-item/menu-bar-item.styles.js +9 -4
  255. package/dist/components/navigation/menu-bar-item/menu-bar-item.styles.js.map +1 -1
  256. package/dist/components/navigation/pagination/pagination.styles.d.ts.map +1 -1
  257. package/dist/components/navigation/pagination/pagination.styles.js +10 -3
  258. package/dist/components/navigation/pagination/pagination.styles.js.map +1 -1
  259. package/dist/components/navigation/pagination/pagination.template.js +1 -1
  260. package/dist/components/navigation/skip-link/skip-link.styles.d.ts.map +1 -1
  261. package/dist/components/navigation/skip-link/skip-link.styles.js +1 -0
  262. package/dist/components/navigation/skip-link/skip-link.styles.js.map +1 -1
  263. package/dist/components/navigation/tab-bar/tab-bar.styles.d.ts.map +1 -1
  264. package/dist/components/navigation/tab-bar/tab-bar.styles.js +9 -3
  265. package/dist/components/navigation/tab-bar/tab-bar.styles.js.map +1 -1
  266. package/dist/components/navigation/top-title-bar/top-title-bar.d.ts +5 -0
  267. package/dist/components/navigation/top-title-bar/top-title-bar.d.ts.map +1 -1
  268. package/dist/components/navigation/top-title-bar/top-title-bar.js +19 -7
  269. package/dist/components/navigation/top-title-bar/top-title-bar.js.map +1 -1
  270. package/dist/components/prism-bash-vbqaw3JR.js +226 -0
  271. package/dist/components/prism-css-B93tXdOU.js +59 -0
  272. package/dist/components/prism-javascript-DeC_17Pw.js +140 -0
  273. package/dist/components/prism-json-DIMpow89.js +25 -0
  274. package/dist/components/prism-markdown-D-uFBkiz.js +339 -0
  275. package/dist/components/prism-markup-DfJYPYHA.js +177 -0
  276. package/dist/components/prism-typescript-Cdpexfvg.js +87 -0
  277. package/dist/components/prism-yaml-DO0f9pf6.js +71 -0
  278. package/dist/components/status-and-feedback/badge/badge.styles.d.ts.map +1 -1
  279. package/dist/components/status-and-feedback/badge/badge.styles.js +2 -0
  280. package/dist/components/status-and-feedback/badge/badge.styles.js.map +1 -1
  281. package/dist/components/status-and-feedback/inline-dialog/inline-dialog.d.ts +7 -4
  282. package/dist/components/status-and-feedback/inline-dialog/inline-dialog.d.ts.map +1 -1
  283. package/dist/components/status-and-feedback/inline-dialog/inline-dialog.js +10 -3
  284. package/dist/components/status-and-feedback/inline-dialog/inline-dialog.js.map +1 -1
  285. package/dist/components/status-and-feedback/inline-dialog/inline-dialog.styles.d.ts.map +1 -1
  286. package/dist/components/status-and-feedback/inline-dialog/inline-dialog.styles.js +16 -5
  287. package/dist/components/status-and-feedback/inline-dialog/inline-dialog.styles.js.map +1 -1
  288. package/dist/components/status-and-feedback/modal-dialog/modal-dialog.styles.d.ts.map +1 -1
  289. package/dist/components/status-and-feedback/modal-dialog/modal-dialog.styles.js +2 -1
  290. package/dist/components/status-and-feedback/modal-dialog/modal-dialog.styles.js.map +1 -1
  291. package/dist/css/settings.css +100 -18
  292. package/dist/fonts/JetBrainsMono-Italic[wght].woff2 +0 -0
  293. package/dist/fonts/JetBrainsMono[wght].woff2 +0 -0
  294. package/dist/fonts/RijksSansWeb-Italic.woff2 +0 -0
  295. package/dist/fonts/RijksSansWeb-Regular.woff2 +0 -0
  296. package/package.json +18 -4
  297. package/dist/fonts/IBMPlexMono-Bold.woff2 +0 -0
  298. package/dist/fonts/IBMPlexMono-Regular.woff2 +0 -0
  299. package/dist/fonts/RijksSansVF-Italic.ttf +0 -0
  300. package/dist/fonts/RijksSansVF-Regular.ttf +0 -0
@@ -7,8 +7,8 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  import { LitElement } from 'lit';
8
8
  import { property, state } from 'lit/decorators.js';
9
9
  import { computePosition, flip, shift, offset, size, autoUpdate } from '@floating-ui/dom';
10
- import { menuStyles, menuItemStyles, menuDividerStyles } from './menu.styles.js';
11
- import { menuTemplate, menuItemTemplate, menuDividerTemplate } from './menu.template.js';
10
+ import { menuStyles, menuItemStyles, menuDividerStyles, menuGroupStyles } from './menu.styles.js';
11
+ import { menuTemplate, menuItemTemplate, menuDividerTemplate, menuGroupTemplate } from './menu.template.js';
12
12
  import { nlddMenuTranslations } from './menu.i18n.js';
13
13
  import '../../lists-and-menus/cells/icon-cell/icon-cell.js';
14
14
  import '../../lists-and-menus/cells/spacer-cell/spacer-cell.js';
@@ -17,6 +17,7 @@ import '../../content/icon/icon.js';
17
17
  import '../../status-and-feedback/inline-dialog/inline-dialog.js';
18
18
  import { isKeyboardMode } from '../../../utilities/input-modality.js';
19
19
  import { POPOVER_REOPEN_GUARD_MS } from '../../../utilities/popover-guard.js';
20
+ import { breakpoints } from '../../../assets/styles/breakpoints.js';
20
21
  // # nldd-menu-divider
21
22
  export class NLDDMenuDivider extends LitElement {
22
23
  render() {
@@ -27,6 +28,50 @@ NLDDMenuDivider.styles = menuDividerStyles;
27
28
  if (!customElements.get('nldd-menu-divider')) {
28
29
  customElements.define('nldd-menu-divider', NLDDMenuDivider);
29
30
  }
31
+ // # nldd-menu-group
32
+ /**
33
+ * A labelled group of menu items inside an nldd-menu. Wraps its slotted
34
+ * items in `role="group"` with `aria-labelledby` pointing to the group's
35
+ * `text`, providing native ARIA group semantics that a flat title element
36
+ * can't deliver.
37
+ *
38
+ * The group renders an automatic divider above itself, except when it's
39
+ * the first child of the menu — so consumers don't need to manage
40
+ * separator placement around groups themselves. Spacing above the title
41
+ * is intentionally larger than below, to bind the title visually to the
42
+ * items it labels.
43
+ *
44
+ * Use the wrapper for grouping with a title; for ungrouped flat menus or
45
+ * a divider without a title, the existing `nldd-menu-item` +
46
+ * `nldd-menu-divider` flat structure keeps working unchanged.
47
+ *
48
+ * @attr {string} text - Group title text shown above the items.
49
+ *
50
+ * @slot - nldd-menu-item children (the items belonging to this group).
51
+ */
52
+ export class NLDDMenuGroup extends LitElement {
53
+ constructor() {
54
+ super(...arguments);
55
+ this.text = '';
56
+ this._titleId = `nldd-menu-group-title-${NLDDMenuGroup._idCounter++}`;
57
+ }
58
+ render() {
59
+ return menuGroupTemplate(this);
60
+ }
61
+ }
62
+ NLDDMenuGroup.styles = menuGroupStyles;
63
+ // SSR caveat: this counter is per module instance, not per render. If
64
+ // the component ever runs in a context where modules reload between
65
+ // server and client (SSR hydration, Vite HMR with fresh module state),
66
+ // IDs will mismatch and aria-labelledby refs will break. Browser-only
67
+ // today; revisit if hydration is added.
68
+ NLDDMenuGroup._idCounter = 0;
69
+ __decorate([
70
+ property({ type: String, reflect: true })
71
+ ], NLDDMenuGroup.prototype, "text", void 0);
72
+ if (!customElements.get('nldd-menu-group')) {
73
+ customElements.define('nldd-menu-group', NLDDMenuGroup);
74
+ }
30
75
  // # nldd-menu-item
31
76
  /**
32
77
  * A single item within an nldd-menu.
@@ -61,28 +106,96 @@ export class NLDDMenuItem extends LitElement {
61
106
  this.queryMarkMode = 'predictive';
62
107
  /** Set by nldd-menu. Not part of the public API. */
63
108
  this.menuVariant = 'menu';
109
+ /** Tracks whether this item's submenu (if any) is currently open. Set by
110
+ * the parent nldd-menu via the `submenu-open`/`submenu-close` lifecycle. */
111
+ this._submenuOpen = false;
112
+ /** Cached reference to the direct child `<nldd-menu>` (this item's
113
+ * submenu). Resolved once at firstUpdated; mutating the children after
114
+ * mount is not supported in v1. */
115
+ this._cachedSubmenuEl = null;
116
+ this._submenuMutationObserver = null;
64
117
  }
65
118
  connectedCallback() {
66
119
  super.connectedCallback();
67
120
  if (!this.id) {
68
121
  this.id = `nldd-menu-item-${NLDDMenuItem._idCounter++}`;
69
122
  }
70
- this.addEventListener('focusin', () => {
123
+ this.addEventListener('focusin', (e) => {
124
+ // Only react when focus actually lands on our own shadow content
125
+ // (the .menu__item button). focusin is composed and bubbles, so it
126
+ // also fires here when focus moves into a slotted descendant —
127
+ // notably when a child submenu's .menu element gets focused on
128
+ // open. Without this guard the parent's _handleMenuItemFocused
129
+ // re-highlights the opener after we just cleared it.
130
+ const realTarget = e.composedPath()[0];
131
+ if (!realTarget || !this.shadowRoot?.contains(realTarget))
132
+ return;
71
133
  this.setAttribute('data-focused', '');
72
134
  this.dispatchEvent(new CustomEvent('menu-item-focused', {
73
135
  bubbles: true,
74
136
  composed: true,
75
137
  }));
76
138
  });
77
- this.addEventListener('focusout', () => this.removeAttribute('data-focused'));
139
+ this.addEventListener('focusout', (e) => {
140
+ // Mirror the focusin guard so a slotted descendant losing focus
141
+ // doesn't strip data-focused from the opener while it's still
142
+ // keyboard-focused itself.
143
+ const realTarget = e.composedPath()[0];
144
+ if (!realTarget || !this.shadowRoot?.contains(realTarget))
145
+ return;
146
+ this.removeAttribute('data-focused');
147
+ });
78
148
  }
79
149
  focus(options) {
80
150
  const focusable = this.shadowRoot?.querySelector('button, a');
81
151
  focusable?.focus(options);
82
152
  }
153
+ get _submenuEl() {
154
+ return this._cachedSubmenuEl;
155
+ }
156
+ get _hasSubmenu() {
157
+ return this._cachedSubmenuEl !== null;
158
+ }
159
+ firstUpdated() {
160
+ // Resolve submenu once and cache. Also trigger a single re-render so
161
+ // the chevron + ARIA attrs reflect the now-known submenu state.
162
+ this._cachedSubmenuEl = this.querySelector(':scope > nldd-menu');
163
+ if (this._cachedSubmenuEl !== null) {
164
+ this.requestUpdate();
165
+ }
166
+ // Submenu attachment is one-shot in v1 — a nldd-menu added after this
167
+ // point would silently miss aria-controls, the chevron indicator, the
168
+ // hover-open lifecycle, and the close-state sync. Watch for late
169
+ // additions and warn so consumers don't chase the symptom.
170
+ this._submenuMutationObserver = new MutationObserver(() => {
171
+ const current = this.querySelector(':scope > nldd-menu');
172
+ if (current && current !== this._cachedSubmenuEl) {
173
+ console.warn('[nldd-menu-item] A nldd-menu child was added after mount. '
174
+ + 'Submenu attachment is resolved once at firstUpdated in v1, '
175
+ + 'so this menu will not be treated as a submenu. Define the '
176
+ + 'nldd-menu child before the item is connected to the DOM.');
177
+ }
178
+ });
179
+ this._submenuMutationObserver.observe(this, { childList: true });
180
+ }
181
+ disconnectedCallback() {
182
+ super.disconnectedCallback();
183
+ this._submenuMutationObserver?.disconnect();
184
+ this._submenuMutationObserver = null;
185
+ }
83
186
  _handleClick() {
84
187
  if (this.disabled)
85
188
  return;
189
+ // Submenu items don't fire `select` — they open their submenu instead.
190
+ // Item is either an action OR a submenu opener, not both.
191
+ if (this._hasSubmenu) {
192
+ this.dispatchEvent(new CustomEvent('submenu-open', {
193
+ detail: { submenu: this._submenuEl, item: this },
194
+ bubbles: true,
195
+ composed: false,
196
+ }));
197
+ return;
198
+ }
86
199
  this.dispatchEvent(new CustomEvent('select', {
87
200
  bubbles: true,
88
201
  composed: true,
@@ -132,6 +245,9 @@ __decorate([
132
245
  __decorate([
133
246
  state()
134
247
  ], NLDDMenuItem.prototype, "menuVariant", void 0);
248
+ __decorate([
249
+ state()
250
+ ], NLDDMenuItem.prototype, "_submenuOpen", void 0);
135
251
  if (!customElements.get('nldd-menu-item')) {
136
252
  customElements.define('nldd-menu-item', NLDDMenuItem);
137
253
  }
@@ -195,12 +311,34 @@ export class NLDDMenu extends LitElement {
195
311
  * Unset keys fall back to the Dutch default.
196
312
  */
197
313
  this.translations = {};
314
+ /**
315
+ * @internal — development only.
316
+ *
317
+ * Renders a translucent SVG overlay showing the safe triangle's current
318
+ * shape. Reflected as `debug-safe-triangle` so it can be toggled at
319
+ * runtime in DevTools, but it must not be enabled in shipped code: the
320
+ * overlay attaches a popover to `document.body` and is intended purely
321
+ * as a development aid.
322
+ */
323
+ this.debugSafeTriangle = false;
198
324
  /**
199
325
  * Custom filter function. Defaults to case-insensitive substring match
200
326
  * on text, value, and aliases attributes.
201
327
  */
202
328
  this.filterFn = defaultFilterFn;
203
329
  this._isEmpty = false;
330
+ /** Currently open child submenu (a direct descendant nldd-menu opened
331
+ * by one of this menu's items). null when no submenu is open. */
332
+ this._activeSubmenu = null;
333
+ this._activeSubmenuOpener = null;
334
+ /** When this menu is itself a submenu, points to the parent menu that
335
+ * opened it. Set by the parent's _handleSubmenuOpen. null on the root.
336
+ * @internal */
337
+ this._parentMenu = null;
338
+ /** The menu-item that triggered this submenu — used to label the back
339
+ * button in drill-in mode.
340
+ * @internal */
341
+ this._parentItem = null;
204
342
  this._isOpen = false;
205
343
  this._closedAt = 0;
206
344
  this._cleanupAutoUpdate = null;
@@ -221,60 +359,289 @@ export class NLDDMenu extends LitElement {
221
359
  this.showPopover();
222
360
  }
223
361
  };
362
+ // — Safe triangle (cascade only) ——————————————————————————————————————
363
+ //
364
+ // While a cascade submenu is open, a global mousemove listener tracks
365
+ // the cursor against a "safe triangle" (Mayank/ishadeed pattern). The
366
+ // triangle's apex is captured once on the first mousemove after submenu
367
+ // open; the base is the submenu's two near corners (top + bottom of the
368
+ // edge facing the parent — left edge if submenu is right of cursor,
369
+ // right edge if floating-ui flipped it to the other side). The wedge
370
+ // stays fixed until the cursor enters the submenu, so the user has a
371
+ // 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.
374
+ this._hoverOpenTimer = null;
375
+ this._hoverCloseTimer = null;
376
+ this._safeTriangleListener = null;
377
+ this._safeTriangleStallTimer = null;
378
+ this._lastCursorPos = null;
379
+ this._safeTriangleApex = null;
380
+ this._safeTriangleSubmenu = null;
381
+ this._movingTowardSubmenu = false;
224
382
  this._handleMenuItemMouseenter = (event) => {
225
383
  const item = event.target.closest('nldd-menu-item');
226
- if (!item || item.disabled || item.hasAttribute('hidden'))
384
+ if (!item)
227
385
  return;
228
- this._setHighlight(item);
386
+ this._activateItem(item);
229
387
  };
230
388
  this._handleMouseleave = () => {
231
389
  if (this.variant !== 'listbox')
232
390
  this._clearHighlight();
391
+ this._cancelHoverOpen();
392
+ // The hover-guard takes over close behaviour while the cursor is
393
+ // outside both the parent menu and the active submenu.
233
394
  };
395
+ // — Debug overlay ————————————————————————————————————————————————————
396
+ this._safeTriangleOverlay = null;
234
397
  this._handleMenuItemFocused = (event) => {
235
398
  const item = event.target.closest('nldd-menu-item');
236
399
  if (!item || item.disabled || item.hasAttribute('hidden'))
237
400
  return;
401
+ // Same caveat as _handleMenuItemMouseenter: this listener catches
402
+ // focused events that bubble up from descendant submenus too. Skip
403
+ // those so we don't highlight items that aren't ours.
404
+ if (item.closest('nldd-menu') !== this)
405
+ return;
238
406
  this._setHighlight(item);
239
407
  };
408
+ /**
409
+ * Open a submenu in response to one of this menu's items dispatching
410
+ * `submenu-open`. Branches on drill-in vs cascade mode for anchor +
411
+ * placement, but the lifecycle (popover.show, listen for close, clear
412
+ * state on hide) is the same for both modes.
413
+ */
414
+ this._handleSubmenuOpen = (event) => {
415
+ // Only handle events from items that are direct children of this menu.
416
+ // Items inside a sub-submenu fire their own submenu-open which bubbles
417
+ // here too — we let that one bubble past, our descendant menu handles it.
418
+ const { item, submenu } = event.detail;
419
+ if (item.closest('nldd-menu') !== this)
420
+ return;
421
+ event.stopPropagation();
422
+ // Same submenu already open — bail before re-running the open path. Without
423
+ // this, hover-opening then clicking the same opener (or rapid double-click)
424
+ // would stack a second `toggle` listener; `showPopover()` is a no-op on an
425
+ // already-open popover so both listeners would survive and double-fire on
426
+ // the next close.
427
+ if (this._activeSubmenu === submenu)
428
+ return;
429
+ // Close any other submenu that's already open in this menu before
430
+ // opening a new one — only one peer submenu visible at a time.
431
+ if (this._activeSubmenu && this._activeSubmenu !== submenu) {
432
+ this._activeSubmenu.hidePopover?.();
433
+ }
434
+ submenu._parentMenu = this;
435
+ submenu._parentItem = item;
436
+ if (this._drillInMode) {
437
+ // Drill-in: anchor to the root's anchor so all submenus open at the
438
+ // same screen position — visually stacks. Inherit root placement
439
+ // for consistent direction. Back button rendered in template.
440
+ const root = this._rootMenu;
441
+ submenu.anchorElement = root._getAnchorEl();
442
+ submenu.placement = root.placement;
443
+ }
444
+ else {
445
+ // Cascade: anchor to the parent item, open beside it.
446
+ submenu.anchorElement = item;
447
+ submenu.placement = 'right-start';
448
+ }
449
+ this._activeSubmenu = submenu;
450
+ this._activeSubmenuOpener = item;
451
+ item._submenuOpen = true;
452
+ // Clear [highlighted] on the opener — the "logically current" item is
453
+ // now in the submenu, not on the opener. The opener still shows visibly
454
+ // active via .menu__item[aria-expanded="true"] (lighter neutral); CSS
455
+ // :hover upgrades it to accent if the cursor returns to it.
456
+ item.removeAttribute('highlighted');
457
+ // Listen once for the submenu's close so we can clear state, ARIA and
458
+ // 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;
463
+ submenu.removeEventListener('toggle', onToggle);
464
+ if (this._activeSubmenu === submenu) {
465
+ this._activeSubmenu = null;
466
+ this._activeSubmenuOpener = null;
467
+ }
468
+ submenu._parentMenu = null;
469
+ submenu._parentItem = null;
470
+ item._submenuOpen = false;
471
+ // Drop any safe-triangle "in transit" highlight on the opener —
472
+ // without this, an opener whose submenu was closed via stall-
473
+ // dismissal or programmatic hidePopover keeps the bold accent
474
+ // even though the close should also drop the visual signal.
475
+ // Skip when the item is currently focused — keyboard close
476
+ // (ArrowLeft) sync-focuses the opener before this async toggle
477
+ // fires, and that focus already set [highlighted] via the
478
+ // menu-item-focused chain; stripping it here would leave the
479
+ // opener visibly unhighlighted despite being focused.
480
+ if (!item.hasAttribute('data-focused')) {
481
+ item.removeAttribute('highlighted');
482
+ }
483
+ this._cancelHoverClose();
484
+ this._stopSafeTriangle();
485
+ // Counterpart to `submenu-open` — consumers tracking submenu state
486
+ // via declarative event listeners (rather than reaching into our
487
+ // internals) get a clean close signal here.
488
+ this.dispatchEvent(new CustomEvent('submenu-close', {
489
+ detail: { submenu, item },
490
+ bubbles: true,
491
+ composed: false,
492
+ }));
493
+ };
494
+ submenu.addEventListener('toggle', onToggle);
495
+ submenu.showPopover?.();
496
+ // Start the hover triangle tracker for cascade-mode submenus only.
497
+ // Drill-in mode replaces the parent view, so there's no "moving toward"
498
+ // path between two visible menus to protect.
499
+ if (!this._drillInMode) {
500
+ // Wait one frame so the submenu's getBoundingClientRect reflects its
501
+ // final position (computePosition runs in the toggle handler).
502
+ requestAnimationFrame(() => {
503
+ if (this._activeSubmenu === submenu) {
504
+ this._startSafeTriangle(submenu);
505
+ }
506
+ });
507
+ }
508
+ };
509
+ /** Close this submenu when the back button is clicked, returning to the
510
+ * parent view (which is still open as a popover behind this one in
511
+ * drill-in mode).
512
+ * @internal */
513
+ this._handleBack = () => {
514
+ this.hidePopover?.();
515
+ };
516
+ /** Cursor moved onto the drill-in back button — clear any item highlight
517
+ * so we don't show two "active" elements at once (back button + first
518
+ * item that was auto-highlighted on open).
519
+ * @internal */
520
+ this._handleBackMouseenter = () => {
521
+ this._clearHighlight();
522
+ };
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;
534
+ return;
535
+ }
536
+ if (this._lastDrillInMode === current)
537
+ return;
538
+ this._lastDrillInMode = current;
539
+ if (this._activeSubmenu) {
540
+ this._activeSubmenu.hidePopover?.();
541
+ }
542
+ };
543
+ /**
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.
548
+ */
549
+ this._handleSelectChainClose = () => {
550
+ this.hidePopover?.();
551
+ };
240
552
  // — Private handlers ——————————————————————————————————————————————————————
241
553
  this._handleKeydown = (event) => {
242
554
  const items = this._getVisibleItems();
243
555
  if (items.length === 0)
244
556
  return;
557
+ // keydown bubbles up the flat tree, so this handler fires on every
558
+ // ancestor menu in the chain. Bail when focus belongs to a descendant
559
+ // submenu — that submenu's own handler already processed the key, and
560
+ // processing it here too would advance navigation an extra step per
561
+ // ancestor (or close an extra level on Escape).
562
+ const focusedDescendant = this.querySelector('nldd-menu-item[data-focused]');
563
+ if (focusedDescendant && focusedDescendant.closest('nldd-menu') !== this)
564
+ return;
245
565
  const index = this._getFocusedIndex(items);
246
566
  switch (event.key) {
247
567
  case 'ArrowDown': {
248
568
  event.preventDefault();
569
+ event.stopPropagation();
249
570
  const next = index === -1 ? 0 : index < items.length - 1 ? index + 1 : 0;
250
571
  items[next].focus();
251
572
  break;
252
573
  }
253
574
  case 'ArrowUp': {
254
575
  event.preventDefault();
576
+ event.stopPropagation();
255
577
  const prev = index === -1 ? items.length - 1 : index > 0 ? index - 1 : items.length - 1;
256
578
  items[prev].focus();
257
579
  break;
258
580
  }
259
581
  case 'Home': {
260
582
  event.preventDefault();
583
+ event.stopPropagation();
261
584
  items[0].focus();
262
585
  break;
263
586
  }
264
587
  case 'End': {
265
588
  event.preventDefault();
589
+ event.stopPropagation();
266
590
  items[items.length - 1].focus();
267
591
  break;
268
592
  }
593
+ case 'ArrowRight': {
594
+ // Open the focused item's submenu if it has one. Mode-agnostic:
595
+ // the parent menu's _handleSubmenuOpen handler routes to cascade
596
+ // or drill-in based on viewport.
597
+ const focused = items[index];
598
+ if (focused?._hasSubmenu) {
599
+ event.preventDefault();
600
+ event.stopPropagation();
601
+ focused._handleClick();
602
+ // Focus the first item in the submenu once it's open.
603
+ requestAnimationFrame(() => {
604
+ this._activeSubmenu?._getVisibleItems()[0]?.focus();
605
+ });
606
+ }
607
+ break;
608
+ }
609
+ case 'ArrowLeft': {
610
+ // In a submenu (cascade or drill-in): close it and return focus
611
+ // to the parent item. On the root menu, ArrowLeft is a no-op.
612
+ if (this._isSubmenu) {
613
+ event.preventDefault();
614
+ event.stopPropagation();
615
+ const parentItem = this._parentItem;
616
+ this.hidePopover();
617
+ parentItem?.focus();
618
+ }
619
+ break;
620
+ }
269
621
  case 'Escape': {
270
622
  event.preventDefault();
623
+ event.stopPropagation();
271
624
  this.hidePopover();
272
- const anchorEl = this._getAnchorEl();
273
- anchorEl?.focus();
625
+ // On a submenu close, return focus to the parent item rather
626
+ // than the root anchor — keeps the user oriented in the chain.
627
+ const focusTarget = this._isSubmenu
628
+ ? this._parentItem
629
+ : this._getAnchorEl();
630
+ focusTarget?.focus();
274
631
  break;
275
632
  }
633
+ default: {
634
+ // Typeahead (ARIA APG menu pattern): typing a single printable
635
+ // character jumps to the next item whose visible text starts
636
+ // with it. Repeated presses cycle through matches.
637
+ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
638
+ this._handleTypeahead(event, items, index);
639
+ }
640
+ }
276
641
  }
277
642
  };
643
+ this._typeaheadBuffer = '';
644
+ this._typeaheadTimer = null;
278
645
  this._handleToggle = async (event) => {
279
646
  const toggleEvent = event;
280
647
  this._isOpen = toggleEvent.newState === 'open';
@@ -319,6 +686,15 @@ export class NLDDMenu extends LitElement {
319
686
  get _resolvedEmptyText() {
320
687
  return this.emptyText || this._t('components.menu.empty-text');
321
688
  }
689
+ /** Accessible name for the drill-in back button. Prefixes the parent
690
+ * item's label with the localised "back" word so AT announces "Back:
691
+ * Bestand" instead of an ambiguous "Bestand, button". Only used when
692
+ * this menu is itself a submenu in drill-in mode. */
693
+ get _resolvedBackLabel() {
694
+ const back = this._t('components.menu.back');
695
+ const parent = this._parentItem?.text ?? '';
696
+ return parent ? `${back}: ${parent}` : back;
697
+ }
322
698
  // — Lifecycle ——————————————————————————————————————————————————————————————
323
699
  updated(changedProperties) {
324
700
  if (changedProperties.has('width')) {
@@ -351,9 +727,385 @@ export class NLDDMenu extends LitElement {
351
727
  return document.getElementById(this.anchor);
352
728
  return null;
353
729
  }
354
- // — Lifecycle callbacks ————————————————————————————————————————————————————
730
+ /**
731
+ * Item-activation logic shared between the native `mouseenter` capture
732
+ * handler and the safe-triangle recovery paths (sideways exit, direction
733
+ * reversal, stall dismissal). Those paths know which item is under the
734
+ * cursor — they don't need to synthesize a MouseEvent just to re-enter
735
+ * the handler. Factoring out the body keeps the entry contracts honest:
736
+ * the recovery sites pass an already-resolved item rather than relying
737
+ * on `event.target` semantics they'd have to fake.
738
+ */
739
+ _activateItem(item) {
740
+ if (item.disabled || item.hasAttribute('hidden'))
741
+ return;
742
+ // The mouseenter listener is registered with capture:true, so it fires
743
+ // for events targeted at items in descendant submenus too. Bail out
744
+ // for those — the descendant menu has its own listener that will
745
+ // handle them. (Synthetic callers can hit this branch when the item
746
+ // under the cursor belongs to a nested menu.)
747
+ if (item.closest('nldd-menu') !== this)
748
+ return;
749
+ // Hover-triangle guard: while the user is on a path toward the active
750
+ // submenu, peer items don't get highlighted and don't schedule
751
+ // hover-opens. Without this, the highlight flickers across every item
752
+ // the cursor brushes past on its diagonal route to the submenu.
753
+ if (this._movingTowardSubmenu && item !== this._activeSubmenuOpener)
754
+ return;
755
+ 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
+ if (this._drillInMode)
760
+ return; // Touch / narrow viewport: no hover-open.
761
+ // Settled on a peer item (not the active submenu's opener) — close the
762
+ // 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.
765
+ if (this._activeSubmenu && item !== this._activeSubmenuOpener) {
766
+ this._activeSubmenu.hidePopover?.();
767
+ }
768
+ // Always cancel any pending hover-open from a previous item — moving
769
+ // the cursor onto a peer (with or without submenu) means the previous
770
+ // item's submenu should not open after its delay. Then schedule a new
771
+ // one only when the new item itself has a submenu.
772
+ this._cancelHoverOpen();
773
+ if (item._hasSubmenu && this._activeSubmenuOpener !== item) {
774
+ this._hoverOpenTimer = window.setTimeout(() => {
775
+ this._hoverOpenTimer = null;
776
+ item._handleClick();
777
+ }, 150);
778
+ }
779
+ }
780
+ /** Resolve the menu-item under a viewport point and activate it via
781
+ * `_activateItem`. Used by safe-triangle recovery paths whose natural
782
+ * mouseenter has already fired (and bailed) — they need to re-trigger
783
+ * activation without synthesizing a MouseEvent. */
784
+ _activateItemAt(p) {
785
+ const el = document.elementFromPoint(p.x, p.y);
786
+ const item = el?.closest('nldd-menu-item');
787
+ if (item)
788
+ this._activateItem(item);
789
+ }
790
+ /** Start the global mouse tracker that powers the safe triangle. Called
791
+ * by _handleSubmenuOpen when a submenu opens in cascade mode. The
792
+ * listener is a thin orchestrator over the named phase methods below. */
793
+ _startSafeTriangle(submenu) {
794
+ this._stopSafeTriangle();
795
+ this._lastCursorPos = null;
796
+ this._safeTriangleApex = null;
797
+ this._safeTriangleSubmenu = submenu;
798
+ this._movingTowardSubmenu = false;
799
+ this._safeTriangleListener = (e) => {
800
+ const p = { x: e.clientX, y: e.clientY };
801
+ const r = submenu.getBoundingClientRect();
802
+ // 1. In submenu (or any nested submenu) — arrived, clean up.
803
+ if (NLDDMenu._isPointInMenuTree(p, submenu)) {
804
+ this._safeTriangleArrived(p);
805
+ return;
806
+ }
807
+ const openerRect = this._activeSubmenuOpener?.getBoundingClientRect();
808
+ const cursorOnOpener = NLDDMenu._rectContainsPoint(openerRect, p);
809
+ const lastOnOpener = NLDDMenu._rectContainsPoint(openerRect, this._lastCursorPos);
810
+ // 2. Returned to opener — drop apex so the next exit can re-pin
811
+ // a fresh wedge. Overlay stays drawn until next render.
812
+ if (this._safeTriangleApex !== null && cursorOnOpener) {
813
+ this._safeTriangleApex = null;
814
+ }
815
+ const wasMoving = this._movingTowardSubmenu;
816
+ if (this._safeTriangleApex === null) {
817
+ // 3a. Still on opener — wait for an exit direction.
818
+ if (cursorOnOpener) {
819
+ this._safeTriangleWaitOnOpener(p, r, openerRect);
820
+ return;
821
+ }
822
+ // 3b. Crossed top/bottom edge of opener — pin apex.
823
+ if (openerRect && lastOnOpener && (p.y > openerRect.bottom || p.y < openerRect.top)) {
824
+ this._safeTrianglePinApex(p, openerRect);
825
+ }
826
+ else {
827
+ // 3c. Exited sideways — no protection, fall through to
828
+ // linger-close so the submenu closes if cursor leaves
829
+ // the menu entirely.
830
+ this._safeTriangleSidewaysExit(p, wasMoving);
831
+ }
832
+ }
833
+ else {
834
+ // 4. Apex pinned — test whether cursor is inside the wedge.
835
+ this._safeTrianglePolygonTest(p, r);
836
+ }
837
+ this._lastCursorPos = p;
838
+ // 5. Common per-frame work: highlight, recovery, timers.
839
+ this._safeTriangleSyncOpenerHighlight();
840
+ this._safeTriangleDirectionReversalRecovery(p, wasMoving);
841
+ this._safeTriangleScheduleStall();
842
+ if (this._movingTowardSubmenu) {
843
+ this._cancelHoverClose();
844
+ return;
845
+ }
846
+ this._safeTriangleMaybeScheduleLingerClose(p);
847
+ };
848
+ window.addEventListener('mousemove', this._safeTriangleListener);
849
+ }
850
+ // — Safe-triangle phase methods ————————————————————————————————————————
851
+ /** Cursor entered the submenu (or any nested submenu) — clean up triangle
852
+ * state but leave the window mousemove listener alive. The listener still
853
+ * needs to fire while the cursor is inside the submenu so we can detect a
854
+ * return trip to the opener and re-pin a fresh apex for another exit. */
855
+ _safeTriangleArrived(p) {
856
+ this._movingTowardSubmenu = false;
857
+ this._cancelHoverClose();
858
+ this._lastCursorPos = p;
859
+ this._safeTriangleApex = null;
860
+ this._activeSubmenuOpener?.removeAttribute('highlighted');
861
+ this._removeSafeTriangleOverlay();
862
+ }
863
+ /** Cursor still on the opener — wait for an exit direction. Sets the
864
+ * "in transit" flag (so brief peer pass-throughs don't activate) and
865
+ * renders a live debug preview that follows the cursor. */
866
+ _safeTriangleWaitOnOpener(p, r, openerRect) {
867
+ this._movingTowardSubmenu = true;
868
+ this._lastCursorPos = p;
869
+ // Set opener highlight up front so the bold accent is already on
870
+ // while the cursor is still on the opener — avoids a visible "pop"
871
+ // when the cursor first crosses the bottom edge.
872
+ if (this._activeSubmenuOpener
873
+ && !this._activeSubmenuOpener.hasAttribute('highlighted')) {
874
+ this._setHighlight(this._activeSubmenuOpener);
875
+ }
876
+ if (this.debugSafeTriangle && openerRect) {
877
+ const nearX = p.x < r.left ? r.left : r.right;
878
+ this._renderSafeTriangleOverlay([
879
+ { x: p.x - NLDDMenu._SAFE_TRIANGLE_APEX_X_OFFSET, y: p.y },
880
+ { x: nearX, y: r.top },
881
+ { x: nearX, y: r.bottom },
882
+ ]);
883
+ }
884
+ }
885
+ /** Cursor crossed the opener's top or bottom edge — pin apex there.
886
+ * x is clamped to the opener's range so the wedge starts at the visual
887
+ * edge regardless of how far past the edge this mousemove sample is, then
888
+ * shifted by _SAFE_TRIANGLE_APEX_X_OFFSET to widen the wedge at the
889
+ * cursor side (the 4px headroom that makes brief diagonal wobble forgive). */
890
+ _safeTrianglePinApex(p, openerRect) {
891
+ const apexX = Math.max(openerRect.left, Math.min(openerRect.right, p.x))
892
+ - NLDDMenu._SAFE_TRIANGLE_APEX_X_OFFSET;
893
+ const apexY = p.y > openerRect.bottom ? openerRect.bottom : openerRect.top;
894
+ this._safeTriangleApex = { x: apexX, y: apexY };
895
+ this._movingTowardSubmenu = true;
896
+ }
897
+ /** Cursor exited the opener sideways (left/right) or with no opener
898
+ * history — no triangle protection. Drop the flag and the overlay,
899
+ * and synthesize mouseenter on the element under the cursor so peers
900
+ * activate immediately (the natural mouseenter fired with flag=true
901
+ * during the on-opener phase and got bailed). */
902
+ _safeTriangleSidewaysExit(p, wasMoving) {
903
+ this._movingTowardSubmenu = false;
904
+ this._lastCursorPos = p;
905
+ this._removeSafeTriangleOverlay();
906
+ if (wasMoving)
907
+ this._activateItemAt(p);
908
+ }
909
+ /** Apex pinned — test whether the current cursor position is inside the
910
+ * wedge. Picks the submenu edge facing the cursor (left for right-start,
911
+ * right when floating-ui flipped it). */
912
+ _safeTrianglePolygonTest(p, r) {
913
+ const nearX = p.x < r.left ? r.left : r.right;
914
+ const apex = this._safeTriangleApex;
915
+ // Single-point apex (no jitter band): the apex is already offset left
916
+ // of the actual crossing point by _SAFE_TRIANGLE_APEX_X_OFFSET, so the
917
+ // cursor sits comfortably inside the wedge near the opener edge — no
918
+ // degenerate cases where a 1px jitter pops the cursor out at the apex.
919
+ const wedge = [
920
+ apex,
921
+ { x: nearX, y: r.top },
922
+ { x: nearX, y: r.bottom },
923
+ ];
924
+ this._movingTowardSubmenu = NLDDMenu._pointInPolygon(p, wedge);
925
+ if (this.debugSafeTriangle) {
926
+ this._renderSafeTriangleOverlay(wedge);
927
+ }
928
+ }
929
+ /** Sync opener highlight with the in-transit flag. Uses _setHighlight so
930
+ * any peer that briefly got highlighted (e.g. via the synthetic
931
+ * mouseenter on a wedge-edge wobble) is cleared. Skip when the opener
932
+ * already has the attribute to avoid DOM churn each frame. */
933
+ _safeTriangleSyncOpenerHighlight() {
934
+ if (this._activeSubmenuOpener && this._movingTowardSubmenu
935
+ && !this._activeSubmenuOpener.hasAttribute('highlighted')) {
936
+ this._setHighlight(this._activeSubmenuOpener);
937
+ }
938
+ }
939
+ /** When the in-transit flag flips true → false, the cursor may already
940
+ * be sitting on a peer item whose natural mouseenter fired (and bailed)
941
+ * earlier. mouseenter won't re-fire until the cursor leaves and re-
942
+ * enters, so synthesize one here to activate the peer immediately. */
943
+ _safeTriangleDirectionReversalRecovery(p, wasMoving) {
944
+ if (!wasMoving || this._movingTowardSubmenu)
945
+ return;
946
+ this._activateItemAt(p);
947
+ }
948
+ /** Re-arm the stall-dismissal timer. If the cursor sits motionless
949
+ * inside the safe triangle long enough, drop the protection so the
950
+ * element under the cursor can become interactive without nudging. */
951
+ _safeTriangleScheduleStall() {
952
+ if (this._safeTriangleStallTimer !== null) {
953
+ clearTimeout(this._safeTriangleStallTimer);
954
+ this._safeTriangleStallTimer = null;
955
+ }
956
+ if (!this._movingTowardSubmenu)
957
+ return;
958
+ this._safeTriangleStallTimer = window.setTimeout(() => {
959
+ this._safeTriangleStallTimer = null;
960
+ this._movingTowardSubmenu = false;
961
+ this._safeTriangleApex = null;
962
+ if (this._lastCursorPos)
963
+ this._activateItemAt(this._lastCursorPos);
964
+ }, NLDDMenu._SAFE_TRIANGLE_STALL_DISMISS_MS);
965
+ }
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
+ /** True when the rect contains the point (inclusive). Returns false for
985
+ * a missing rect or point — convenient for the optional-chained call
986
+ * sites in the safe-triangle logic. */
987
+ static _rectContainsPoint(rect, point) {
988
+ return !!rect && !!point
989
+ && point.x >= rect.left && point.x <= rect.right
990
+ && point.y >= rect.top && point.y <= rect.bottom;
991
+ }
992
+ _stopSafeTriangle() {
993
+ if (this._safeTriangleListener !== null) {
994
+ window.removeEventListener('mousemove', this._safeTriangleListener);
995
+ this._safeTriangleListener = null;
996
+ }
997
+ if (this._safeTriangleStallTimer !== null) {
998
+ clearTimeout(this._safeTriangleStallTimer);
999
+ this._safeTriangleStallTimer = null;
1000
+ }
1001
+ this._lastCursorPos = null;
1002
+ this._safeTriangleApex = null;
1003
+ this._safeTriangleSubmenu = null;
1004
+ this._movingTowardSubmenu = false;
1005
+ this._removeSafeTriangleOverlay();
1006
+ }
1007
+ _renderSafeTriangleOverlay(vertices) {
1008
+ const NS = 'http://www.w3.org/2000/svg';
1009
+ if (this._safeTriangleOverlay === null) {
1010
+ const wrapper = document.createElement('div');
1011
+ wrapper.setAttribute('popover', 'manual');
1012
+ wrapper.setAttribute('aria-hidden', 'true');
1013
+ wrapper.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;border:0;padding:0;margin:0;background:transparent;overflow:visible;';
1014
+ const svg = document.createElementNS(NS, 'svg');
1015
+ svg.setAttribute('width', '100%');
1016
+ svg.setAttribute('height', '100%');
1017
+ const polygon = document.createElementNS(NS, 'polygon');
1018
+ polygon.setAttribute('fill', 'rgba(255, 0, 128, 0.15)');
1019
+ polygon.setAttribute('stroke', 'rgba(255, 0, 128, 0.85)');
1020
+ polygon.setAttribute('stroke-width', '1');
1021
+ svg.appendChild(polygon);
1022
+ wrapper.appendChild(svg);
1023
+ document.body.appendChild(wrapper);
1024
+ wrapper.showPopover();
1025
+ this._safeTriangleOverlay = wrapper;
1026
+ }
1027
+ const polygon = this._safeTriangleOverlay.querySelector('polygon');
1028
+ polygon.setAttribute('points', vertices.map(v => `${v.x},${v.y}`).join(' '));
1029
+ }
1030
+ _removeSafeTriangleOverlay() {
1031
+ this._safeTriangleOverlay?.hidePopover?.();
1032
+ this._safeTriangleOverlay?.remove();
1033
+ this._safeTriangleOverlay = null;
1034
+ }
1035
+ _cancelHoverOpen() {
1036
+ if (this._hoverOpenTimer !== null) {
1037
+ clearTimeout(this._hoverOpenTimer);
1038
+ this._hoverOpenTimer = null;
1039
+ }
1040
+ }
1041
+ _cancelHoverClose() {
1042
+ if (this._hoverCloseTimer !== null) {
1043
+ clearTimeout(this._hoverCloseTimer);
1044
+ this._hoverCloseTimer = null;
1045
+ }
1046
+ }
1047
+ /** 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. */
1052
+ static _isPointInMenuTree(p, menu) {
1053
+ const r = menu.getBoundingClientRect();
1054
+ if (p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom)
1055
+ return true;
1056
+ if (menu._activeSubmenu)
1057
+ return NLDDMenu._isPointInMenuTree(p, menu._activeSubmenu);
1058
+ return false;
1059
+ }
1060
+ /** Convex point-in-polygon test via consistent edge-cross-product sign.
1061
+ * Vertices must be ordered (clockwise or counter-clockwise). */
1062
+ static _pointInPolygon(p, vertices) {
1063
+ let hasNeg = false;
1064
+ let hasPos = false;
1065
+ for (let i = 0; i < vertices.length; i++) {
1066
+ const a = vertices[i];
1067
+ const b = vertices[(i + 1) % vertices.length];
1068
+ const cross = (p.x - b.x) * (a.y - b.y) - (a.x - b.x) * (p.y - b.y);
1069
+ if (cross < 0)
1070
+ hasNeg = true;
1071
+ if (cross > 0)
1072
+ hasPos = true;
1073
+ if (hasNeg && hasPos)
1074
+ return false;
1075
+ }
1076
+ return true;
1077
+ }
1078
+ static _getDrillInModeQuery() {
1079
+ if (NLDDMenu._drillInModeQuery === null) {
1080
+ // Use the shared breakpoint constant so the JS-side threshold can't
1081
+ // drift from the CSS-side one (spacer.styles.ts and friends pull
1082
+ // from the same source).
1083
+ NLDDMenu._drillInModeQuery = matchMedia(`(pointer: coarse), (max-width: ${breakpoints.smMax})`);
1084
+ }
1085
+ return NLDDMenu._drillInModeQuery;
1086
+ }
1087
+ get _drillInMode() {
1088
+ return NLDDMenu._getDrillInModeQuery().matches;
1089
+ }
1090
+ /** Walks the parent-menu chain up to the root (the menu that wasn't
1091
+ * opened by another menu — has no _parentMenu). */
1092
+ get _rootMenu() {
1093
+ let m = this;
1094
+ while (m._parentMenu)
1095
+ m = m._parentMenu;
1096
+ return m;
1097
+ }
1098
+ /** True when this menu is itself a submenu (was opened by another menu's
1099
+ * item). The root menu returns false. */
1100
+ get _isSubmenu() {
1101
+ return this._parentMenu !== null;
1102
+ }
355
1103
  connectedCallback() {
356
1104
  super.connectedCallback();
1105
+ if (!this.id) {
1106
+ // Auto-id so submenu openers can reference us via aria-controls.
1107
+ this.id = `nldd-menu-${NLDDMenu._menuIdCounter++}`;
1108
+ }
357
1109
  if (!this.hasAttribute('popover')) {
358
1110
  this.setAttribute('popover', '');
359
1111
  }
@@ -362,33 +1114,68 @@ export class NLDDMenu extends LitElement {
362
1114
  this.addEventListener('mouseenter', this._handleMenuItemMouseenter, true);
363
1115
  this.addEventListener('mouseleave', this._handleMouseleave);
364
1116
  this.addEventListener('menu-item-focused', this._handleMenuItemFocused);
1117
+ this.addEventListener('submenu-open', this._handleSubmenuOpen);
1118
+ this.addEventListener('select', this._handleSelectChainClose);
365
1119
  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);
366
1127
  }
367
1128
  firstUpdated() {
368
1129
  this._updateEmptyState();
369
1130
  }
370
1131
  disconnectedCallback() {
371
1132
  super.disconnectedCallback();
1133
+ // Close any open child submenu before tearing down — the submenu is a
1134
+ // document-anchored popover and would otherwise outlive its parent.
1135
+ // The toggle listener wired in _handleSubmenuOpen still fires from
1136
+ // the hidePopover, but the cleanup below has not run yet so its
1137
+ // state-clear path is safe.
1138
+ if (this._activeSubmenu) {
1139
+ this._activeSubmenu.hidePopover?.();
1140
+ }
372
1141
  this.removeEventListener('toggle', this._handleToggle);
373
1142
  this.removeEventListener('keydown', this._handleKeydown);
374
1143
  this.removeEventListener('mouseenter', this._handleMenuItemMouseenter, true);
375
1144
  this.removeEventListener('mouseleave', this._handleMouseleave);
376
1145
  this.removeEventListener('menu-item-focused', this._handleMenuItemFocused);
1146
+ this.removeEventListener('submenu-open', this._handleSubmenuOpen);
1147
+ this.removeEventListener('select', this._handleSelectChainClose);
377
1148
  document.removeEventListener('click', this._handleDocumentClick);
1149
+ NLDDMenu._getDrillInModeQuery().removeEventListener('change', this._handleViewportResize);
1150
+ this._cancelHoverOpen();
1151
+ this._cancelHoverClose();
1152
+ this._stopSafeTriangle();
1153
+ if (this._typeaheadTimer !== null) {
1154
+ clearTimeout(this._typeaheadTimer);
1155
+ this._typeaheadTimer = null;
1156
+ }
378
1157
  this._cleanupAutoUpdate?.();
379
1158
  this._cleanupAutoUpdate = null;
380
1159
  }
381
1160
  // — Internal helpers ——————————————————————————————————————————————————————
382
1161
  _getVisibleItems() {
383
- return Array.from(this.querySelectorAll('nldd-menu-item:not([hidden]):not([disabled])'));
1162
+ // Light-DOM querySelectorAll returns ALL menu-item descendants — items
1163
+ // inside nested submenus included. Filter to items that belong directly
1164
+ // to this menu (closest enclosing nldd-menu === this), so navigation
1165
+ // and highlight management stay scoped to one level at a time.
1166
+ return Array.from(this.querySelectorAll('nldd-menu-item:not([hidden]):not([disabled])')).filter(item => item.closest('nldd-menu') === this);
384
1167
  }
385
1168
  _getFocusedIndex(items) {
386
1169
  return items.findIndex(item => item.hasAttribute('data-focused'));
387
1170
  }
388
1171
  _clearHighlight() {
389
- Array.from(this.querySelectorAll('nldd-menu-item')).forEach(item => {
390
- item.removeAttribute('highlighted');
391
- });
1172
+ // Same scope rule as _getVisibleItems — only clear highlights on items
1173
+ // that belong directly to this menu. Light-DOM querySelectorAll would
1174
+ // otherwise also clear items inside nested submenus, silently
1175
+ // corrupting their highlight state.
1176
+ Array.from(this.querySelectorAll('nldd-menu-item'))
1177
+ .filter(item => item.closest('nldd-menu') === this)
1178
+ .forEach(item => item.removeAttribute('highlighted'));
392
1179
  }
393
1180
  _setHighlight(target) {
394
1181
  this._clearHighlight();
@@ -412,7 +1199,10 @@ export class NLDDMenu extends LitElement {
412
1199
  const isFirst = index === 0;
413
1200
  const isLast = index === visible.length - 1;
414
1201
  const prevIsDivider = index > 0 && visible[index - 1].tagName.toLowerCase() === 'nldd-menu-divider';
415
- if (isFirst || isLast || prevIsDivider) {
1202
+ // nldd-menu-group renders its own auto-divider above; suppress an
1203
+ // explicit divider that would render right next to it.
1204
+ const nextIsGroup = index < visible.length - 1 && visible[index + 1].tagName.toLowerCase() === 'nldd-menu-group';
1205
+ if (isFirst || isLast || prevIsDivider || nextIsGroup) {
416
1206
  el.setAttribute('hidden', '');
417
1207
  }
418
1208
  });
@@ -434,12 +1224,25 @@ export class NLDDMenu extends LitElement {
434
1224
  item.toggleAttribute('hidden', !matches);
435
1225
  item.query = (matches && query) ? query : '';
436
1226
  });
1227
+ this._updateGroupVisibility();
437
1228
  this._setHighlight(null);
438
1229
  this._updateEmptyState();
439
1230
  this._updateDividerVisibility();
440
1231
  if (this._isOpen)
441
1232
  this.reposition();
442
1233
  }
1234
+ /**
1235
+ * Hide each nldd-menu-group whose items are all filtered out — a labelled
1236
+ * heading above an empty section reads as broken. Runs after filter() has
1237
+ * updated individual item visibility.
1238
+ */
1239
+ _updateGroupVisibility() {
1240
+ const groups = this.querySelectorAll('nldd-menu-group');
1241
+ groups.forEach(group => {
1242
+ const visibleItems = group.querySelectorAll('nldd-menu-item:not([hidden])');
1243
+ group.toggleAttribute('hidden', visibleItems.length === 0);
1244
+ });
1245
+ }
443
1246
  /**
444
1247
  * Move both focus and highlight to the next, previous, or first visible item.
445
1248
  */
@@ -497,10 +1300,17 @@ export class NLDDMenu extends LitElement {
497
1300
  if (!anchorEl || !this._isOpen)
498
1301
  return;
499
1302
  const viewportMargin = parseInt(getComputedStyle(this).getPropertyValue('--_viewport-margin'));
1303
+ // Cascade-mode submenus: shift up by the menu's own padding so the
1304
+ // first submenu item lines up vertically with the parent opener item.
1305
+ // Without this, the submenu's top edge aligns with the opener and the
1306
+ // inner padding pushes the first item down, leaving a visible step.
1307
+ const submenuPadding = (this._isSubmenu && !this._drillInMode)
1308
+ ? parseInt(getComputedStyle(this).getPropertyValue('--_menu-padding')) || 0
1309
+ : 0;
500
1310
  const { x, y } = await computePosition(anchorEl, this, {
501
1311
  placement: this.placement,
502
1312
  middleware: [
503
- offset(0),
1313
+ offset({ mainAxis: 0, alignmentAxis: -submenuPadding }),
504
1314
  flip({ padding: viewportMargin }),
505
1315
  shift({ padding: viewportMargin }),
506
1316
  size({
@@ -516,11 +1326,86 @@ export class NLDDMenu extends LitElement {
516
1326
  top: `${y}px`,
517
1327
  });
518
1328
  }
1329
+ /**
1330
+ * ARIA APG typeahead: characters typed within 500ms accumulate into a
1331
+ * buffer; the menu jumps to the first item whose text starts with the
1332
+ * buffer (case-insensitive).
1333
+ *
1334
+ * Single-char buffer (first press or after reset) cycles through matches
1335
+ * — repeated presses of the same letter step through every item starting
1336
+ * with it. Multi-char buffer matches from the currently-focused item, so
1337
+ * extending the prefix while the current item still matches keeps focus
1338
+ * stable instead of jumping forward.
1339
+ */
1340
+ _handleTypeahead(event, items, currentIndex) {
1341
+ // Extend (or seed) the buffer and (re)arm the reset timer.
1342
+ if (this._typeaheadTimer !== null) {
1343
+ clearTimeout(this._typeaheadTimer);
1344
+ }
1345
+ this._typeaheadBuffer += event.key.toLowerCase();
1346
+ this._typeaheadTimer = window.setTimeout(() => {
1347
+ this._typeaheadBuffer = '';
1348
+ this._typeaheadTimer = null;
1349
+ }, NLDDMenu._TYPEAHEAD_RESET_MS);
1350
+ // Single char → cycle past the current item; multi char → start AT the
1351
+ // current item so a still-matching prefix doesn't move focus.
1352
+ const start = this._typeaheadBuffer.length === 1 && currentIndex >= 0
1353
+ ? currentIndex + 1
1354
+ : Math.max(0, currentIndex);
1355
+ for (let i = 0; i < items.length; i++) {
1356
+ const idx = (start + i) % items.length;
1357
+ if (items[idx].text.toLowerCase().startsWith(this._typeaheadBuffer)) {
1358
+ event.preventDefault();
1359
+ event.stopPropagation();
1360
+ items[idx].focus();
1361
+ return;
1362
+ }
1363
+ }
1364
+ }
519
1365
  render() {
520
1366
  return menuTemplate.call(this, this._isEmpty, this.variant);
521
1367
  }
522
1368
  }
523
1369
  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
+ /** When the cursor sits motionless inside the safe triangle for this
1374
+ * long, dismiss the triangle and let whatever item is under the cursor
1375
+ * become interactive. Without this, a paused cursor would stay
1376
+ * "protected" indefinitely and the user couldn't peer-hover. */
1377
+ NLDDMenu._SAFE_TRIANGLE_STALL_DISMISS_MS = 750;
1378
+ /** Pull the wedge apex this many pixels away from the submenu edge (i.e.
1379
+ * to the left when the submenu is on the right). Widens the wedge near
1380
+ * the cursor so brief diagonal wobble doesn't pop the cursor out at the
1381
+ * apex, and avoids the degenerate single-line wedge when the cursor
1382
+ * crossed the opener edge exactly at the same x as the near submenu
1383
+ * corner. The shift is always negative — the standard placement is
1384
+ * submenu-right, so "left" is the intuitive forgive direction. */
1385
+ NLDDMenu._SAFE_TRIANGLE_APEX_X_OFFSET = 4;
1386
+ /**
1387
+ * Drill-in mode is the touch-friendly rendering: a submenu replaces its
1388
+ * parent's view by anchoring to the root anchor (so visually it stacks
1389
+ * over the parent) and gets a back-button header. Otherwise (cascade)
1390
+ * the submenu opens beside its parent item.
1391
+ *
1392
+ * Detection is based on pointer type and viewport width — touch devices
1393
+ * and narrow viewports drill in, everything else cascades. No consumer
1394
+ * override; the choice is environment-driven.
1395
+ *
1396
+ * Cached MediaQueryList shared across instances — `matchMedia()` returns
1397
+ * 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`.
1400
+ */
1401
+ NLDDMenu._drillInModeQuery = null;
1402
+ // — Lifecycle callbacks ————————————————————————————————————————————————————
1403
+ NLDDMenu._menuIdCounter = 0;
1404
+ /** Reset the typeahead buffer after this much idle time. 500ms is the
1405
+ * common ARIA APG / OS convention — long enough to type a 2-3 char prefix
1406
+ * comfortably, short enough that an unrelated later keystroke starts a
1407
+ * fresh search. */
1408
+ NLDDMenu._TYPEAHEAD_RESET_MS = 500;
524
1409
  __decorate([
525
1410
  property({ type: String, reflect: true })
526
1411
  ], NLDDMenu.prototype, "anchor", void 0);
@@ -548,12 +1433,18 @@ __decorate([
548
1433
  __decorate([
549
1434
  property({ type: Object })
550
1435
  ], NLDDMenu.prototype, "translations", void 0);
1436
+ __decorate([
1437
+ property({ type: Boolean, reflect: true, attribute: 'debug-safe-triangle' })
1438
+ ], NLDDMenu.prototype, "debugSafeTriangle", void 0);
551
1439
  __decorate([
552
1440
  property({ attribute: false })
553
1441
  ], NLDDMenu.prototype, "filterFn", void 0);
554
1442
  __decorate([
555
1443
  state()
556
1444
  ], NLDDMenu.prototype, "_isEmpty", void 0);
1445
+ __decorate([
1446
+ state()
1447
+ ], NLDDMenu.prototype, "_parentItem", void 0);
557
1448
  if (!customElements.get('nldd-menu')) {
558
1449
  customElements.define('nldd-menu', NLDDMenu);
559
1450
  }