@fusion.dev/helix-headless 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/custom-elements.json +234 -234
  2. package/dist/accordion/item/accordion-item.js +0 -108
  3. package/dist/accordion/root/accordion-root.js +0 -33
  4. package/dist/announcement/root/announcement-root.js +1 -76
  5. package/dist/app-header/root/app-header-root.js +0 -84
  6. package/dist/avatar/group/avatar-group.js +0 -77
  7. package/dist/avatar/root/avatar-root.js +0 -72
  8. package/dist/badge/root/badge-root.js +0 -67
  9. package/dist/bar-chart/root/bar-chart-root.js +3 -263
  10. package/dist/blockquote/root/blockquote-root.js +0 -46
  11. package/dist/breadcrumbs/breadcrumb/breadcrumb.js +0 -72
  12. package/dist/breadcrumbs/root/breadcrumbs-root.js +0 -52
  13. package/dist/button/root/button-root.js +0 -215
  14. package/dist/button-group/root/button-group-root.js +0 -30
  15. package/dist/callout/root/callout-root.js +0 -52
  16. package/dist/card/root/card-root.js +0 -41
  17. package/dist/carousel/root/carousel-root.d.ts +2 -2
  18. package/dist/carousel/root/carousel-root.js +2 -362
  19. package/dist/chat/composer/chat-composer.js +0 -131
  20. package/dist/chat/message/chat-message.js +0 -137
  21. package/dist/chat/root/chat-root.js +0 -193
  22. package/dist/checkbox/root/checkbox-root.js +0 -113
  23. package/dist/checkbox-group/root/checkbox-group-root.js +1 -157
  24. package/dist/code/root/code-root.js +0 -26
  25. package/dist/code-block/root/code-block-root.js +0 -100
  26. package/dist/color-input/root/color-input-root.js +0 -217
  27. package/dist/color-picker/color.js +0 -69
  28. package/dist/color-picker/root/color-picker-root.js +0 -272
  29. package/dist/combobox/root/combobox-root.js +0 -428
  30. package/dist/command/root/command-root.js +0 -177
  31. package/dist/copy-button/root/copy-button-root.js +0 -134
  32. package/dist/data-table/root/data-table-root.js +0 -336
  33. package/dist/data-table/root/order.js +0 -100
  34. package/dist/date-input/root/date-input-root.js +1 -398
  35. package/dist/date-picker/root/date-picker-root.js +0 -358
  36. package/dist/dialog/root/dialog-root.js +0 -189
  37. package/dist/divider/root/divider-root.js +0 -71
  38. package/dist/donut-chart/root/donut-chart-root.js +0 -214
  39. package/dist/drawer/root/drawer-root.js +0 -116
  40. package/dist/drilldown/item/drilldown-item.js +0 -82
  41. package/dist/drilldown/root/drilldown-root.js +0 -91
  42. package/dist/element-events.generated.js +0 -5
  43. package/dist/empty/root/empty-root.js +0 -35
  44. package/dist/file-input/root/file-input-root.js +0 -232
  45. package/dist/format-date/root/format-date-root.js +0 -95
  46. package/dist/heatmap/root/buckets.js +0 -57
  47. package/dist/heatmap/root/heatmap-root.js +0 -313
  48. package/dist/icon/root/icon-root.js +0 -48
  49. package/dist/icon-button/root/icon-button-root.js +0 -30
  50. package/dist/image/root/image-root.js +0 -174
  51. package/dist/index.js +0 -9
  52. package/dist/input/root/input-root.js +0 -228
  53. package/dist/kbd/root/kbd-root.js +0 -117
  54. package/dist/legend/root/legend-root.js +0 -73
  55. package/dist/line-chart/root/line-chart-root.js +0 -172
  56. package/dist/list/item/list-item.js +0 -92
  57. package/dist/list/root/list-root.js +0 -26
  58. package/dist/listbox/root/listbox-root.js +2 -429
  59. package/dist/loader/root/loader-root.js +0 -40
  60. package/dist/megamenu/root/megamenu-root.js +0 -284
  61. package/dist/menu/item/menu-item.js +1 -221
  62. package/dist/menu/root/menu-root.js +1 -171
  63. package/dist/meter/root/meter-root.js +1 -152
  64. package/dist/navigation/root/navigation-root.js +0 -56
  65. package/dist/notifications/notification/notification.js +0 -159
  66. package/dist/notifications/root/notifications-root.js +0 -148
  67. package/dist/number-input/root/number-input-root.js +1 -263
  68. package/dist/option/group/option-group.js +0 -26
  69. package/dist/option/root/option-root.js +0 -157
  70. package/dist/outline/root/outline-root.js +0 -292
  71. package/dist/pagination/root/pagination-root.js +0 -361
  72. package/dist/pagination/size/pagination-size.js +0 -104
  73. package/dist/pagination/summary/pagination-summary.js +0 -90
  74. package/dist/pin-input/root/pin-input-root.js +0 -276
  75. package/dist/popover/root/popover-root.js +0 -103
  76. package/dist/progress/root/progress-root.js +1 -133
  77. package/dist/qr-code/root/qr-code-root.js +0 -96
  78. package/dist/quote/root/quote-root.js +0 -25
  79. package/dist/radio/group/radio-group.js +0 -81
  80. package/dist/radio/root/radio-root.js +0 -85
  81. package/dist/rating/root/rating-root.js +0 -220
  82. package/dist/scroll-area/root/scroll-area-root.js +0 -100
  83. package/dist/select/root/select-root.js +2 -218
  84. package/dist/skeleton/root/skeleton-root.js +0 -20
  85. package/dist/slider/root/slider-root.js +1 -267
  86. package/dist/sparkline/root/sparkline-root.js +3 -164
  87. package/dist/speed-dial/item/speed-dial-item.js +0 -52
  88. package/dist/speed-dial/root/speed-dial-root.js +1 -116
  89. package/dist/splitter/root/splitter-root.js +0 -489
  90. package/dist/stat/group/stat-group.js +0 -18
  91. package/dist/stat/root/stat-root.js +0 -23
  92. package/dist/steps/root/steps-root.js +0 -62
  93. package/dist/steps/step/step.js +0 -131
  94. package/dist/switch/root/switch-root.js +0 -80
  95. package/dist/table/cell/table-cell.js +0 -23
  96. package/dist/table/column/table-column.js +0 -158
  97. package/dist/table/root/table-root.js +0 -82
  98. package/dist/table/row/table-row.js +0 -21
  99. package/dist/tabs/panel/tab-panel.js +0 -49
  100. package/dist/tabs/root/tabs-root.js +0 -98
  101. package/dist/tabs/tab/tab.js +0 -59
  102. package/dist/tag/root/tag-root.js +0 -31
  103. package/dist/textarea/root/textarea-root.js +0 -162
  104. package/dist/time-input/root/time-input-root.js +0 -283
  105. package/dist/time-picker/root/time-picker-root.js +0 -326
  106. package/dist/timeline/item/timeline-item.js +0 -66
  107. package/dist/timeline/root/timeline-root.js +0 -41
  108. package/dist/toast/item/toast-item.js +0 -165
  109. package/dist/toast/root/toast-root.js +0 -51
  110. package/dist/toggle/group/toggle-group.js +0 -150
  111. package/dist/toggle/root/toggle-root.js +0 -78
  112. package/dist/toolbar/root/toolbar-root.js +0 -116
  113. package/dist/tooltip/root/tooltip-root.js +0 -427
  114. package/dist/tour/root/tour-root.js +0 -130
  115. package/dist/tour/stop/tour-stop.js +1 -127
  116. package/dist/tree/item/tree-item.js +0 -198
  117. package/dist/tree/root/tree-root.js +0 -97
  118. package/dist/trend/root/trend-root.js +0 -53
  119. package/dist/video/root/video-root.d.ts +8 -12
  120. package/dist/video/root/video-root.js +5 -756
  121. package/dist/video-playlist/item/video-playlist-item.js +1 -90
  122. package/dist/video-playlist/menu/video-playlist-menu.js +1 -132
  123. package/dist/video-playlist/root/video-playlist-root.js +0 -113
  124. package/dist/virtual-list/root/virtual-list-root.js +0 -105
  125. package/jsx.d.ts +1408 -2576
  126. package/package.json +6 -6
  127. package/vue.d.ts +1 -1
