@fusion.dev/helix-headless 0.183.0 → 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.
- package/custom-elements.json +483 -76
- package/dist/code-block/root/code-block-root.d.ts +42 -1
- package/dist/code-block/root/code-block-root.js +25 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/menu/filter/index.d.ts +1 -0
- package/dist/menu/filter/index.js +4 -0
- package/dist/menu/filter/menu-filter.d.ts +114 -0
- package/dist/menu/filter/menu-filter.js +172 -0
- package/dist/menu/root/menu-root.d.ts +28 -29
- package/dist/menu/root/menu-root.js +32 -107
- package/dist/time-input/root/time-input-root.d.ts +11 -0
- package/dist/time-input/root/time-input-root.js +11 -0
- package/lint.json +1 -1
- package/package.json +5 -5
- package/types/jsx.d.ts +124 -10
- package/types/vue.d.ts +27 -4
- package/types/words.d.ts +7 -0
|
@@ -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
|
-
|
|
349
|
-
|
|
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);
|
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,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
|
|
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
|
-
*
|
|
320
|
-
*
|
|
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
|
|
330
|
-
*
|
|
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;
|