@fusion.dev/helix-headless 0.165.0 → 0.166.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.
@@ -4,7 +4,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
4
4
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
- import { ariaChord, auditName, customElement, deepActiveElement, HelixBeforeToggleEvent, HelixElement, HelixSelectEvent, isRtl, onApple, onUnlessFalse, PopoverController, requestChange, slotFilled, } from '@fusion.dev/helix-core';
7
+ import { ariaChord, auditName, customElement, deepActiveElement, followLink, HelixBeforeToggleEvent, HelixElement, HelixSelectEvent, hostAria, isRtl, onApple, onUnlessFalse, PopoverController, requestChange, slotFilled, } from '@fusion.dev/helix-core';
8
8
  import { caretRight, check } from '@fusion.dev/helix-icons';
9
9
  import { html, nothing } from 'lit';
10
10
  import { property, state } from 'lit/decorators.js';
@@ -21,6 +21,7 @@ let HelixHeadlessMenuItem = class HelixHeadlessMenuItem extends HelixElement {
21
21
  this.exclusive = false;
22
22
  this.expanded = false;
23
23
  this.flyout = false;
24
+ this.highlighted = false;
24
25
  this.href = '';
25
26
  this.onPage = true;
26
27
  this.rel = '';
@@ -28,6 +29,7 @@ let HelixHeadlessMenuItem = class HelixHeadlessMenuItem extends HelixElement {
28
29
  this.showingSubmenu = false;
29
30
  this.target = '';
30
31
  this.value = '';
32
+ this.virtual = false;
31
33
  this.isMenuItem = true;
32
34
  this.keepsOpen = false;
33
35
  this.submenu = false;
@@ -90,6 +92,7 @@ let HelixHeadlessMenuItem = class HelixHeadlessMenuItem extends HelixElement {
90
92
  willUpdate(changed) {
91
93
  super.willUpdate(changed);
92
94
  this.toggleState('checked', this.checked);
95
+ this.toggleState('highlighted', this.virtual && this.highlighted);
93
96
  if (!this.childrenKnown) {
94
97
  return;
95
98
  }
@@ -109,6 +112,11 @@ let HelixHeadlessMenuItem = class HelixHeadlessMenuItem extends HelixElement {
109
112
  }
110
113
  updated(changed) {
111
114
  super.updated(changed);
115
+ hostAria(this.internals, {
116
+ describedBy: this.onHost
117
+ ? [...this.renderRoot.querySelectorAll('#description, #end-words')].filter((slot) => slot.id === 'description' ? this.description : this.end)
118
+ : [],
119
+ });
112
120
  if (this.partNamed('item')) {
113
121
  auditName(this, () => this.partNamed('item') ?? undefined, 'Give the row words in its default slot, or a label on the icon it holds.');
114
122
  }
@@ -126,7 +134,7 @@ let HelixHeadlessMenuItem = class HelixHeadlessMenuItem extends HelixElement {
126
134
  const ids = [this.description ? 'description' : '', this.end ? 'end-words' : '']
127
135
  .filter((id) => id !== '')
128
136
  .join(' ');
129
- return ids === '' ? nothing : ids;
137
+ return ids === '' ? null : ids;
130
138
  }
131
139
  renderEnd() {
132
140
  const kbd = this.tag('kbd');
@@ -151,6 +159,9 @@ let HelixHeadlessMenuItem = class HelixHeadlessMenuItem extends HelixElement {
151
159
  this.choose();
152
160
  }
153
161
  get active() {
162
+ if (this.virtual) {
163
+ return this.highlighted;
164
+ }
154
165
  const active = deepActiveElement();
155
166
  return active !== null && active.getRootNode() === this.renderRoot;
156
167
  }
@@ -201,7 +212,11 @@ let HelixHeadlessMenuItem = class HelixHeadlessMenuItem extends HelixElement {
201
212
  const spaceKeeps = space && this.choice && !this.hasSubmenu;
202
213
  this.keepsOpen = this.closeOnSelect === undefined ? spaceKeeps : !this.closeOnSelect;
203
214
  this.tick();
204
- this.dispatchEvent(new HelixSelectEvent({ value: this.value }));
215
+ if (this.dispatchEvent(new HelixSelectEvent({ value: this.value })) &&
216
+ this.virtual &&
217
+ this.href !== '') {
218
+ followLink(this.ownerDocument, this);
219
+ }
205
220
  }
206
221
  renderMark(submenu) {
207
222
  if (submenu) {
@@ -220,19 +235,31 @@ let HelixHeadlessMenuItem = class HelixHeadlessMenuItem extends HelixElement {
220
235
  }
221
236
  return nothing;
222
237
  }
238
+ get onHost() {
239
+ return this.virtual && this.onPage && !this.showingSubmenu;
240
+ }
223
241
  render() {
242
+ const submenu = this.hasSubmenu;
243
+ const ticks = this.choice;
244
+ const role = ticks ? (this.exclusive ? 'menuitemradio' : 'menuitemcheckbox') : 'menuitem';
245
+ const checkedState = ticks ? (this.checked ? 'true' : 'false') : null;
246
+ const keys = this.shortcut === '' ? null : ariaChord(this.shortcut, !this.hydrating && onApple());
247
+ const host = this.onHost;
248
+ const internals = this.internals;
249
+ internals.role = host ? role : null;
250
+ internals.ariaChecked = host ? checkedState : null;
251
+ internals.ariaDisabled = host && this.disabled ? 'true' : null;
252
+ internals.ariaHasPopup = host && submenu ? 'menu' : null;
253
+ internals.ariaExpanded = host && this.flyout && submenu ? String(this.expanded) : null;
254
+ internals.ariaKeyShortcuts = host ? keys : null;
255
+ const own = (value) => (host || value === null ? nothing : value);
224
256
  if (this.showingSubmenu) {
225
257
  return html `<slot name="submenu" @slotchange=${this.readSubmenu}></slot>`;
226
258
  }
227
259
  if (!this.onPage) {
228
260
  return nothing;
229
261
  }
230
- const submenu = this.hasSubmenu;
231
- const linked = this.href !== '' && !this.disabled;
232
- const ticks = this.choice;
233
- const role = ticks ? (this.exclusive ? 'menuitemradio' : 'menuitemcheckbox') : 'menuitem';
234
- const checkedState = ticks ? (this.checked ? 'true' : 'false') : nothing;
235
- const keys = this.shortcut === '' ? nothing : ariaChord(this.shortcut, !this.hydrating && onApple());
262
+ const linked = this.href !== '' && !this.disabled && !this.virtual;
236
263
  const inside = html `<span part="start" ?data-empty=${!this.start}
237
264
  ><slot name="start" @slotchange=${this.readStart}></slot
238
265
  ></span>
@@ -249,11 +276,11 @@ let HelixHeadlessMenuItem = class HelixHeadlessMenuItem extends HelixElement {
249
276
  rel=${this.rel || nothing}
250
277
  ?download=${this.download}
251
278
  role=${role}
252
- aria-checked=${checkedState}
253
- aria-describedby=${this.describedBy}
279
+ aria-checked=${own(checkedState)}
280
+ aria-describedby=${own(this.describedBy)}
254
281
  aria-haspopup=${submenu ? 'menu' : nothing}
255
282
  aria-expanded=${this.flyout && submenu ? String(this.expanded) : nothing}
256
- aria-keyshortcuts=${keys}
283
+ aria-keyshortcuts=${own(keys)}
257
284
  tabindex="-1"
258
285
  @click=${this.handleClick}
259
286
  @keydown=${this.handleKeydown}
@@ -261,14 +288,14 @@ let HelixHeadlessMenuItem = class HelixHeadlessMenuItem extends HelixElement {
261
288
  : html `<div
262
289
  part="item"
263
290
  id=${this.flyout ? 'row' : nothing}
264
- role=${role}
265
- aria-checked=${checkedState}
266
- aria-describedby=${this.describedBy}
267
- aria-disabled=${this.disabled ? 'true' : nothing}
268
- aria-haspopup=${submenu ? 'menu' : nothing}
269
- aria-expanded=${this.flyout && submenu ? String(this.expanded) : nothing}
270
- aria-keyshortcuts=${keys}
271
- tabindex=${this.disabled ? nothing : '-1'}
291
+ role=${own(role)}
292
+ aria-checked=${own(checkedState)}
293
+ aria-describedby=${own(this.describedBy)}
294
+ aria-disabled=${own(this.disabled ? 'true' : null)}
295
+ aria-haspopup=${own(submenu ? 'menu' : null)}
296
+ aria-expanded=${own(this.flyout && submenu ? String(this.expanded) : null)}
297
+ aria-keyshortcuts=${own(keys)}
298
+ tabindex=${own(this.disabled ? null : '-1')}
272
299
  @click=${this.handleClick}
273
300
  @keydown=${this.handleKeydown}
274
301
  >${inside}</div>`}
@@ -307,6 +334,9 @@ __decorate([
307
334
  __decorate([
308
335
  property({ attribute: false })
309
336
  ], HelixHeadlessMenuItem.prototype, "flyout", void 0);
337
+ __decorate([
338
+ property({ attribute: false })
339
+ ], HelixHeadlessMenuItem.prototype, "highlighted", void 0);
310
340
  __decorate([
311
341
  property()
312
342
  ], HelixHeadlessMenuItem.prototype, "href", void 0);
@@ -328,6 +358,9 @@ __decorate([
328
358
  __decorate([
329
359
  property()
330
360
  ], HelixHeadlessMenuItem.prototype, "value", void 0);
361
+ __decorate([
362
+ property({ attribute: false })
363
+ ], HelixHeadlessMenuItem.prototype, "virtual", void 0);
331
364
  __decorate([
332
365
  state()
333
366
  ], HelixHeadlessMenuItem.prototype, "submenu", void 0);
@@ -65,6 +65,21 @@ declare const HelixHeadlessMenu_base: (new (...args: any[]) => import("@fusion.d
65
65
  * opens it from the keyboard, and the trigger is described by
66
66
  * `long-press-label`, which says so.
67
67
  *
68
+ * **A filter field.** With `filterable`, the menu opens with focus in a field
69
+ * above its rows, and typing narrows the rows of the page the keys move in,
70
+ * as React Aria's `Autocomplete` around a `Menu` does. A row is kept where its
71
+ * words hold the filter, in any case, or where `filterMatch` says so. Each
72
+ * page starts unfiltered, and the filter is cleared as the menu closes. Where
73
+ * typing keeps no row, the menu shows `no-results-text`. Focus stays in the
74
+ * field: Up and Down Arrow move the highlight through the rows that are left,
75
+ * Enter chooses the highlighted row, Right Arrow opens its submenu, Left Arrow
76
+ * on a submenu goes back, and Escape clears the filter and then closes the
77
+ * menu. Home, End and the other keys move the caret and type. The field names
78
+ * the highlighted row with `aria-activedescendant`, and the rows are a `menu`
79
+ * after the field. Cancel `helix-filter` to keep every row
80
+ * showing and narrow the rows yourself, such as by replacing them with rows
81
+ * from a server.
82
+ *
68
83
  * @since 0.1.0
69
84
  * @status stable
70
85
  * @category overlays
@@ -72,10 +87,12 @@ declare const HelixHeadlessMenu_base: (new (...args: any[]) => import("@fusion.d
72
87
  * @documentation https://fusion.dev/helix/components/menu
73
88
  *
74
89
  * @slot - The items.
90
+ * @slot no-results - What the menu shows when the filter keeps no rows, for content richer than `no-results-text`.
75
91
  * @slot trigger - The control that opens the menu, or with `context` the area a context menu opens over.
76
92
  *
77
93
  * @event {HelixBeforeCloseEvent} helix-before-close - The menu is about to close. Cancelable: cancel it to keep it open.
78
94
  * @event {HelixBeforeOpenEvent} helix-before-open - The menu is about to open. Cancelable: cancel it to keep it closed.
95
+ * @event {HelixFilterEvent} helix-filter - The text in the filter field changed, with the new text in `detail.value`. Cancelable: cancel it to keep every row showing and narrow the rows yourself.
79
96
  * @event {HelixSelectEvent} helix-select - An item was chosen. Cancelable: cancel it to keep the menu open.
80
97
  * @event {ToggleEvent} toggle - The menu opened or closed. The event carries `newState` and `oldState`.
81
98
  *
@@ -85,7 +102,8 @@ declare const HelixHeadlessMenu_base: (new (...args: any[]) => import("@fusion.d
85
102
  * @csspart header - The bar above a submenu, holding the way back and its name.
86
103
  * @csspart live - A live region that repeats the empty and loading text, so screen readers announce it when it changes. Visually hidden in the styled tier.
87
104
  * @csspart loading - The text shown while the items are loading.
88
- * @csspart surface - The floating panel the menu is drawn on.
105
+ * @csspart search-field - With `filterable`, the field that narrows the rows.
106
+ * @csspart surface - The floating panel the menu is drawn on. It is the `menu` itself, and with `filterable` it holds the filter field and then the `menu`.
89
107
  * @csspart title - The name of the submenu that is showing.
90
108
  *
91
109
  * @cssproperty --floating-anchor-inline-size - The trigger's width, written on the panel while it is open, as in `min-inline-size: var(--floating-anchor-inline-size)`. Read it; a value set here is written over.
@@ -123,6 +141,31 @@ export declare class HelixHeadlessMenu extends HelixHeadlessMenu_base {
123
141
  * asks for one, with a right-click, a long press, Shift+F10 or the menu key.
124
142
  */
125
143
  context: boolean;
144
+ /**
145
+ * The text the rows are filtered by, which the filter field holds.
146
+ *
147
+ * Cleared as the menu closes and as a submenu opens or closes, so each page
148
+ * starts with every row showing.
149
+ */
150
+ filter: string;
151
+ /**
152
+ * Whether the menu shows a field above its rows that narrows them as the
153
+ * reader types, with focus staying in the field.
154
+ */
155
+ filterable: boolean;
156
+ /**
157
+ * The filter field's name and placeholder.
158
+ */
159
+ filterLabel: string;
160
+ /**
161
+ * Whether a row is kept by the filter: a function given the row's words and
162
+ * the filter, trimmed, that returns `true` to keep it showing, as React
163
+ * Aria's `Autocomplete` takes `filter`. See Listbox's `filterMatch`.
164
+ *
165
+ * Absent, a row is kept where its words hold the filter, in any case. A
166
+ * property only, there being no way to write a function in markup.
167
+ */
168
+ filterMatch?: ((text: string, filter: string) => boolean) | undefined;
126
169
  /**
127
170
  * Accessible name for the surface.
128
171
  *
@@ -153,6 +196,10 @@ export declare class HelixHeadlessMenu extends HelixHeadlessMenu_base {
153
196
  * to open the menu. Translate it with the rest of your interface.
154
197
  */
155
198
  longPressLabel: string;
199
+ /**
200
+ * The text shown when the filter keeps no rows.
201
+ */
202
+ noResultsText: string;
156
203
  /**
157
204
  * Space between the trigger and the surface, in pixels.
158
205
  */
@@ -234,6 +281,35 @@ export declare class HelixHeadlessMenu extends HelixHeadlessMenu_base {
234
281
  private get trigger();
235
282
  /** The items on the page that is showing, which is what the keys move over. */
236
283
  private get items();
284
+ /** The element that is the `menu`: the surface, or with `filterable` the box after the field. */
285
+ private get menuBox();
286
+ /** The filter field, with `filterable`. */
287
+ private get field();
288
+ /** The submenu the filter narrows, or null for the top page. */
289
+ private filterPage;
290
+ /** The filter a listener refused, which narrows nothing. */
291
+ private refused;
292
+ /** Whether the filter is narrowing the rows. */
293
+ private get filtering();
294
+ /** Whether the filter keeps a row: one of another page, or one whose words it matches. */
295
+ private kept;
296
+ /**
297
+ * Asks to filter by new text, which the field and `filter` then hold. A
298
+ * refusal keeps every row showing, for a page that narrows them itself.
299
+ */
300
+ private requestFilter;
301
+ private readonly handleFilterInput;
302
+ /** Filters by what the reader typed, and highlights the first row kept. */
303
+ private filterBy;
304
+ /** The row the keys reached while focus stays in the filter field. */
305
+ private highlighted;
306
+ /**
307
+ * Moves the reader to a row: focus, or with `filterable` the highlight,
308
+ * which the field names with `aria-activedescendant`. An element reference,
309
+ * because the field is in this shadow root and the row in the tree around
310
+ * it, the one direction a reference crosses.
311
+ */
312
+ private reach;
237
313
  /** Opens the menu. */
238
314
  show(): void;
239
315
  /** Opens the menu with focus on its first or its last row. */
@@ -260,6 +336,8 @@ export declare class HelixHeadlessMenu extends HelixHeadlessMenu_base {
260
336
  * below the fold of a scrolled panel.
261
337
  */
262
338
  private readonly handleFocusIn;
339
+ /** Scrolls the panel that holds a row to show it. */
340
+ private revealRow;
263
341
  private readonly handleKeydown;
264
342
  /**
265
343
  * Down and Up on the trigger open the menu on its first and its last row.
@@ -276,6 +354,11 @@ export declare class HelixHeadlessMenu extends HelixHeadlessMenu_base {
276
354
  * `open-on="long-press"`, or a touch or a pen held on a context menu's area.
277
355
  */
278
356
  private readonly handlePointerDown;
357
+ /**
358
+ * Keeps focus in the filter field through a press on a row or the way
359
+ * back, as React Aria's virtual focus does: the press still chooses.
360
+ */
361
+ private readonly handleMouseDown;
279
362
  private readonly handlePointerMove;
280
363
  private readonly handlePointerUp;
281
364
  private readonly handlePointerCancel;