@fusion.dev/helix-headless 0.157.0 → 0.158.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 { customElement, HelixElement, HelixSelectEvent, MixinControllerOpen, matchesChord, words, } from '@fusion.dev/helix-core';
7
+ import { customElement, drawStatus, HelixElement, HelixSelectEvent, MixinControllerOpen, matchesChord, StatusController, words, } from '@fusion.dev/helix-core';
8
8
  import { nothing } from 'lit';
9
9
  import { property, state } from 'lit/decorators.js';
10
10
  import { html } from 'lit/static-html.js';
@@ -61,6 +61,7 @@ let HelixHeadlessCommand = class HelixHeadlessCommand extends MixinControllerOpe
61
61
  }
62
62
  this.requestOpen(false);
63
63
  };
64
+ this.status = new StatusController(this);
64
65
  this.handleBack = (event) => {
65
66
  if (event.key !== 'Backspace' ||
66
67
  this.page === '' ||
@@ -124,7 +125,12 @@ let HelixHeadlessCommand = class HelixHeadlessCommand extends MixinControllerOpe
124
125
  this.trail = trail;
125
126
  this.page = page;
126
127
  this.listbox?.requestFilter('');
127
- void this.updateComplete.then(() => this.searchField?.focus());
128
+ void this.updateComplete.then(() => {
129
+ this.searchField?.focus();
130
+ if (this.listName !== '') {
131
+ this.status.say(this.listName);
132
+ }
133
+ });
128
134
  }
129
135
  get listName() {
130
136
  if (this.page === '') {
@@ -210,7 +216,7 @@ let HelixHeadlessCommand = class HelixHeadlessCommand extends MixinControllerOpe
210
216
  }
211
217
  }}
212
218
  >
213
- ${this.renderListbox()}
219
+ ${this.renderListbox()}${drawStatus(this.status)}
214
220
  <!-- Handed on only while something fills it, as the list's slots are,
215
221
  so an empty header draws nothing in the dialog. -->
216
222
  <slot
@@ -231,6 +237,7 @@ let HelixHeadlessCommand = class HelixHeadlessCommand extends MixinControllerOpe
231
237
  aria-label=${this.listName}
232
238
  empty-text=${this.emptyText}
233
239
  filter-delay=${this.filterDelay}
240
+ .filterMatch=${this.filterMatch}
234
241
  filter-label=${this.placeholder}
235
242
  load-error=${this.loadError}
236
243
  loading-text=${this.loadingText}
@@ -277,7 +284,7 @@ let HelixHeadlessCommand = class HelixHeadlessCommand extends MixinControllerOpe
277
284
  }
278
285
  render() {
279
286
  if (this.inline) {
280
- return html `<slot name="header"></slot>${this.renderListbox()}${this.renderFooter(nothing)}`;
287
+ return html `<slot name="header"></slot>${this.renderListbox()}${drawStatus(this.status)}${this.renderFooter(nothing)}`;
281
288
  }
282
289
  return html `${this.renderDialog()}`;
283
290
  }
@@ -289,6 +296,9 @@ __decorate([
289
296
  __decorate([
290
297
  property({ attribute: 'filter-delay', type: Number })
291
298
  ], HelixHeadlessCommand.prototype, "filterDelay", void 0);
299
+ __decorate([
300
+ property({ attribute: false })
301
+ ], HelixHeadlessCommand.prototype, "filterMatch", void 0);
292
302
  __decorate([
293
303
  property()
294
304
  ], HelixHeadlessCommand.prototype, "hotkey", void 0);
@@ -1015,7 +1015,7 @@ let HelixHeadlessDataTable = class HelixHeadlessDataTable extends MixinCollectio
1015
1015
  : '';
1016
1016
  }
1017
1017
  }
