@fusion.dev/helix-headless 0.182.2 → 0.184.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.
@@ -80,6 +80,7 @@ declare const HelixHeadlessBarChart_base: typeof HelixElement & (new (...args: a
80
80
  * @csspart bar - One column. Under `stacked`, one segment of a category's column.
81
81
  * @csspart bar-end - The bar at a column's far end, the end away from zero, which is the end to round. A column standing alone is its own far end. Under `stacked`, the last positive segment above zero and the last negative one below it. Its `data-end` is `positive` or `negative`. A blank or a zero is never the end. Set together with `bar`.
82
82
  * @csspart bar-end-negative - A far end below zero, whose far end is its bottom, or its inline start when the bars run across. Set together with `bar-end`.
83
+ * @csspart bar-negative - A bar below zero, standing alone or any segment of a stack below it, whose base is its top, or its inline end when the bars run across. Set together with `bar`.
83
84
  * @csspart bar-wash-end - The far stop of a bar's gradient, where one is used.
84
85
  * @csspart bar-wash-start - The stop of a bar's gradient at its base.
85
86
  * @csspart baseline - The line the columns stand on.
@@ -120,6 +120,7 @@ let HelixHeadlessBarChart = class HelixHeadlessBarChart extends MixinChartStatus
120
120
  ? `--chart-series: var(--bar-chart-tone-${ink}, var(--chart-series-${seriesIndex(tone)})); --chart-bar-wash: url(#helix-bar-wash-tone-${ink})`
121
121
  : `--chart-series: var(--chart-series-${seriesIndex(tone)}); --chart-bar-wash: url(#helix-bar-wash-${seriesIndex(tone)})`;
122
122
  const below = end === 'negative';
123
+ const sinks = value < (from ?? 0);
123
124
  return sideways
124
125
  ? svg `<rect
125
126
  data-end=${end ?? nothing}
@@ -127,7 +128,7 @@ let HelixHeadlessBarChart = class HelixHeadlessBarChart extends MixinChartStatus
127
128
  data-tone=${ink ?? nothing}
128
129
  height=${thickness}
129
130
  style=${paint}
130
- part="bar${end ? ' bar-end' : ''}${below ? ' bar-end-negative' : ''}"
131
+ part="bar${sinks ? ' bar-negative' : ''}${end ? ' bar-end' : ''}${below ? ' bar-end-negative' : ''}"
131
132
  width=${size}
132
133
  x=${along}
133
134
  y=${across}
@@ -138,7 +139,7 @@ let HelixHeadlessBarChart = class HelixHeadlessBarChart extends MixinChartStatus
138
139
  data-tone=${ink ?? nothing}
139
140
  height=${size}
140
141
  style=${paint}
141
- part="bar${end ? ' bar-end' : ''}${below ? ' bar-end-negative' : ''}"
142
+ part="bar${sinks ? ' bar-negative' : ''}${end ? ' bar-end' : ''}${below ? ' bar-end-negative' : ''}"
142
143
  width=${thickness}
143
144
  x=${across}
144
145
  y=${along}
@@ -1,6 +1,29 @@
1
1
  import { HelixElement } from '@fusion.dev/helix-core';
2
- import { type PropertyValues, type TemplateResult } from 'lit';
2
+ import { type CSSResult, type PropertyValues, type TemplateResult } from 'lit';
3
3
  import '../../copy-button/index.js';
4
+ /**
5
+ * Turns code into highlighted markup for a block's `language`, or returns
6
+ * `undefined` for a language it does not read, which the block then draws
7
+ * plain.
8
+ */
9
+ export type CodeBlockHighlighter = (code: string, language: string) => string | undefined;
10
+ /**
11
+ * Gives every Code Block of both tiers a highlighter, for the blocks that set
12
+ * no `highlight` of their own, and the sheet that colors what it returns.
13
+ *
14
+ * **Opt-in, so a page that shows plain code carries no grammar.** Code Block
15
+ * ships none: a grammar is the larger part of any highlighter, and a block
16
+ * that registers one for every page makes every page pay for it. A page that
17
+ * wants color imports one entry that calls this, such as
18
+ * `@fusion.dev/helix-sandbox/code-block`, which reads `css`, `html`, `js`,
19
+ * `jsx`, `sh`, `ts` and `tsx` with the sandbox's Lezer grammars and colors
20
+ * them from the `--helix-color-syntax-*` tokens.
21
+ *
22
+ * A block's own `highlight` wins over this. Blocks already on the page draw
23
+ * again with it, and every block built after has it. `undefined` takes it
24
+ * away again.
25
+ */
26
+ export declare const registerHighlighter: (highlighter: CodeBlockHighlighter | undefined, sheet?: CSSResult) => void;
4
27
  /**
5
28
  * @summary A block of code with an optional name and a copy button.
6
29
  *
@@ -37,6 +60,20 @@ import '../../copy-button/index.js';
37
60
  * the slot, so the copy button and the page's own text are the code as
38
61
  * written.
39
62
  *
63
+ * **Code Block ships no grammar**, so plain code costs you no parser. To color
64
+ * code without a highlighter of your own, import one more entry beside the
65
+ * block's. It colors every block by its `language` (`css`, `html`, `js`,
66
+ * `jsx`, `sh`, `ts`, or `tsx`) with the Lezer grammars of the sandbox's
67
+ * editor, in the editor's colors:
68
+ *
69
+ * ```js
70
+ * import '@fusion.dev/helix/code-block'
71
+ * import '@fusion.dev/helix-sandbox/code-block'
72
+ * ```
73
+ *
74
+ * That entry calls `registerHighlighter`, which you can also call yourself to
75
+ * give every block one highlighter of your own.
76
+ *
40
77
  * Set `expandable` on a block whose height is capped, such as by
41
78
  * `--code-block-max-block-size`, and a button under it shows every line and
42
79
  * then fewer again, as a docs site's long example does. The button is drawn
@@ -269,6 +306,10 @@ export declare class HelixHeadlessCodeBlock extends HelixElement {
269
306
  * in the slot, so the copy button copies it as written and a reader without
270
307
  * the script still has it. Not called on a server, which has no code to
271
308
  * read, nor for a diff.
309
+ *
310
+ * Absent, the block draws with the highlighter `registerHighlighter` gave
311
+ * every block, where an entry such as `@fusion.dev/helix-sandbox/code-block`
312
+ * registered one, and plain otherwise.
272
313
  */
273
314
  highlight?: (code: string, language: string) => string;
274
315
  /**
@@ -8,6 +8,27 @@ import '../../copy-button/index.js';
8
8
  import { highlightedLines } from '../highlight.js';
9
9
  import { gutterNumbers, lineCount, lineRuns, lineSpans } from '../lines.js';
10
10
  import { foldContext, pairLines, parsePatch, } from '../patch.js';
11
+ let registered;
12
+ const adoptRegistered = (block) => {
13
+ const root = block.shadowRoot;
14
+ const sheet = registered?.sheet?.styleSheet;
15
+ if (root && sheet && !root.adoptedStyleSheets.includes(sheet)) {
16
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
17
+ }
18
+ };
19
+ const blocksOnPage = (root = document) => [...root.querySelectorAll('*')].flatMap((node) => [
20
+ ...(node instanceof HelixHeadlessCodeBlock ? [node] : []),
21
+ ...(node.shadowRoot ? blocksOnPage(node.shadowRoot) : []),
22
+ ]);
23
+ export const registerHighlighter = (highlighter, sheet) => {
24
+ registered = highlighter ? { highlighter, sheet } : undefined;
25
+ if (typeof document !== 'undefined') {
26
+ for (const block of blocksOnPage()) {
27
+ adoptRegistered(block);
28
+ block.requestUpdate();
29
+ }
30
+ }
31
+ };
11
32
  const stopPress = (event) => event.stopPropagation();
12
33
  const isBlock = (element) => element?.localName.endsWith('code-block') === true;
13
34
  const blocksIn = (host) => [...host.children].filter((child) => child.slot === '' && isBlock(child));
@@ -332,6 +353,7 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
332
353
  firstUpdated(changed) {
333
354
  super.firstUpdated(changed);
334
355
  this.finishHydration();
356
+ adoptRegistered(this);
335
357
  }
336
358
  render() {
337
359
  const blocks = this.blocks;
@@ -344,11 +366,10 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
344
366
  }
345
367
  const text = this.text;
346
368
  let highlighted;
369
+ const highlight = this.highlight ?? registered?.highlighter;
347
370
  try {
348
- highlighted =
349
- this.highlight && text !== ''
350
- ? highlightedLines(this.highlight(text, this.language))
351
- : undefined;
371
+ const markup = highlight && text !== '' ? highlight(text, this.language) : undefined;
372
+ highlighted = markup === undefined ? undefined : highlightedLines(markup);
352
373
  }
353
374
  catch (error) {
354
375
  globalThis.reportError?.(error);
@@ -333,7 +333,15 @@ export declare class HelixHeadlessHeatmap extends HelixHeadlessHeatmap_base {
333
333
  protected readonly aria: AriaForwardController;
334
334
  private observer;
335
335
  private announced;
336
- /** Where a range taken in with Shift starts: the cell last pressed, or none. */
336
+ /**
337
+ * Where a range taken in with Shift starts: the row of the table whose cell
338
+ * was last pressed, or none.
339
+ *
340
+ * **The row, and not the cell's place among those drawn.** A narrow strip
341
+ * draws the newest whole columns, so a resize that drops or adds a column
342
+ * moves every cell's place, and an anchor kept as a place started the next
343
+ * range on another day.
344
+ */
337
345
  private anchor;
338
346
  /** The cell the keys rested on before the key now being handled moved them. */
339
347
  private left;
@@ -436,6 +444,8 @@ export declare class HelixHeadlessHeatmap extends HelixHeadlessHeatmap_base {
436
444
  * keep the oldest is answering a question nobody asked.
437
445
  */
438
446
  private get drawn();
447
+ /** How many of the oldest rows a narrow strip leaves undrawn. */
448
+ private get dropped();
439
449
  /**
440
450
  * The number of cells drawn, which the keys walk.
441
451
  *
@@ -54,8 +54,9 @@ let HelixHeadlessHeatmap = class HelixHeadlessHeatmap extends MixinChartStatus(M
54
54
  this.stretch(at);
55
55
  return;
56
56
  }
57
- const keys = this.keysOf(this.drawn);
58
- const added = rangeAdd(commaList(this.value), keys, this.anchor < 0 ? at : this.anchor, at);
57
+ const keys = this.keysOf(this.points);
58
+ const from = keys[this.anchor] === undefined ? -1 : this.anchor;
59
+ const added = rangeAdd(commaList(this.value), keys, from, this.dropped + at);
59
60
  if (added) {
60
61
  this.choose(added.join(', '));
61
62
  }
@@ -125,21 +126,25 @@ let HelixHeadlessHeatmap = class HelixHeadlessHeatmap extends MixinChartStatus(M
125
126
  if (!mode || key === undefined || key === '') {
126
127
  return;
127
128
  }
128
- this.anchor = index;
129
+ this.anchor = this.dropped + index;
129
130
  this.base = undefined;
130
131
  this.choose(pressedValue(mode, this.value, key));
131
132
  }
132
133
  stretch(to) {
133
- const keys = this.keysOf(this.drawn);
134
- if (this.anchor < 0) {
135
- this.anchor = this.left < 0 ? to : this.left;
134
+ const all = this.keysOf(this.points);
135
+ const dropped = this.dropped;
136
+ if (all[this.anchor] === undefined) {
137
+ this.anchor = dropped + (this.left < 0 ? to : this.left);
136
138
  }
137
139
  if (this.base === undefined || this.value !== this.shaped) {
138
- const corner = keys[this.anchor];
140
+ const corner = all[this.anchor];
139
141
  this.base = commaList(this.value).filter((one) => one !== corner);
140
142
  }
141
- const taken = rectangleOf(keys.length, this.rowCount, this.anchor, to).map((at) => keys[at] ?? '');
142
- const all = this.keysOf(this.points);
143
+ const rows = this.rowCount;
144
+ const shift = (rows - (dropped % rows)) % rows;
145
+ const taken = rectangleOf(all.length + shift, rows, this.anchor + shift, dropped + to + shift)
146
+ .filter((at) => at >= shift)
147
+ .map((at) => all[at - shift] ?? '');
143
148
  this.choose(inSequence([...this.base, ...taken].filter(Boolean), all).join(', '));
144
149
  this.shaped = this.value;
145
150
  }
@@ -182,6 +187,9 @@ let HelixHeadlessHeatmap = class HelixHeadlessHeatmap extends MixinChartStatus(M
182
187
  const all = this.points;
183
188
  return all.slice(Math.max(0, all.length - this.room));
184
189
  }
190
+ get dropped() {
191
+ return Math.max(0, this.points.length - this.room);
192
+ }
185
193
  get readings() {
186
194
  return this.drawn.length;
187
195
  }
package/dist/index.d.ts CHANGED
@@ -63,6 +63,7 @@ export * from './listbox/index.js';
63
63
  export * from './loader/index.js';
64
64
  export * from './map/index.js';
65
65
  export * from './megamenu/index.js';
66
+ export * from './menu/filter/index.js';
66
67
  export * from './menu/index.js';
67
68
  export * from './meter/index.js';
68
69
  export * from './navigation/index.js';
package/dist/index.js CHANGED
@@ -54,6 +54,7 @@ export * from './listbox/index.js';
54
54
  export * from './loader/index.js';
55
55
  export * from './map/index.js';
56
56
  export * from './megamenu/index.js';
57
+ export * from './menu/filter/index.js';
57
58
  export * from './menu/index.js';
58
59
  export * from './meter/index.js';
59
60
  export * from './navigation/index.js';
@@ -0,0 +1 @@
1
+ export { filterMenu, MenuFilterController } from './menu-filter.js';
@@ -0,0 +1,4 @@
1
+ import { HelixHeadlessMenu } from '../root/menu-root.js';
2
+ import { filterMenu } from './menu-filter.js';
3
+ filterMenu(HelixHeadlessMenu);
4
+ export { filterMenu, MenuFilterController } from './menu-filter.js';
@@ -0,0 +1,114 @@
1
+ import { type CSSResult, nothing, type ReactiveController, type TemplateResult } from 'lit';
2
+ import type { HelixHeadlessMenuItem } from '../item/menu-item.js';
3
+ import type { HelixHeadlessMenu } from '../root/menu-root.js';
4
+ type Row = HelixHeadlessMenuItem;
5
+ /**
6
+ * The field and then the `menu`: a menu holds menu items and nothing else, so
7
+ * the field stands beside it, as React Aria's `Autocomplete` puts its search
8
+ * field beside its menu. The menu calls it through its filter, so the parts it
9
+ * writes are the menu's.
10
+ */
11
+ export declare const drawFilter: (host: HelixHeadlessMenu, rows: TemplateResult, busy: string | typeof nothing, onInput: (event: Event) => void) => TemplateResult;
12
+ /**
13
+ * Narrows a `filterable` menu's rows as the reader types in a field above
14
+ * them, with focus staying in the field and the highlight moving through the
15
+ * rows that are left.
16
+ *
17
+ * **Its own entry**, `@fusion.dev/helix-headless/menu/filter`, so a menu that
18
+ * never filters carries none of this. Importing the entry gives every menu of
19
+ * the tier one, and `filterable` turns it on. Menu's description says what a
20
+ * reader gets; this is the part of it the menu does not hold:
21
+ *
22
+ * - the field and the `menu` after it, which the menu draws in its surface
23
+ * - which rows the filter keeps, and the words each matched, marked
24
+ * - the keys the field answers: Up and Down move the highlight, Enter presses
25
+ * the highlighted row, and Escape clears the filter before it closes
26
+ * - `helix-filter`, sent as the text changes, which a listener cancels to
27
+ * keep every row showing
28
+ * - how many rows are kept, or `no-results-text`, said in the live region
29
+ *
30
+ * The styled tier's entry hands in the sheet that draws the field, which the
31
+ * menu adopts while it filters.
32
+ */
33
+ export declare class MenuFilterController implements ReactiveController {
34
+ private readonly host;
35
+ /** The same menu, read through what it keeps to itself. */
36
+ private readonly inside;
37
+ /** The sheet the menu adopts while it filters, from the styled tier. */
38
+ sheet: CSSResult | undefined;
39
+ /** The row the keys reached while focus stays in the field. */
40
+ highlighted: Row | undefined;
41
+ /** The submenu the filter narrows, or null for the top page. */
42
+ private page;
43
+ /** The filter a listener refused, which narrows nothing. */
44
+ private refused;
45
+ /** The filter the last update saw, so a change moves a highlight it hid. */
46
+ private seen;
47
+ constructor(host: HelixHeadlessMenu, sheet?: CSSResult);
48
+ hostConnected(): void;
49
+ hostDisconnected(): void;
50
+ /** The field, while it is drawn. */
51
+ get field(): HTMLInputElement | null;
52
+ /** Whether the filter is narrowing the rows. */
53
+ get filtering(): boolean;
54
+ /** Whether the filter keeps a row: one of another page, or one whose words it matches. */
55
+ kept(row: Row): boolean;
56
+ /** The rows of the page the filter narrows that it keeps. */
57
+ private get keptRows();
58
+ /**
59
+ * Asks to filter by new text, which the field and `filter` then hold. A
60
+ * refusal keeps every row showing, for a page that narrows them itself.
61
+ */
62
+ request(value: string): void;
63
+ /** Filters by what the reader typed, and highlights the first row kept. */
64
+ private by;
65
+ /**
66
+ * Puts the highlight on a row, which the field names with
67
+ * `aria-activedescendant`. An element reference, because the field is in
68
+ * the menu's shadow root and the row in the tree around it, the one
69
+ * direction a reference crosses.
70
+ */
71
+ highlight(row: Row | undefined): void;
72
+ /**
73
+ * Answers a key the field takes, and returns whether it did: Up and Down
74
+ * move the highlight through the rows kept, Enter presses the highlighted
75
+ * row, and Escape over text empties the filter, the next one going back or
76
+ * closing.
77
+ */
78
+ keydown(event: KeyboardEvent, items: Row[], at: number): boolean;
79
+ /**
80
+ * Keeps focus in the field through a press on a row or the way back, as
81
+ * React Aria's virtual focus does: the press still chooses.
82
+ */
83
+ private readonly handleMouseDown;
84
+ private readonly handleInput;
85
+ /**
86
+ * What a filter that keeps no rows shows, or nothing where it keeps some.
87
+ *
88
+ * It sets what the live region says as well: `no-results-text` where the
89
+ * filter keeps no rows, and how many it keeps where it keeps some, as React
90
+ * Aria and Web Awesome say the count. Only "No matches" was said before
91
+ * that, so a filter that kept forty rows and one that kept one sounded the
92
+ * same.
93
+ */
94
+ status(): TemplateResult | undefined;
95
+ /** The field and then the `menu`, for the menu to draw in its surface. See `drawFilter`. */
96
+ drawFilter(rows: TemplateResult, busy: string | typeof nothing): TemplateResult;
97
+ /**
98
+ * Marks the words each kept row matched, as Listbox marks its options, and
99
+ * moves a highlight the filter hid to the first row it keeps. Called by the
100
+ * menu after it has marked which rows are on the page.
101
+ */
102
+ updated(): void;
103
+ }
104
+ /**
105
+ * Gives every menu of a class a `MenuFilterController`, which `filterable`
106
+ * turns on.
107
+ *
108
+ * The entries call it for their own tier's class: the headless entry for
109
+ * `HelixHeadlessMenu`, and the styled one for `HelixMenu`, with the sheet that
110
+ * draws the field. **Menus already on the page are given one too**, and drawn
111
+ * again, so a page that imports the menu and then this entry gets the field.
112
+ */
113
+ export declare const filterMenu: (element: typeof HelixHeadlessMenu, sheet?: CSSResult) => void;
114
+ export {};
@@ -0,0 +1,172 @@
1
+ import { filterKeeps, formatWords, HelixFilterEvent, markMatches, slottedWords, } from '@fusion.dev/helix-core';
2
+ import { html, nothing } from 'lit';
3
+ export const drawFilter = (host, rows, busy, onInput) => html `<input
4
+ part="search-field"
5
+ type="text"
6
+ autocomplete="off"
7
+ spellcheck="false"
8
+ .value=${host.filter}
9
+ placeholder=${host.filterLabel}
10
+ aria-label=${host.filterLabel}
11
+ aria-autocomplete="list"
12
+ aria-controls="items"
13
+ @input=${onInput}
14
+ /><div id="items" role="menu" aria-busy=${busy} aria-label=${host.label || nothing}>${rows}</div>`;
15
+ export class MenuFilterController {
16
+ constructor(host, sheet) {
17
+ this.page = null;
18
+ this.seen = '';
19
+ this.handleMouseDown = (event) => {
20
+ if (this.host.filterable &&
21
+ this.host.open &&
22
+ event.composedPath()[0] !== this.field &&
23
+ !this.inside.fromTrigger(event)) {
24
+ event.preventDefault();
25
+ }
26
+ };
27
+ this.handleInput = (event) => {
28
+ event.stopPropagation();
29
+ this.by(event.target.value);
30
+ };
31
+ this.host = host;
32
+ this.inside = host;
33
+ this.sheet = sheet;
34
+ this.inside.filterer = this;
35
+ host.addController(this);
36
+ }
37
+ hostConnected() {
38
+ this.host.addEventListener('mousedown', this.handleMouseDown);
39
+ }
40
+ hostDisconnected() {
41
+ this.host.removeEventListener('mousedown', this.handleMouseDown);
42
+ if (this.host.filterable) {
43
+ markMatches(this.host, [], '');
44
+ }
45
+ }
46
+ get field() {
47
+ return this.host.renderRoot?.querySelector('[part="search-field"]') ?? null;
48
+ }
49
+ get filtering() {
50
+ const { filter } = this.host;
51
+ return this.host.filterable && filter.trim() !== '' && filter !== this.refused;
52
+ }
53
+ kept(row) {
54
+ return (!this.filtering ||
55
+ this.inside.holderOf(row) !== this.page ||
56
+ filterKeeps(slottedWords(row, ['']), this.host.filter.trim(), this.host.filterMatch));
57
+ }
58
+ get keptRows() {
59
+ return this.inside.rowsOf(this.page).filter((row) => this.kept(row));
60
+ }
61
+ request(value) {
62
+ const { host, inside } = this;
63
+ if (value === host.filter) {
64
+ return;
65
+ }
66
+ this.page = inside.flies ? inside.keyPage : inside.drilldown.page;
67
+ this.refused = host.dispatchEvent(new HelixFilterEvent({ value })) ? undefined : value;
68
+ host.filter = value;
69
+ }
70
+ by(value) {
71
+ this.request(value);
72
+ void this.host.updateComplete.then(() => this.inside.reach(this.inside.items[0]));
73
+ }
74
+ highlight(row) {
75
+ if (this.highlighted && this.highlighted !== row) {
76
+ this.highlighted.highlighted = false;
77
+ }
78
+ this.highlighted = row;
79
+ const field = this.field;
80
+ if (field && 'ariaActiveDescendantElement' in field) {
81
+ field.ariaActiveDescendantElement = row ?? null;
82
+ }
83
+ if (row) {
84
+ row.highlighted = true;
85
+ }
86
+ }
87
+ keydown(event, items, at) {
88
+ if (event.key === 'Escape') {
89
+ if (this.host.filter === '') {
90
+ return false;
91
+ }
92
+ event.stopPropagation();
93
+ this.by('');
94
+ return true;
95
+ }
96
+ if (event.composedPath()[0] !== this.field) {
97
+ return false;
98
+ }
99
+ const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
100
+ if (step !== 0) {
101
+ const count = items.length;
102
+ this.inside.reach(items[at < 0 ? (step > 0 ? 0 : count - 1) : (at + step + count) % count]);
103
+ return true;
104
+ }
105
+ if (event.key === 'Enter') {
106
+ items[at]?.renderRoot.querySelector('[part="item"]')?.click();
107
+ return true;
108
+ }
109
+ return false;
110
+ }
111
+ status() {
112
+ const { host } = this;
113
+ const count = this.filtering ? this.keptRows.length : -1;
114
+ this.inside.announced =
115
+ count === 0
116
+ ? host.noResultsText
117
+ : count > 0
118
+ ? formatWords(host, 'resultsLabel', { count })
119
+ : '';
120
+ return count === 0
121
+ ? html `<p part="empty"><slot name="no-results">${host.noResultsText}</slot></p>`
122
+ : undefined;
123
+ }
124
+ drawFilter(rows, busy) {
125
+ return drawFilter(this.host, rows, busy, this.handleInput);
126
+ }
127
+ updated() {
128
+ const { host } = this;
129
+ adopt(host, this.sheet, host.filterable);
130
+ if (!host.filterable) {
131
+ return;
132
+ }
133
+ markMatches(host, this.keptRows, this.filtering ? host.filter.trim() : '');
134
+ const changed = host.filter !== this.seen;
135
+ this.seen = host.filter;
136
+ const items = this.inside.items;
137
+ if (changed && host.open && !items.includes(this.highlighted)) {
138
+ this.inside.reach(items[0]);
139
+ }
140
+ }
141
+ }
142
+ const adopt = (element, given, on) => {
143
+ const root = element.shadowRoot;
144
+ const sheet = given?.styleSheet;
145
+ if (!root || !sheet || root.adoptedStyleSheets.includes(sheet) === on) {
146
+ return;
147
+ }
148
+ root.adoptedStyleSheets = on
149
+ ? [...root.adoptedStyleSheets, sheet]
150
+ : root.adoptedStyleSheets.filter((one) => one !== sheet);
151
+ };
152
+ const onPage = (of, root = document) => [...root.querySelectorAll('*')].flatMap((node) => [
153
+ ...(node instanceof of ? [node] : []),
154
+ ...(node.shadowRoot ? onPage(of, node.shadowRoot) : []),
155
+ ]);
156
+ export const filterMenu = (element, sheet) => {
157
+ const attach = (menu) => {
158
+ const inside = menu;
159
+ if (inside.filterer) {
160
+ inside.filterer.sheet ??= sheet;
161
+ return;
162
+ }
163
+ new MenuFilterController(menu, sheet);
164
+ };
165
+ element.addInitializer((host) => attach(host));
166
+ if (typeof document !== 'undefined') {
167
+ for (const menu of onPage(element)) {
168
+ attach(menu);
169
+ menu.requestUpdate();
170
+ }
171
+ }
172
+ };
@@ -72,7 +72,15 @@ declare const HelixHeadlessMenu_base: (new (...args: any[]) => import("@fusion.d
72
72
  * opens it from the keyboard, and the trigger is described by
73
73
  * `long-press-label`, which says so.
74
74
  *
75
- * **A filter field.** With `filterable`, the menu opens with focus in a field
75
+ * **A filter field** is a separate import, `@fusion.dev/helix/menu/filter`, so
76
+ * a menu that never filters carries none of its code:
77
+ *
78
+ * ```js
79
+ * import '@fusion.dev/helix/menu'
80
+ * import '@fusion.dev/helix/menu/filter'
81
+ * ```
82
+ *
83
+ * With it imported and `filterable` set, the menu opens with focus in a field
76
84
  * above its rows, and typing narrows the rows of the page the keys move in,
77
85
  * as React Aria's `Autocomplete` around a `Menu` does. A row is kept where its
78
86
  * words hold the filter, in any case, or where `filterMatch` says so. Each
@@ -165,7 +173,11 @@ export declare class HelixHeadlessMenu extends HelixHeadlessMenu_base {
165
173
  filter: string;
166
174
  /**
167
175
  * Whether the menu shows a field above its rows that narrows them as the
168
- * reader types, with focus staying in the field.
176
+ * reader types, with focus staying in the field, once
177
+ * `@fusion.dev/helix/menu/filter` (or `@fusion.dev/helix-headless/menu/filter`)
178
+ * is imported. Without that import it does nothing, so a menu that never
179
+ * filters carries none of the filter's code, and a development build says
180
+ * which import is missing.
169
181
  */
170
182
  filterable: boolean;
171
183
  /**
@@ -305,31 +317,23 @@ export declare class HelixHeadlessMenu extends HelixHeadlessMenu_base {
305
317
  private get items();
306
318
  /** The element that is the `menu`: the surface, or with `filterable` the box after the field. */
307
319
  private get menuBox();
308
- /** The filter field, with `filterable`. */
309
- private get field();
310
- /** The submenu the filter narrows, or null for the top page. */
311
- private filterPage;
312
- /** The filter a listener refused, which narrows nothing. */
313
- private refused;
314
- /** Whether the filter is narrowing the rows. */
315
- private get filtering();
316
- /** Whether the filter keeps a row: one of another page, or one whose words it matches. */
317
- private kept;
318
320
  /**
319
- * Asks to filter by new text, which the field and `filter` then hold. A
320
- * refusal keeps every row showing, for a page that narrows them itself.
321
- */
321
+ * The filter `menu/filter` gives this menu, which `filterable` turns on.
322
+ *
323
+ * Declared and never initialized, because the entry sets it from the
324
+ * constructor Lit runs initializers in, which is before this class's own
325
+ * fields: an initializer here would set it back to `undefined`.
326
+ */
327
+ private filterer;
328
+ /** The filter, while `filterable` is set and `menu/filter` is imported. */
329
+ private get filters();
330
+ /** Whether the filter keeps a row, which every row is without one. */
331
+ private kept;
332
+ /** Asks to filter by new text. See `MenuFilterController.request`. */
322
333
  private requestFilter;
323
- private readonly handleFilterInput;
324
- /** Filters by what the reader typed, and highlights the first row kept. */
325
- private filterBy;
326
- /** The row the keys reached while focus stays in the filter field. */
327
- private highlighted;
328
334
  /**
329
- * Moves the reader to a row: focus, or with `filterable` the highlight,
330
- * which the field names with `aria-activedescendant`. An element reference,
331
- * because the field is in this shadow root and the row in the tree around
332
- * it, the one direction a reference crosses.
335
+ * Moves the reader to a row: focus, or with a filter the highlight, focus
336
+ * staying in the filter field.
333
337
  */
334
338
  private reach;
335
339
  /** Watches for rows taken away while the menu is open. */
@@ -408,11 +412,6 @@ export declare class HelixHeadlessMenu extends HelixHeadlessMenu_base {
408
412
  * `open-on="long-press"`, or a touch or a pen held on a context menu's area.
409
413
  */
410
414
  private readonly handlePointerDown;
411
- /**
412
- * Keeps focus in the filter field through a press on a row or the way
413
- * back, as React Aria's virtual focus does: the press still chooses.
414
- */
415
- private readonly handleMouseDown;
416
415
  private readonly handlePointerMove;
417
416
  private readonly handlePointerUp;
418
417
  private readonly handlePointerCancel;