@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.
- package/custom-elements.json +234 -234
- package/dist/accordion/item/accordion-item.js +0 -108
- package/dist/accordion/root/accordion-root.js +0 -33
- package/dist/announcement/root/announcement-root.js +1 -76
- package/dist/app-header/root/app-header-root.js +0 -84
- package/dist/avatar/group/avatar-group.js +0 -77
- package/dist/avatar/root/avatar-root.js +0 -72
- package/dist/badge/root/badge-root.js +0 -67
- package/dist/bar-chart/root/bar-chart-root.js +3 -263
- package/dist/blockquote/root/blockquote-root.js +0 -46
- package/dist/breadcrumbs/breadcrumb/breadcrumb.js +0 -72
- package/dist/breadcrumbs/root/breadcrumbs-root.js +0 -52
- package/dist/button/root/button-root.js +0 -215
- package/dist/button-group/root/button-group-root.js +0 -30
- package/dist/callout/root/callout-root.js +0 -52
- package/dist/card/root/card-root.js +0 -41
- package/dist/carousel/root/carousel-root.d.ts +2 -2
- package/dist/carousel/root/carousel-root.js +2 -362
- package/dist/chat/composer/chat-composer.js +0 -131
- package/dist/chat/message/chat-message.js +0 -137
- package/dist/chat/root/chat-root.js +0 -193
- package/dist/checkbox/root/checkbox-root.js +0 -113
- package/dist/checkbox-group/root/checkbox-group-root.js +1 -157
- package/dist/code/root/code-root.js +0 -26
- package/dist/code-block/root/code-block-root.js +0 -100
- package/dist/color-input/root/color-input-root.js +0 -217
- package/dist/color-picker/color.js +0 -69
- package/dist/color-picker/root/color-picker-root.js +0 -272
- package/dist/combobox/root/combobox-root.js +0 -428
- package/dist/command/root/command-root.js +0 -177
- package/dist/copy-button/root/copy-button-root.js +0 -134
- package/dist/data-table/root/data-table-root.js +0 -336
- package/dist/data-table/root/order.js +0 -100
- package/dist/date-input/root/date-input-root.js +1 -398
- package/dist/date-picker/root/date-picker-root.js +0 -358
- package/dist/dialog/root/dialog-root.js +0 -189
- package/dist/divider/root/divider-root.js +0 -71
- package/dist/donut-chart/root/donut-chart-root.js +0 -214
- package/dist/drawer/root/drawer-root.js +0 -116
- package/dist/drilldown/item/drilldown-item.js +0 -82
- package/dist/drilldown/root/drilldown-root.js +0 -91
- package/dist/element-events.generated.js +0 -5
- package/dist/empty/root/empty-root.js +0 -35
- package/dist/file-input/root/file-input-root.js +0 -232
- package/dist/format-date/root/format-date-root.js +0 -95
- package/dist/heatmap/root/buckets.js +0 -57
- package/dist/heatmap/root/heatmap-root.js +0 -313
- package/dist/icon/root/icon-root.js +0 -48
- package/dist/icon-button/root/icon-button-root.js +0 -30
- package/dist/image/root/image-root.js +0 -174
- package/dist/index.js +0 -9
- package/dist/input/root/input-root.js +0 -228
- package/dist/kbd/root/kbd-root.js +0 -117
- package/dist/legend/root/legend-root.js +0 -73
- package/dist/line-chart/root/line-chart-root.js +0 -172
- package/dist/list/item/list-item.js +0 -92
- package/dist/list/root/list-root.js +0 -26
- package/dist/listbox/root/listbox-root.js +2 -429
- package/dist/loader/root/loader-root.js +0 -40
- package/dist/megamenu/root/megamenu-root.js +0 -284
- package/dist/menu/item/menu-item.js +1 -221
- package/dist/menu/root/menu-root.js +1 -171
- package/dist/meter/root/meter-root.js +1 -152
- package/dist/navigation/root/navigation-root.js +0 -56
- package/dist/notifications/notification/notification.js +0 -159
- package/dist/notifications/root/notifications-root.js +0 -148
- package/dist/number-input/root/number-input-root.js +1 -263
- package/dist/option/group/option-group.js +0 -26
- package/dist/option/root/option-root.js +0 -157
- package/dist/outline/root/outline-root.js +0 -292
- package/dist/pagination/root/pagination-root.js +0 -361
- package/dist/pagination/size/pagination-size.js +0 -104
- package/dist/pagination/summary/pagination-summary.js +0 -90
- package/dist/pin-input/root/pin-input-root.js +0 -276
- package/dist/popover/root/popover-root.js +0 -103
- package/dist/progress/root/progress-root.js +1 -133
- package/dist/qr-code/root/qr-code-root.js +0 -96
- package/dist/quote/root/quote-root.js +0 -25
- package/dist/radio/group/radio-group.js +0 -81
- package/dist/radio/root/radio-root.js +0 -85
- package/dist/rating/root/rating-root.js +0 -220
- package/dist/scroll-area/root/scroll-area-root.js +0 -100
- package/dist/select/root/select-root.js +2 -218
- package/dist/skeleton/root/skeleton-root.js +0 -20
- package/dist/slider/root/slider-root.js +1 -267
- package/dist/sparkline/root/sparkline-root.js +3 -164
- package/dist/speed-dial/item/speed-dial-item.js +0 -52
- package/dist/speed-dial/root/speed-dial-root.js +1 -116
- package/dist/splitter/root/splitter-root.js +0 -489
- package/dist/stat/group/stat-group.js +0 -18
- package/dist/stat/root/stat-root.js +0 -23
- package/dist/steps/root/steps-root.js +0 -62
- package/dist/steps/step/step.js +0 -131
- package/dist/switch/root/switch-root.js +0 -80
- package/dist/table/cell/table-cell.js +0 -23
- package/dist/table/column/table-column.js +0 -158
- package/dist/table/root/table-root.js +0 -82
- package/dist/table/row/table-row.js +0 -21
- package/dist/tabs/panel/tab-panel.js +0 -49
- package/dist/tabs/root/tabs-root.js +0 -98
- package/dist/tabs/tab/tab.js +0 -59
- package/dist/tag/root/tag-root.js +0 -31
- package/dist/textarea/root/textarea-root.js +0 -162
- package/dist/time-input/root/time-input-root.js +0 -283
- package/dist/time-picker/root/time-picker-root.js +0 -326
- package/dist/timeline/item/timeline-item.js +0 -66
- package/dist/timeline/root/timeline-root.js +0 -41
- package/dist/toast/item/toast-item.js +0 -165
- package/dist/toast/root/toast-root.js +0 -51
- package/dist/toggle/group/toggle-group.js +0 -150
- package/dist/toggle/root/toggle-root.js +0 -78
- package/dist/toolbar/root/toolbar-root.js +0 -116
- package/dist/tooltip/root/tooltip-root.js +0 -427
- package/dist/tour/root/tour-root.js +0 -130
- package/dist/tour/stop/tour-stop.js +1 -127
- package/dist/tree/item/tree-item.js +0 -198
- package/dist/tree/root/tree-root.js +0 -97
- package/dist/trend/root/trend-root.js +0 -53
- package/dist/video/root/video-root.d.ts +8 -12
- package/dist/video/root/video-root.js +5 -756
- package/dist/video-playlist/item/video-playlist-item.js +1 -90
- package/dist/video-playlist/menu/video-playlist-menu.js +1 -132
- package/dist/video-playlist/root/video-playlist-root.js +0 -113
- package/dist/virtual-list/root/virtual-list-root.js +0 -105
- package/jsx.d.ts +1408 -2576
- package/package.json +6 -6
- 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
|