1018
- renderCell(column, row, picked, place, stop, header) {
1018
+ renderCell(column, row, picked, place, stop, header, id) {
1019
1019
  const edge = edgeOf(column.textAlign);
1020
1020
  const placed = header && this.childField ? this.tree().placed.get(row) : undefined;
1021
1021
  const style = [
@@ -1027,6 +1027,7 @@ let HelixHeadlessDataTable = class HelixHeadlessDataTable extends MixinCollectio
1027
1027
  return html `<span
1028
1028
  part="cell${picked ? ' cell-selected' : ''}${header ? ' cell-header' : ''}"
1029
1029
  role=${header ? 'rowheader' : 'gridcell'}
1030
+ id=${id ?? nothing}
1030
1031
  tabindex=${stop ? '0' : '-1'}
1031
1032
  data-column=${place}
1032
1033
  style=${style || nothing}
@@ -1083,10 +1084,12 @@ let HelixHeadlessDataTable = class HelixHeadlessDataTable extends MixinCollectio
1083
1084
  const stop = (place) => index === stopRow && place === stopColumn;
1084
1085
  const moveKey = grips ? this.moveKeyOf(row) : '';
1085
1086
  const marked = mark?.key === moveKey ? mark.edge : undefined;
1087
+ const header = named ? `row-header-${index}` : undefined;
1086
1088
  return html `<div
1087
1089
  part="row${held?.key === moveKey ? ' row-dragging' : ''}${marked === 'before' ? ' row-drop-before' : ''}${marked === 'after' ? ' row-drop-after' : ''}"
1088
1090
  role="row"
1089
1091
  aria-rowindex=${this.rowIndex(index)}
1092
+ aria-labelledby=${header ?? nothing}
1090
1093
  aria-selected=${chosen ? String(picked) : nothing}
1091
1094
  aria-level=${placed ? placed.level : nothing}
1092
1095
  aria-posinset=${placed ? placed.position : nothing}
@@ -1103,7 +1106,7 @@ let HelixHeadlessDataTable = class HelixHeadlessDataTable extends MixinCollectio
1103
1106
  data-column=${this.selectAt}
1104
1107
  >${this.renderCheckbox(formatWords(this, 'selectRowLabel', { row: this.rowName(row, key) }), picked, false, (event) => this.toggleRow(key, event))}</span
1105
1108
  >`
1106
- : nothing}${columns.map((column, place) => this.renderCell(column, row, picked, place + offset, stop(place + offset), column === named))}
1109
+ : nothing}${columns.map((column, place) => this.renderCell(column, row, picked, place + offset, stop(place + offset), column === named, column === named ? header : undefined))}
1107
1110
  </div>`;
1108
1111
  })}`;
1109
1112
  }
@@ -63,6 +63,7 @@ export type ListItemMove = 'dragging' | 'drop-after' | 'drop-before';
63
63
  * @cssstate dragging - The row is being moved, in a `reorderable` list.
64
64
  * @cssstate drop-after - The row being moved goes after this one when it is dropped.
65
65
  * @cssstate drop-before - The row being moved goes before this one when it is dropped.
66
+ * @cssstate focus-visible - The row's cell has keyboard focus that the browser shows, in a list with `selection-mode`.
66
67
  * @cssstate selected - The row is selected, in a list that selects.
67
68
  */
68
69
  export declare class HelixHeadlessListItem extends HelixElement {
@@ -78,7 +79,9 @@ export declare class HelixHeadlessListItem extends HelixElement {
78
79
  current?: ListItemCurrent;
79
80
  /**
80
81
  * Takes the row out of reach in a list with `selection-mode`: the arrow keys
81
- * pass over it and it cannot be selected or acted on.
82
+ * pass over it and it cannot be selected or acted on. In a list with
83
+ * `disabled-behavior="selection"`, it cannot be selected and is reached and
84
+ * acted on as any row is.
82
85
  */
83
86
  disabled: boolean;
84
87
  /**
@@ -121,6 +124,12 @@ export declare class HelixHeadlessListItem extends HelixElement {
121
124
  * selected, which keeps `aria-selected` off a list that does not select.
122
125
  */
123
126
  selected?: boolean | undefined;
127
+ /**
128
+ * Whether `disabled` takes the row out of selection alone, written by a list
129
+ * with `disabled-behavior="selection"`. No attribute: it is coordination
130
+ * between elements, as `focusable` is.
131
+ */
132
+ selectionOnly: boolean;
124
133
  /**
125
134
  * Where the link opens. Use it for a row that leaves the site.
126
135
  */
@@ -137,6 +146,11 @@ export declare class HelixHeadlessListItem extends HelixElement {
137
146
  private get described();
138
147
  /** Whether a list with `selection-mode` holds this row, which makes it a row of a grid. */
139
148
  private get grouped();
149
+ /**
150
+ * Says whether the cell shows keyboard focus, as `:focus-visible` does, so a
151
+ * sheet outside can draw the ring on the row by the host.
152
+ */
153
+ private readonly handleCellFocus;
140
154
  /** @internal The cell a list with `selection-mode` moves focus to. */
141
155
  get cell(): HTMLElement | null;
142
156
  /**
@@ -16,8 +16,19 @@ let HelixHeadlessListItem = class HelixHeadlessListItem extends HelixElement {
16
16
  this.disabled = false;
17
17
  this.href = '';
18
18
  this.rel = '';
19
+ this.selectionOnly = false;
19
20
  this.target = '';
20
21
  this.value = '';
22
+ this.handleCellFocus = (event) => {
23
+ const cell = event.currentTarget;
24
+ let shown = false;
25
+ try {
26
+ shown = event.type === 'focus' && cell.matches(':focus-visible');
27
+ }
28
+ catch {
29
+ }
30
+ this.toggleState('focus-visible', shown);
31
+ };
21
32
  }
22
33
  get words() {
23
34
  return this.childrenKnown ? slottedWords(this, ['']) : '';
@@ -56,7 +67,8 @@ let HelixHeadlessListItem = class HelixHeadlessListItem extends HelixElement {
56
67
  super.willUpdate(changed);
57
68
  hostAria(this.internals, { role: this.grouped ? 'row' : 'listitem' });
58
69
  this.internals.ariaSelected = this.selected === undefined ? null : String(this.selected);
59
- this.internals.ariaDisabled = this.grouped && this.disabled ? 'true' : null;
70
+ this.internals.ariaDisabled =
71
+ this.grouped && this.disabled && !this.selectionOnly ? 'true' : null;
60
72
  this.internals.ariaCurrent =
61
73
  this.current !== undefined && this.href === '' ? this.current : null;
62
74
  this.toggleState('selected', this.selected === true);
@@ -91,6 +103,8 @@ let HelixHeadlessListItem = class HelixHeadlessListItem extends HelixElement {
91
103
  tabindex=${tabindex}
92
104
  aria-labelledby=${labelledby}
93
105
  aria-describedby=${describedby}
106
+ @focus=${this.handleCellFocus}
107
+ @blur=${this.handleCellFocus}
94
108
  >${content}<slot name="end"></slot>${this.renderGrip()}</div>`