@@ -9,158 +9,21 @@ import { caretRight, check } from '@fusion.dev/helix-icons';
9
9
  import { html, nothing } from 'lit';
10
10
  import { property, state } from 'lit/decorators.js';
11
11
  import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
12
- /**
13
- * @summary One row in a menu: a command, a choice, or a way into a submenu.
14
- *
15
- * @description
16
- * Three shapes, one element, because a reader moving down a menu with the
17
- * arrow keys does not care which kind each row is and neither should the
18
- * markup. What it is follows from what it was given: something slotted into
19
- * `submenu` makes it a way in, `checked` makes it a choice, and neither makes
20
- * it a command.
21
- *
22
- * **The role follows the shape.** A row that can be ticked is
23
- * `menuitemcheckbox` and carries `aria-checked`, because a reader is told "one
24
- * of these is on" rather than "here is a button". A row that opens a page keeps
25
- * `menuitem` and says `aria-haspopup`, since the page it opens is a menu.
26
- *
27
- * Whether it is on the page showing is set by the menu as a property, never as
28
- * an attribute: an attribute written onto a consumer's element after it
29
- * upgrades is a hydration mismatch waiting to happen.
30
- *
31
- * @since 0.1.0
32
- * @status experimental
33
- * @aliases Menu option, Menu row
34
- * @documentation https://helix.fusion.dev/components/menu
35
- *
36
- * @slot - The label.
37
- * @slot start - Before the label. A mark about the row.
38
- * @slot submenu - Rows this one opens, which makes it a way in.
39
- *
40
- * @event {HelixSelectEvent} helix-select - Fired when the row is chosen. Cancelable: refusing it takes the decision and nothing moves.
41
- *
42
- * @csspart item - The row, which is an `<a>` where `href` is set.
43
- * @csspart mark - The trailing mark: a tick when checked, a caret when it opens
44
- * a page. One column, because a row is never both.
45
- * @csspart start - The leading column, which is a slot and is always there.
46
- */
47
12
  let HelixHeadlessMenuItem = class HelixHeadlessMenuItem extends HelixElement {
48
13
  constructor() {
49
14
  super(...arguments);
50
- /**
51
- * Whether the row is one of a set of choices.
52
- *
53
- * Separate from `checked`, and it has to be. A row that is off is still a
54
- * choice, and `checked` alone cannot say so: an unticked row and a row that
55
- * was never tickable look identical from here. Announcing only the ticked one
56
- * as a checkbox tells a reader its siblings are commands, which is the one
57
- * thing they are not.
58
- *
59
- * `checked` implies it, so a row that is on never has to say both.
60
- */
61
15
  this.checkable = false;
62
- /**
63
- * Whether the row is ticked.
64
- */
65
16
  this.checked = false;
66
- /**
67
- * Whether the row can be chosen.
68
- */
69
17
  this.disabled = false;
70
- /**
71
- * Whether the link saves the file rather than opening it.
72
- *
73
- * Only meaningful beside an `href`, and only for a file served from this
74
- * origin: the attribute is ignored cross-origin, which is the browser's rule
75
- * about who may name a file on somebody's disk rather than a gap here.
76
- */
77
18
  this.download = false;
78
- /**
79
- * Where the row goes, which makes it a real link.
80
- *
81
- * **The row itself becomes an `<a>`, and the role goes with it.** A
82
- * `menuitem` cannot contain a link: measured in Chromium, an anchor inside
83
- * one is exposed as a child and the row loses its accessible name entirely.
84
- * So the anchor *is* the row, carrying `menuitem` and the `tabindex` the menu
85
- * roves, which is the same maneuver `Tree.Item` makes and the reason `href`
86
- * changes the markup rather than only adding an attribute.
87
- *
88
- * **`Option` cannot have this and the reason is worth knowing**, since a
89
- * command palette is the obvious place to want it. A listbox has to keep
90
- * focus somewhere else, in the text field for a combobox, so it says which
91
- * option is current with `aria-activedescendant`. That reference can only
92
- * point at the option's host, measured: assigning
93
- * `ariaActiveDescendantElement` an element inside a sibling shadow root reads
94
- * back `null`. So `role="option"` is pinned to the host, and an anchor under
95
- * it hits the naming failure above. A menu has no such constraint because
96
- * focus really moves to the row.
97
- */
98
19
  this.href = '';
99
- /**
100
- * Whether this row is on the page currently showing.
101
- *
102
- * Set by the menu, and a property rather than an attribute for that reason.
103
- * Public because the menu has to reach it, and not something a consumer
104
- * writes: which page is open is the menu's business.
105
- */
106
20
  this.onPage = true;
107
- /**
108
- * The relationship, which `target="_blank"` should always set.
109
- */
110
21
  this.rel = '';
111
- /**
112
- * Whether this row's submenu is the page showing.
113
- *
114
- * Separate from `onPage`, and it has to be. A row that opened a page is not
115
- * itself on that page, so both are false for it, and rendering nothing on
116
- * that basis takes its `slot[name="submenu"]` with it: the rows inside have
117
- * nowhere to be assigned and are never laid out. The element that holds the
118
- * page has to keep the slot even when it shows no row of its own.
119
- */
120
22
  this.showingSubmenu = false;
121
- /**
122
- * Where the link opens, for the rows that should leave the page.
123
- */
124
23
  this.target = '';
125
- /**
126
- * What this row stands for, carried in the event when it is chosen.
127
- */
128
24
  this.value = '';
129
- /**
130
- * @internal Read by a parent to identify its children across the light DOM,
131
- * which is cheaper than `instanceof` and is why this is public rather than
132
- * protected. Not a consumer's to read or set.
133
- */
134
25
  this.isMenuItem = true;
135
- /**
136
- * Whether anything is slotted into `submenu`. Read after first render.
137
- *
138
- * State rather than a getter over `children`. A getter is read during
139
- * `render()` with nothing to re-read it, so a submenu arriving after the
140
- * row's first render produces no caret and no `aria-haspopup` while selecting
141
- * the row still drills in, which is what a framework filling a submenu on
142
- * demand hits every time. `Tree.Item` backs `branch` the same way for the
143
- * same reason.
144
- *
145
- * Kept in step by `readSubmenu`, which is bound in the template rather than
146
- * once at first render. See the note there.
147
- */
148
26
  this.submenu = false;
149
- /**
150
- * Re-reads whether anything is in the submenu, from whichever slot is current.
151
- *
152
- * **Bound in the template, and it has to be.** This row renders a
153
- * `slot[name="submenu"]` in two template branches, one for the row and one
154
- * for the page it opens, and moving between them makes Lit build a new slot
155
- * and throw the old one away. A listener added at `firstUpdated` stays on the
156
- * discarded slot, which fires `slotchange` on its way out with nothing
157
- * assigned to it any more: one drill-in sets `submenu` false and nothing is
158
- * left listening to set it back, so every caret in the menu vanishes and
159
- * every row stops opening.
160
- *
161
- * The live slot is asked rather than `event.target`, so it does not matter
162
- * which of the two fired or in what order.
163
- */
164
27
  this.readSubmenu = () => {
165
28
  const slot = this.renderRoot.querySelector('slot[name="submenu"]');
166
29
  this.submenu = (slot?.assignedElements().length ?? 0) > 0;
@@ -168,23 +31,6 @@ let HelixHeadlessMenuItem = class HelixHeadlessMenuItem extends HelixElement {
168
31
  this.handleClick = () => {
169
32
  this.choose();
170
33
  };
171
- /**
172
- * Enter and Space choose the row, which a `<div>` would not do on its own.
173
- *
174
- * It is not a `<button>`: a button inside a menu is announced as a button,
175
- * and the roles a menu needs are not roles a button can take.
176
- *
177
- * **A linked row hands Enter back to the browser.** An anchor answers Enter
178
- * itself, by firing a click and following the link, so preventing the default
179
- * here would mean re-issuing that navigation by hand and losing whatever the
180
- * browser does around it. `Tree.Item` records the same trap from the other
181
- * side: its tree prevents every key it handles, which is exactly what was
182
- * swallowing Enter on its rows.
183
- *
184
- * Space is different, and that is the platform rather than a choice: an
185
- * anchor ignores it. So the row presses itself, and the click that comes back
186
- * both announces and navigates.
187
- */
188
34
  this.handleKeydown = (event) => {
189
35
  if (event.key !== 'Enter' && event.key !== ' ') {
190
36
  return;
@@ -201,14 +47,6 @@ let HelixHeadlessMenuItem = class HelixHeadlessMenuItem extends HelixElement {
201
47
  this.handleClick();
202
48
  };
203
49
  }
204
- /**
205
- * Whether anything was slotted into `submenu`.
206
- *
207
- * False on the server, which is the right answer rather than a safe one: the
208
- * server cannot know whether a submenu was slotted, and a row that served an
209
- * `aria-haspopup` it might not have would be a promise made to a screen
210
- * reader on the strength of a guess.
211
- */
212
50
  get hasSubmenu() {
213
51
  return this.submenu;
214
52
  }
@@ -216,46 +54,16 @@ let HelixHeadlessMenuItem = class HelixHeadlessMenuItem extends HelixElement {
216
54
  super.firstUpdated(changed);
217
55
  this.readSubmenu();
218
56
  }
219
- /**
220
- * Moves focus onto the row itself.
221
- *
222
- * The host is not focusable: what takes focus is the row inside this shadow
223
- * root, and a menu moving between rows with the arrow keys has to reach it.
224
- */
225
57
  focus() {
226
58
  this.renderRoot.querySelector('[part="item"]')?.focus();
227
59
  }
228
- /**
229
- * `NavigableItem`, so the arrow keys can be the shared controller's business.
230
- *
231
- * `focus()` above already puts focus on the control inside the row rather than
232
- * on the row, which is the whole of what moving here means.
233
- */
234
60
  focusItem() {
235
61
  this.focus();
236
62
  }
237
- /** Chooses the row, which is the same act as pressing it. */
238
63
  select() {
239
64
  this.choose();
240
65
  }
241
- /**
242
- * Whether this is the row the set is on, which in a menu is where focus is.
243
- *
244
- * A radio group's selection is an answer it holds; a menu has no answer, only
245
- * a row the reader has walked to, so focus is the whole of what "the current
246
- * one" can mean here. Read from the shadow root rather than kept as state,
247
- * because focus moves for reasons this element is not told about: a press, a
248
- * Tab, the drilldown putting the reader on the first row of a page it has
249
- * just opened.
250
- */
251
66
  get selected() {
252
- /* Asked of the document, not of the shadow root. Focus lands on the control
253
- inside this row, and the platform retargets it: from outside a shadow
254
- tree, `activeElement` is the host, which is this element. So the document
255
- already answers the question and nothing has to walk a tree to do it.
256
- `ShadowRoot.activeElement` is also the reading that does not survive
257
- happy-dom, which climbs `getRootNode()` to the top and then dereferences
258
- what it ran out of. */
259
67
  return this.ownerDocument.activeElement === this;
260
68
  }
261
69
  choose() {
@@ -265,47 +73,19 @@ let HelixHeadlessMenuItem = class HelixHeadlessMenuItem extends HelixElement {
265
73
  this.dispatchEvent(new HelixSelectEvent({ value: this.value }));
266
74
  }
267
75
  render() {
268
- // The page this row opened is showing, so what it contributes is the slot
269
- // its rows are assigned to and nothing else. Its own row belongs to the
270
- // page above.
271
76
  if (this.showingSubmenu) {
272
77
  return html `<slot name="submenu" @slotchange=${this.readSubmenu}></slot>`;
273
78
  }
274
- // Not on the page showing, so there is nothing to draw. Rendering nothing
275
- // rather than hiding it with an attribute, because the attribute would be
276
- // one this element wrote onto itself, and there is no state to keep: the
277
- // rows are in the light DOM and come back as they were.
278
79
  if (!this.onPage) {
279
80
  return nothing;
280
81
  }
281
82
  const submenu = this.hasSubmenu;
282
- /* One row, two tags. Everything about it is the same but the element, which
283
- is what an `href` changes: the anchor carries the role and the tab stop
284
- rather than sitting inside something that does, for the reason the
285
- property's own note records. `Tree.Item` writes its two the same way.
286
-
287
- A disabled row renders no `href`. A disabled anchor is not a thing the
288
- platform has, and `aria-disabled` alone still navigates. */
289
83
  const linked = this.href !== '' && !this.disabled;
290
84
  const role = this.checkable || this.checked ? 'menuitemcheckbox' : 'menuitem';
291
85
  const checkedState = this.checkable || this.checked ? (this.checked ? 'true' : 'false') : nothing;
292
86
  const inside = html `<span part="start"><slot name="start"></slot></span>
293
87
  <slot></slot>
294
- ${
295
- // One trailing column, and a row is never both: a way into a page has
296
- // nothing to tick, and a choice opens nothing. The tick sits after the
297
- // label rather than before it, so the labels start at the edge of the
298
- // row and the marks line up down the side they are read from.
299
- //
300
- // `data-submenu` is what tells the two apart from CSS. They share a
301
- // part name because they share a column, and only one of them is
302
- // directional: a caret has to mirror in a right-to-left document and a
303
- // tick must not, which Unicode's own mirroring list agrees on. The
304
- // attribute is on an element in this shadow root rather than on the
305
- // host, so it is the divider's `data-empty` and not a hydration
306
- // hazard. `hasSubmenu` is derived from slot assignment and has no
307
- // attribute a selector could read instead.
308
- submenu
88
+ ${submenu
309
89
  ? html `<svg
310
90
  part="mark"
311
91
  data-submenu
@@ -9,100 +9,18 @@ import { caretLeft } from '@fusion.dev/helix-icons';
9
9
  import { html, nothing } from 'lit';
10
10
  import { property } from 'lit/decorators.js';
11
11
  import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
12
- /**
13
- * @summary A menu of commands, opened from a trigger.
14
- *
15
- * @description
16
- * The trigger is slotted and stays the consumer's own element, the same way the
17
- * tooltip takes one. The surface is rendered here, in the top layer through the
18
- * popover API, so it clears any ancestor with `overflow: hidden` without a
19
- * `z-index` anywhere.
20
- *
21
- * **It drills down rather than flying out.** A submenu replaces what is on
22
- * screen and a back row returns, instead of opening a second panel beside the
23
- * first. Panels beside panels need a diagonal path a pointer has to travel
24
- * without straying, they run off the edge of a narrow window, and on a touch
25
- * screen there is no hover to open them with at all. One panel that changes
26
- * what it holds has none of those problems and reads the same on a phone.
27
- *
28
- * The open page is held here rather than in each item, because only one page
29
- * shows at a time and something has to know which. Items are told by property,
30
- * not by attribute: an attribute written onto a consumer's element after it
31
- * upgrades is a hydration mismatch waiting to happen.
32
- *
33
- * @since 0.1.0
34
- * @status experimental
35
- * @category overlays
36
- * @aliases Dropdown, Action menu
37
- * @documentation https://helix.fusion.dev/components/menu
38
- *
39
- * @slot - The items.
40
- * @slot trigger - What opens it.
41
- *
42
- * @event {HelixBeforeCloseEvent} helix-before-close - Fired before it closes, and cancelable.
43
- * @event {HelixBeforeOpenEvent} helix-before-open - Fired before it opens, and cancelable.
44
- * @event {HelixSelectEvent} helix-select - Fired when an item is chosen. Cancelable: refusing it takes the decision and nothing moves.
45
- * @event {Event} toggle - Says the state moved, carrying `newState` and `oldState`.
46
- *
47
- * @csspart back - The control that returns from a submenu.
48
- * @csspart back-mark - The glyph on the back control of a submenu.
49
- * @csspart empty - Shown when there are no items to show.
50
- * @csspart header - The bar above a submenu, holding the way back and its name.
51
- * @csspart loading - Shown while the items are still on their way.
52
- * @csspart surface - The floating panel the menu is drawn on.
53
- * @csspart title - The name of the submenu that is showing.
54
- */
55
12
  let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinControllerOpen(HelixElement)) {
56
13
  constructor() {
57
14
  super(...arguments);
58
- /**
59
- * What the row returning from a submenu reads.
60
- */
61
15
  this.backLabel = 'Back';
62
- /**
63
- * Accessible name for the surface.
64
- */
65
16
  this.label = 'Menu';
66
- /**
67
- * Space between the trigger and the surface, in pixels.
68
- */
69
17
  this.offset = 4;
70
- /**
71
- * Preferred side.
72
- *
73
- * It moves if that side does not fit.
74
- */
75
18
  this.placement = 'bottom-start';
76
- /**
77
- * Which page is showing, and what is on it.
78
- *
79
- * A controller rather than fields here, because none of this is about menus:
80
- * a settings panel or a navigation tree wants the same shape. It holds a real
81
- * stack, so `back()` returns one level rather than to the root.
82
- */
83
19
  this.drilldown = new DrilldownController(this, {
84
20
  isRow: (element) => 'isMenuItem' in element && element.isMenuItem === true,
85
21
  root: () => [...this.children].filter((child) => child.slot !== 'trigger'),
86
22
  slot: 'submenu',
87
23
  });
88
- /**
89
- * Moving between rows, which is not this element's own business any more.
90
- *
91
- * `selects` false, because arriving at Delete must not delete: moving and
92
- * choosing are one act in a radio group and two acts here, which is the
93
- * distinction the controller was already drawing for `Tabs`.
94
- *
95
- * The origin is the selection, and `focus` is the wrong reading however right
96
- * it looks: that origin also carries a rule a menu must not follow, that a
97
- * press which did not come from the set moves nothing. That rule is there for
98
- * a tab panel, which is a tab stop beside the tabs; a menu's panel holds the
99
- * rows and nothing else, and Down on a menu that has just opened is how a
100
- * reader gets to the first row at all.
101
- *
102
- * The horizontal pair is reserved rather than released. Right opens a
103
- * submenu and left comes back, which are the platform's own bindings for a
104
- * menu that drills, so those keys are spoken for here rather than unhandled.
105
- */
106
24
  this.keys = new KeyboardNavigationController(this, {
107
25
  items: () => this.items,
108
26
  reserved: () => ['ArrowLeft', 'ArrowRight'],
@@ -110,9 +28,6 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
110
28
  });
111
29
  this.panel = new PopoverController(this, {
112
30
  anchor: () => this.trigger,
113
- // The host is `display: contents`, so it has no box a press can land in.
114
- // What counts as inside is the surface, plus the trigger, whose own click
115
- // handler decides what a press there means.
116
31
  boundary: () => this,
117
32
  offset: () => this.offset,
118
33
  onDismiss: () => this.hide(),
@@ -126,34 +41,17 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
126
41
  }
127
42
  const chosen = item;
128
43
  if (chosen.hasSubmenu) {
129
- // A row that opens a page has not chosen anything, so its select does not
130
- // leave this element: a consumer listening for a choice would otherwise
131
- // hear one every time a reader looked in a submenu.
132
44
  event.stopPropagation();
133
45
  this.enter(chosen);
134
46
  return;
135
47
  }
136
48
  this.hide();
137
49
  };
138
- /**
139
- * Keeps whatever has focus in sight, however it got there.
140
- *
141
- * On `focusin` rather than in the branches that move focus: a row reached by
142
- * Tab, or focused by the drilldown on the way into a page, can otherwise sit
143
- * below the fold of a scrolled panel.
144
- */
145
50
  this.handleFocusIn = (event) => {
146
51
  const item = this.items.find((row) => row.contains(event.target));
147
52
  if (!item) {
148
53
  return;
149
54
  }
150
- /* `surface`, which is the part this template renders and the name the
151
- whole library spells this box with.
152
-
153
- Keep the two in step. A lookup asking for a name the template does not
154
- render finds null, and `scrollItemIntoView` then runs with no scroller and
155
- takes the page behind the menu with it, which is the one thing that method
156
- exists to prevent. */
157
55
  const surface = this.renderRoot.querySelector('[part="surface"]');
158
56
  scrollItemIntoView(item, { padding: 4, ...(surface ? { scroller: surface } : {}) });
159
57
  };
@@ -171,13 +69,6 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
171
69
  this.leave();
172
70
  }
173
71
  else if (event.key === 'Tab') {
174
- /* Out, and the panel goes with it.
175
- *
176
- * Nothing else closes the menu when focus leaves it, so tabbing out of an
177
- * open one would leave a shadowed panel in the top layer sitting over
178
- * whatever the reader tabbed to. Deliberately not prevented: focus carries on
179
- * outward, which is what the reader asked for. And deliberately not a
180
- * focus trap, because a menu is not a modal and Tab is a way out of one. */
181
72
  this.hide();
182
73
  return;
183
74
  }
@@ -195,87 +86,51 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
195
86
  }
196
87
  };
197
88
  }
198
- /**
199
- * Tells a slotted trigger the disclosure state, by property.
200
- *
201
- * A control that opens a menu has to say so. By property rather than by
202
- * attribute, which is the class note's rule about items applied one level up:
203
- * an attribute written onto a consumer's element after it upgrades is a
204
- * hydration mismatch waiting to happen.
205
- */
206
89
  tellTrigger() {
207
90
  const slot = this.renderRoot?.querySelector('slot[name="trigger"]');
208
91
  tellDisclosure(slot?.assignedElements({ flatten: false })[0], this.open);
209
92
  }
210
- /** The element the surface opens against. */
211
93
  get trigger() {
212
94
  return anchorElement(this.renderRoot?.querySelector('slot[name="trigger"]'), this);
213
95
  }
214
- /** The items on the page that is showing, which is what the keys move over. */
215
96
  get items() {
216
- /* Disabled rows are not there for the keyboard.
217
- *
218
- * A disabled row renders no `tabindex` and cannot take focus, so leaving it
219
- * in the list has the arrow keys compute a move onto it, call `focus()` on
220
- * something unfocusable, and stop: the keyboard is then stuck one row above
221
- * every disabled item in the menu. `Drilldown` filters for the same
222
- * reason. */
223
97
  return this.drilldown.rows.filter((row) => !row.disabled);
224
98
  }
225
- /** Opens the menu. */
226
99
  show() {
227
100
  if (this.open || !this.requestOpen(true)) {
228
101
  return;
229
102
  }
230
103
  this.updateComplete.then(() => {
231
- // Raised in `updated()`, which the state change has just scheduled.
232
104
  this.items[0]?.focus();
233
105
  });
234
106
  }
235
- /** Closes the menu, and forgets which page was showing. */
236
107
  hide() {
237
108
  if (!this.open || !this.requestOpen(false)) {
238
109
  return;
239
110
  }
240
111
  this.panel.hide();
241
112
  this.drilldown.reset();
242
- /* Only where the menu still had it. A dismissal that did not take focus with
243
- it, a press somewhere else on the page, should leave focus where the
244
- reader put it rather than yanking it back to the trigger. `Popover`
245
- guards the same way, and `document.activeElement` retargets to the item
246
- host here, which is what makes the containment test work. */
247
113
  if (this.contains(document.activeElement)) {
248
114
  this.trigger.focus();
249
115
  }
250
116
  }
251
- /** Shows an item's submenu in place of the current page. */
252
117
  enter(item) {
253
118
  if (!this.drilldown.enter(item)) {
254
119
  return;
255
120
  }
256
121
  this.updateComplete.then(() => this.items[0]?.focusItem());
257
122
  }
258
- /** Returns to the page above, or closes when there is none. */
259
123
  leave() {
260
124
  if (this.drilldown.atRoot) {
261
125
  this.hide();
262
126
  return;
263
127
  }
264
- /* Held before going back, because focus returns to the row that opened the
265
- page being left: a reader who drills in and comes straight out should be
266
- on the row they pressed, not at the top of a list they have already
267
- read. */
268
128
  const leaving = this.drilldown.page;
269
129
  this.drilldown.back();
270
130
  this.updateComplete.then(() => leaving?.focus());
271
131
  }
272
132
  connectedCallback() {
273
133
  super.connectedCallback();
274
- /* Capture, so a drill-in never reaches a consumer's own listener on this
275
- same host. The guarantee the class documents is that choosing a row that
276
- opens a submenu is navigation rather than a choice, and stopping it in the
277
- bubble phase stops it for ancestors and not for the siblings registered
278
- here. `Drilldown` learned this already. */
279
134
  this.addEventListener('helix-select', this.handleSelect, true);
280
135
  this.addEventListener('keydown', this.handleKeydown);
281
136
  this.addEventListener('focusin', this.handleFocusIn);
@@ -288,15 +143,8 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
288
143
  }
289
144
  updated(changed) {
290
145
  super.updated(changed);
291
- // Every update, so a trigger slotted after the first render is told too.
292
146
  this.tellTrigger();
293
- /* Put the panel where `open` says, however it got there. Raising it in
294
- `show()` and lowering it in `hide()` alone leaves a menu opened by
295
- declaring `open` visible, out of the top layer and unanchored: every
296
- imperative path works and every declarative one is half-open. */
297
147
  if (changed.has('open')) {
298
- /* Here rather than a microtask later: `updated()` runs after the render,
299
- so the panel is no longer `display: none` and `showPopover` is safe. */
300
148
  if (this.open) {
301
149
  this.panel.show();
302
150
  }
@@ -304,9 +152,6 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
304
152
  this.panel.hide();
305
153
  }
306
154
  }
307
- // Every item shows or hides itself: which page is open is this element's
308
- // business, and where an item belongs is the item's. Set as a property, so
309
- // nothing is written onto a consumer's element.
310
155
  for (const child of this.children) {
311
156
  const item = child;
312
157
  if (item.isMenuItem !== true) {
@@ -315,17 +160,6 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
315
160
  this.mark(item, this.drilldown.atRoot);
316
161
  }
317
162
  }
318
- /**
319
- * Walks the tree marking which rows are on the page showing.
320
- *
321
- * Recursive rather than two levels by hand: without `showingSubmenu` on a
322
- * nested row, drilling into a third-level submenu renders the header over an
323
- * empty panel, the page existing while its rows are still marked as belonging
324
- * to the level above. `Drilldown.mark()` is the same walk.
325
- *
326
- * `slot === 'submenu'` rather than every child, because a row's other
327
- * children are its label and its icons.
328
- */
329
163
  mark(item, onPage) {
330
164
  item.onPage = onPage;
331
165
  item.showingSubmenu = item === this.drilldown.page;
@@ -347,11 +181,7 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
347
181
  data-drilled=${this.drilldown.atRoot ? nothing : ''}
348
182
  ?hidden=${!this.open}
349
183
  >
350
- ${
351
- // A header rather than a row in the list. The way back is not one of
352
- // the things on this page, and putting it in the list means an arrow
353
- // press lands on it as though it were.
354
- this.drilldown.atRoot
184
+ ${this.drilldown.atRoot
355
185
  ? nothing
356
186
  : html `<div part="header">
357
187
  <button