95
109
  : html `<div
96
110
  part="row${this.current === undefined ? '' : ' row-current'}"
@@ -98,6 +112,8 @@ let HelixHeadlessListItem = class HelixHeadlessListItem extends HelixElement {
98
112
  tabindex=${tabindex}
99
113
  aria-labelledby=${labelledby}
100
114
  aria-describedby=${describedby}
115
+ @focus=${this.handleCellFocus}
116
+ @blur=${this.handleCellFocus}
101
117
  ><a
102
118
  part="link"
103
119
  href=${this.href}
@@ -137,6 +153,9 @@ __decorate([
137
153
  __decorate([
138
154
  property({ attribute: false })
139
155
  ], HelixHeadlessListItem.prototype, "selected", void 0);
156
+ __decorate([
157
+ property({ attribute: false })
158
+ ], HelixHeadlessListItem.prototype, "selectionOnly", void 0);
140
159
  __decorate([
141
160
  property()
142
161
  ], HelixHeadlessListItem.prototype, "target", void 0);
@@ -14,7 +14,9 @@ import { type ListSelectionBehavior, type ListSelectionMode } from '../selection
14
14
  * `multiple` any number, and `value` holds the selected rows' values,
15
15
  * separated by commas. A change asks with `helix-before-change` and tells
16
16
  * with `change`. `none` selects nothing, so a press acts on the row. A
17
- * `disabled` row is passed over. Each row is named by its label and described
17
+ * `disabled` row is passed over. With `disabled-behavior="selection"`, a
18
+ * `disabled` row cannot be selected, and is reached and acted on as any row
19
+ * is, as React Aria's GridList has it. Each row is named by its label and described
18
20
  * by its description, so the words of the controls in `end` are not read as
19
21
  * part of its name.
20
22
  *
@@ -125,6 +127,15 @@ export declare class HelixHeadlessList extends HelixElement {
125
127
  * refused, in a `reorderable` list.
126
128
  */
127
129
  canceledLabel: string;
130
+ /**
131
+ * What `disabled` takes from a row, in a list that selects: `all`, which
132
+ * passes over the row, or `selection`, which keeps the row out of the
133
+ * selection and lets the keys reach it and Enter or a press act on it, as
134
+ * React Aria's GridList takes `disabledBehavior`.
135
+ *
136
+ * Absent is `all`.
137
+ */
138
+ disabledBehavior?: 'all' | 'selection' | undefined;
128
139
  /** What screen readers hear when a row is dropped, in a `reorderable` list. */
129
140
  droppedLabel: string;
130
141
  /**
@@ -203,6 +214,10 @@ export declare class HelixHeadlessList extends HelixElement {
203
214
  * lets the arrow keys carry the selection.
204
215
  */
205
216
  selectionBehavior: ListSelectionBehavior;
217
+ /** Whether the row is out of reach of the keys and of actions, past selection. */
218
+ private blocked;
219
+ /** The rows a key can land on that can also be selected. */
220
+ private get selectable();
206
221
  /**
207
222
  * Makes the list interactive: `single` selects one row, `multiple` any
208
223
  * number, and `none` moves and acts without selecting. Unset is a list a
@@ -235,6 +250,8 @@ export declare class HelixHeadlessList extends HelixElement {
235
250
  value: string;
236
251
  /** The row the reader was last on, which keeps the tab stop. */
237
252
  private last;
253
+ /** The rows in order on the last `sync`, so a removed row's place is known. */
254
+ private placed;
238
255
  /** The value of the row a Shift range runs from: the last row pressed. */
239
256
  private anchor;
240
257
  /**
@@ -295,6 +312,17 @@ export declare class HelixHeadlessList extends HelixElement {
295
312
  * row a server rendered draws its served markup first.
296
313
  */
297
314
  private sync;
315
+ /**
316
+ * Moves the tab stop off a row the page removed, to the next row a key can
317
+ * reach or, at the end, the one before it, and moves focus there where
318
+ * removing the row dropped it to the body.
319
+ *
320
+ * **Focus went to the body**, and the arrow keys did nothing until the
321
+ * reader tabbed back into the list. React Aria's GridList moves focus to a
322
+ * neighbor the same way. Focus anywhere but the body is where you put it,
323
+ * and is left there.
324
+ */
325
+ private followRemoved;
298
326
  /** The row a press or a key came from, read off the event's path. */
299
327
  private rowOf;
300
328
  /**
@@ -27,6 +27,7 @@ let HelixHeadlessList = class HelixHeadlessList extends HelixElement {
27
27
  this.reversed = false;
28
28
  this.selectionBehavior = 'toggle';
29
29
  this.value = '';
30
+ this.placed = [];
30
31
  this.keys = new KeyboardNavigationController(this, {
31
32
  items: () => this.reachable.map((row) => this.navigable(row)),
32
33
  origin: 'focus',
@@ -67,12 +68,12 @@ let HelixHeadlessList = class HelixHeadlessList extends HelixElement {
67
68
  };
68
69
  this.handleKeydown = (event) => {
69
70
  const row = this.interactive ? this.rowOf(event) : undefined;
70
- if (row && !row.disabled && !event.altKey && /^Page(Down|Up)$/.test(event.key)) {
71
+ if (row && !this.blocked(row) && !event.altKey && /^Page(Down|Up)$/.test(event.key)) {
71
72
  event.preventDefault();
72
73
  this.page(row, event.key === 'PageDown' ? 1 : -1);
73
74
  }
74
75
  this.moving = undefined;
75
- if (!row || row.disabled || event.altKey) {
76
+ if (!row || this.blocked(row) || event.altKey) {
76
77
  return;
77
78
  }
78
79
  const toggle = event.ctrlKey || event.metaKey;
@@ -85,7 +86,7 @@ let HelixHeadlessList = class HelixHeadlessList extends HelixElement {
85
86
  }
86
87
  if (selectsAll(event) && this.selectionMode === 'multiple') {
87
88
  event.preventDefault();
88
- const every = this.reachable.map((one) => this.valueFor(one)).join(',');
89
+ const every = this.selectable.map((one) => this.valueFor(one)).join(',');
89
90
  this.choose(every === this.value ? null : every);
90
91
  }
91
92
  else if (event.key === 'Escape' && this.value !== '' && this.selectionMode !== 'none') {
@@ -111,7 +112,7 @@ let HelixHeadlessList = class HelixHeadlessList extends HelixElement {
111
112
  if (pressedOwnControl(event, cell && event.composedPath().includes(cell) ? cell : row)) {
112
113
  return;
113
114
  }
114
- if (this.selectionMode === 'none') {
115
+ if (this.selectionMode === 'none' || row.disabled) {
115
116
  this.act(row);
116
117
  }
117
118
  else {
@@ -139,6 +140,12 @@ let HelixHeadlessList = class HelixHeadlessList extends HelixElement {
139
140
  };
140
141
  }
141
142
  static { this.words = 'helix-list'; }
143
+ blocked(row) {
144
+ return row.disabled && this.disabledBehavior !== 'selection';
145
+ }
146
+ get selectable() {
147
+ return this.reachable.filter((row) => !row.disabled);
148
+ }
142
149
  get interactive() {
143
150
  return this.selectionMode !== undefined;
144
151
  }
@@ -146,11 +153,11 @@ let HelixHeadlessList = class HelixHeadlessList extends HelixElement {
146
153
  return this.knownChildren.filter((child) => child instanceof HelixHeadlessListItem);
147
154
  }
148
155
  get reachable() {
149
- return this.interactive ? this.rows.filter((row) => !row.disabled) : [];
156
+ return this.interactive ? this.rows.filter((row) => !this.blocked(row)) : [];
150
157
  }
151
158
  navigable(row) {
152
159
  return focusItemOf(row, {
153
- disabled: row.disabled,
160
+ disabled: this.blocked(row),
154
161
  focus: () => row.focusItem(),
155
162
  words: row.words,
156
163
  });
@@ -210,20 +217,41 @@ let HelixHeadlessList = class HelixHeadlessList extends HelixElement {
210
217
  const mode = this.selectionMode;
211
218
  const chosen = new Set(commaList(this.value));
212
219
  const rows = this.rows;
220
+ if (mode) {
221
+ this.followRemoved(rows);
222
+ }
223
+ this.placed = rows;
213
224
  const holder = mode
214
225
  ? rovingStop(rows, this.last, {
215
- disabled: (row) => row.disabled,
226
+ disabled: (row) => this.blocked(row),
216
227
  selected: (row) => chosen.has(this.valueFor(row)),
217
228
  })
218
229
  : undefined;
219
230
  for (const row of rows) {
220
231
  whenHydrated(row, () => {
221
232
  row.focusable = mode ? row === holder : undefined;
233
+ row.selectionOnly = this.disabledBehavior === 'selection';
222
234
  row.selected =
223
235
  mode === undefined || mode === 'none' ? undefined : chosen.has(this.valueFor(row));
224
236
  });
225
237
  }
226
238
  }
239
+ followRemoved(rows) {
240
+ const last = this.last;
241
+ const at = last ? this.placed.indexOf(last) : -1;
242
+ if (!last || at < 0 || rows.includes(last)) {
243
+ return;
244
+ }
245
+ const present = new Set(rows);
246
+ const reachable = (row) => present.has(row) && !this.blocked(row);
247
+ const next = this.placed.slice(at + 1).find(reachable) ??
248
+ this.placed.slice(0, at).reverse().find(reachable);
249
+ this.last = next;
250
+ const active = deepActiveElement();
251
+ if (next && (active === null || active === document.body)) {
252
+ whenHydrated(next, () => next.focusItem());
253
+ }
254
+ }
227
255
  rowOf(event) {
228
256
  return event
229
257
  .composedPath()
@@ -245,7 +273,7 @@ let HelixHeadlessList = class HelixHeadlessList extends HelixElement {
245
273
  extend,
246
274
  mode: this.selectionMode ?? 'none',
247
275
  pressed: this.valueFor(row),
248
- shown: this.reachable.map((one) => this.valueFor(one)),
276
+ shown: this.selectable.map((one) => this.valueFor(one)),
249
277
  toggle,
250
278
  value: this.value,
251
279
  };
@@ -263,7 +291,10 @@ let HelixHeadlessList = class HelixHeadlessList extends HelixElement {
263
291
  }
264
292
  arrive(row, from) {
265
293
  const moving = this.moving;
266
- if (!moving || this.selectionMode === undefined || this.selectionMode === 'none') {
294
+ if (!moving ||
295
+ this.selectionMode === undefined ||
296
+ this.selectionMode === 'none' ||
297
+ row.disabled) {
267
298
  return;
268
299
  }
269
300
  if (moving.extend && this.anchor === undefined && from) {
@@ -287,7 +318,7 @@ let HelixHeadlessList = class HelixHeadlessList extends HelixElement {
287
318
  }
288
319
  }
289
320
  act(row) {
290
- if (row.disabled) {
321
+ if (this.blocked(row)) {
291
322
  return;
292
323
  }
293
324
  if (row.href !== '') {
@@ -420,6 +451,9 @@ __decorate([
420
451
  words,
421
452
  property({ attribute: 'canceled-label' })
422
453
  ], HelixHeadlessList.prototype, "canceledLabel", void 0);
454
+ __decorate([
455
+ property({ attribute: 'disabled-behavior' })
456
+ ], HelixHeadlessList.prototype, "disabledBehavior", void 0);
423
457
  __decorate([
424
458
  words,
425
459
  property({ attribute: 'dropped-label' })
@@ -14,7 +14,9 @@ declare const HelixHeadlessListbox_base: (new (...args: any[]) => import("@fusio
14
14
  * With `multiple` set, `value` is a comma-separated string, like
15
15
  * `Checkbox.Group`'s, because `MixinControllerValue` holds a string. The form
16
16
  * receives one entry per selected option, as a native `<select multiple>` sends
17
- * it, so your server does not split on commas.
17
+ * it, so your server does not split on commas. An option's value may hold a
18
+ * comma: a run of the string that is an option's value is read as that one
19
+ * value, and the form sends it as one entry.
18
20
  *
19
21
  * Selected and active are separate. The selected option is the value. The
20
22
  * active option is where the keyboard is: the arrow keys move it, and
@@ -185,6 +187,16 @@ export declare class HelixHeadlessListbox extends HelixHeadlessListbox_base {
185
187
  * Placeholder text for the filter field.
186
188
  */
187
189
  filterLabel: string;
190
+ /**
191
+ * Whether an option is kept by the filter: a function given the option's
192
+ * words and the filter, trimmed, that returns `true` to keep it showing, as
193
+ * React Aria's `Autocomplete` takes `filter`. For matching that ignores
194
+ * accents, matches the start of each word, or matches out of order.
195
+ *
196
+ * Absent, an option is kept where its words hold the filter, in any case. A
197
+ * property only, there being no way to write a function in markup.
198
+ */
199
+ filterMatch?: ((text: string, filter: string) => boolean) | undefined;
188
200
  /**
189
201
  * The options as data, for a list too long to draw whole: each a value,
190
202
  * which is also its words, or a record with a `value`, a `label` and
@@ -447,7 +459,12 @@ export declare class HelixHeadlessListbox extends HelixHeadlessListbox_base {
447
459
  private get elements();
448
460
  /** The options a filter has left showing. */
449
461
  private get shown();
450
- /** The chosen values, as an array whether or not `multiple` is set. */
462
+ /**
463
+ * The chosen values, as an array whether or not `multiple` is set.
464
+ *
465
+ * Read against the options' values, so an option whose value holds a comma
466
+ * is one value. See `chosenValues`.
467
+ */
451
468
  get values(): string[];
452
469
  /**
453
470
  * Applies a choice, announcing first so a listener can refuse it.
@@ -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 { AriaForwardController, centerInView, columnStep, countLabel, customElement, DelayController, DisabledCascadeController, drawAutofill, endInSight, formatWords, HelixFilterEvent, HelixFormElement, HelixLoadMoreEvent, hostAria, inlineStep, MixinCollection, MixinControllerValue, MixinField, markMatches, OptionWindowController, onApple, pageRows, rangeAdd, reportValues, requestChange, scrollItemIntoView, selectsAll, Typeahead, toggleEvery, typesCharacter, words, wordsLocale, } from '@fusion.dev/helix-core';
7
+ import { AriaForwardController, centerInView, chosenValues, columnStep, countLabel, customElement, DelayController, DisabledCascadeController, drawAutofill, endInSight, formatWords, HelixFilterEvent, HelixFormElement, HelixLoadMoreEvent, hostAria, inlineStep, MixinCollection, MixinControllerValue, MixinField, markMatches, OptionWindowController, onApple, pageRows, rangeAdd, reportValues, requestChange, scrollItemIntoView, selectsAll, Typeahead, toggleEvery, typesCharacter, words, wordsLocale, } from '@fusion.dev/helix-core';
8
8
  import { magnifyingGlass } from '@fusion.dev/helix-icons';
9
9
  import { html, nothing } from 'lit';
10
10
  import { property, state } from 'lit/decorators.js';
@@ -326,7 +326,7 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
326
326
  return this.entries.filter((option) => !option.hidden);
327
327
  }
328
328
  get values() {
329
- return this.value === '' ? [] : this.value.split(',');
329
+ return chosenValues(this.value, new Set(this.entries.map((option) => option.value)), this.multiple);
330
330
  }
331
331
  choose(value) {
332
332
  const chosen = this.values;
@@ -392,10 +392,14 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
392
392
  }
393
393
  place() {
394
394
  this.forgetRemoved();
395
- const needle = this.filter.trim().toLowerCase();
395
+ const typed = this.filter.trim();
396
+ const needle = typed.toLowerCase();
397
+ const match = this.filterMatch;
396
398
  const chosen = new Set(this.values);
397
399
  for (const option of this.entries) {
398
- option.hidden = needle !== '' && !option.text.toLowerCase().includes(needle);
400
+ option.hidden =
401
+ needle !== '' &&
402
+ !(match ? match(option.text, typed) : option.text.toLowerCase().includes(needle));
399
403
  option.selected = chosen.has(option.value);
400
404
  }
401
405
  for (const group of this.groups) {
@@ -425,10 +429,11 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
425
429
  return;
426
430
  }
427
431
  const present = new Set(this.entries.map((option) => option.value));
428
- const gone = this.values.filter((value) => this.seen.has(value) && !present.has(value));
432
+ const held = chosenValues(this.value, new Set([...present, ...this.seen]), this.multiple);
433
+ const gone = held.filter((value) => this.seen.has(value) && !present.has(value));
429
434
  this.seen = present;
430
435
  if (gone.length > 0) {
431
- this.value = this.values.filter((value) => !gone.includes(value)).join(',');
436
+ this.value = held.filter((value) => !gone.includes(value)).join(',');
432
437
  }
433
438
  }
434
439
  followActive(changed) {
@@ -444,7 +449,7 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
444
449
  }
445
450
  sync() {
446
451
  if (this.multiple) {
447
- reportValues(this.internals, this.name, this.value === '' ? [] : this.value.split(','));
452
+ reportValues(this.internals, this.name, this.values);
448
453
  }
449
454
  else {
450
455
  this.internals.setFormValue(this.value === '' ? null : this.value);
@@ -498,7 +503,7 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
498
503
  }
499
504
  }
500
505
  get currentValue() {
501
- return this.activeValue || (this.value.split(',')[0] ?? '');
506
+ return this.activeValue || (this.values[0] ?? '');
502
507
  }
503
508
  linkActive() {
504
509
  const list = this.renderRoot.querySelector(this.filterable ? '[part="search-field"]' : '[part="items"]');
@@ -666,6 +671,9 @@ __decorate([
666
671
  words,
667
672
  property({ attribute: 'filter-label' })
668
673
  ], HelixHeadlessListbox.prototype, "filterLabel", void 0);
674
+ __decorate([
675
+ property({ attribute: false })
676
+ ], HelixHeadlessListbox.prototype, "filterMatch", void 0);
669
677
  __decorate([
670
678
  property({ attribute: false })
671
679
  ], HelixHeadlessListbox.prototype, "items", void 0);
@@ -358,6 +358,10 @@ export declare class HelixHeadlessMap extends HelixHeadlessMap_base {
358
358
  /**
359
359
  * A route or area renamed, or its description changed, which the list says
360
360
  * and a slot change does not report.
361
+ *
362
+ * Not the map's own `gestures` news, which it sends from `updated` and which
363
+ * changes nothing it draws: an update asked for there ran every map's
364
+ * update twice, and Lit's development build warned about it.
361
365
  */
362
366
  private readonly handlePartChange;
363
367
  /**
@@ -90,7 +90,7 @@ let HelixHeadlessMap = class HelixHeadlessMap extends MixinViewport(HelixElement
90
90
  }
91
91
  };
92
92
  this.handlePartChange = (event) => {
93
- if (event.detail.kind !== 'place') {
93
+ if (event.detail.kind !== 'place' && event.detail.kind !== 'gestures') {
94
94
  this.requestUpdate();
95
95
  }
96
96
  };
@@ -104,7 +104,7 @@ let HelixHeadlessMenuItem = class HelixHeadlessMenuItem extends HelixElement {
104
104
  updated(changed) {
105
105
  super.updated(changed);
106
106
  if (this.partNamed('item')) {
107
- auditName(this, () => this.partNamed('item'), 'Give the row words in its default slot, or a label on the icon it holds.');
107
+ auditName(this, () => this.partNamed('item') ?? undefined, 'Give the row words in its default slot, or a label on the icon it holds.');
108
108
  }
109
109
  }
110
110
  renderDescription() {
@@ -42,8 +42,8 @@ declare const HelixHeadlessMenu_base: (new (...args: any[]) => import("@fusion.d
42
42
  * rows, Home and End go to the first and the last, a typed letter moves to
43
43
  * the next row whose name starts with it, Enter and Space choose, Right Arrow
44
44
  * opens a row's submenu and Left Arrow goes back, the two swapping on a
45
- * right-to-left page, and Escape closes the menu and returns focus to the
46
- * trigger. Tab closes it and moves on.
45
+ * right-to-left page. Left Arrow on the top page does nothing. Escape closes
46
+ * the menu and returns focus to the trigger. Tab closes it and moves on.
47
47
  *
48
48
  * While it is open, the surface carries the trigger's width as
49
49
  * `--floating-anchor-inline-size` and the room left below it as
@@ -93,6 +93,10 @@ declare const HelixHeadlessMenu_base: (new (...args: any[]) => import("@fusion.d
93
93
  * @cssstate drilled - A submenu's page is showing, so content that is not a row can hide with the top page.
94
94
  * @cssstate mark - A row on the current page has a checkbox or a link, so every row keeps the trailing column.
95
95
  * @cssstate open - While the menu is open.
96
+ * @cssstate placed-bottom - While the open panel sits below its trigger.
97
+ * @cssstate placed-left - While the open panel sits to the left of its trigger.
98
+ * @cssstate placed-right - While the open panel sits to the right of its trigger.
99
+ * @cssstate placed-top - While the open panel sits above its trigger.
96
100
  * @cssstate start - A row on the current page has content in its leading column, so every row keeps that column.
97
101
  */
98
102
  export declare class HelixHeadlessMenu extends HelixHeadlessMenu_base {
@@ -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 { AriaForwardController, anchorElement, customElement, DrilldownController, deepActiveElement, deepContains, focusTarget, HelixElement, inlineStep, KeyboardNavigationController, linkDescription, MixinCollection, MixinControllerOpen, PopoverController, scrollItemIntoView, slottedWords, tellDisclosure, typeaheadBy, unlinkDescription, words, } from '@fusion.dev/helix-core';
7
+ import { AriaForwardController, anchorElement, customElement, DrilldownController, deepActiveElement, deepContains, focusTarget, HelixElement, inlineStep, KeyboardNavigationController, linkDescription, MixinCollection, MixinControllerOpen, PopoverController, scrollItemIntoView, sideOf, slottedWords, tellDisclosure, typeaheadBy, unlinkDescription, words, } from '@fusion.dev/helix-core';
8
8
  import { caretLeft } from '@fusion.dev/helix-icons';
9
9
  import { html, nothing } from 'lit';
10
10
  import { property } from 'lit/decorators.js';
@@ -76,6 +76,13 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
76
76
  boundary: () => this,
77
77
  offset: () => this.offset,
78
78
  onDismiss: () => this.hide(),
79
+ onPlace: (placement) => {
80
+ const side = sideOf(placement);
81
+ this.toggleState('placed-bottom', side === 'bottom');
82
+ this.toggleState('placed-left', side === 'left');
83
+ this.toggleState('placed-right', side === 'right');
84
+ this.toggleState('placed-top', side === 'top');
85
+ },
79
86
  open: () => this.open,
80
87
  placement: () => this.placement,
81
88
  surface: () => this.partNamed('surface'),
@@ -145,7 +152,7 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
145
152
  this.enter(items[at]);
146
153
  }
147
154
  else if (along === -1) {
148
- if (this.flies && this.keyPage === null) {
155
+ if (this.flies ? this.keyPage === null : this.drilldown.atRoot) {
149
156
  return;
150
157
  }
151
158
  this.leave();
@@ -25,9 +25,9 @@ declare const HelixHeadlessNumberInput_base: (new (...args: any[]) => import("@f
25
25
  * The locale's group separators are read in every field, so `1,234` is a
26
26
  * number in `en-US`.
27
27
  *
28
- * The field and its two buttons sit in a `role="group"`, and the field carries
29
- * `role-label`, "number field" in English, as its `aria-roledescription`, as
30
- * React Aria's NumberField does. On iOS and iPadOS the field is a plain text
28
+ * The field and its two buttons sit in a `role="group"` named by the label,
29
+ * and the field carries `role-label`, "number field" in English, as its
30
+ * `aria-roledescription`, as React Aria's NumberField does. On iOS and iPadOS the field is a plain text
31
31
  * field, with no `role="spinbutton"`, which VoiceOver reads as a slider you
32
32
  * cannot type in, and no role description, so VoiceOver says the field is
33
33
  * required. React Aria's NumberField does the same.
@@ -492,8 +492,11 @@ export declare class HelixHeadlessNumberInput extends HelixHeadlessNumberInput_b
492
492
  protected renderIncrementIcon(): TemplateResult;
493
493
  protected render(): TemplateResult;
494
494
  /**
495
- * The value as a number, or `NaN` while the control is empty.
495
+ * The value as a number, or `NaN` while the control is empty or does not
496
+ * hold a number.
496
497
  *
498
+ * Read in the locale and the format, as the form reads it, so `1,5` typed
499
+ * in a locale with a decimal comma is 1.5 here while the form holds 1.5.
497
500
  * `NaN` and not zero, which is the trap the platform already avoids: `Number('')`
498
501
  * is zero, so an empty control would report a number it does not hold and a
499
502
  * form would submit it.