@fusion.dev/helix-core 0.142.0 → 0.143.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 +1475 -54
- package/dist/controllers/aria-forward-controller/aria-forward-controller.d.ts +55 -0
- package/dist/controllers/aria-forward-controller/aria-forward-controller.js +72 -16
- package/dist/controllers/arrival-controller/arrival-controller.d.ts +12 -0
- package/dist/controllers/arrival-controller/arrival-controller.js +10 -0
- package/dist/controllers/chart-pointer-controller/chart-pointer-controller.d.ts +10 -0
- package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +5 -1
- package/dist/controllers/copy-controller/copy-controller.js +5 -0
- package/dist/controllers/dismiss-controller/dismiss-controller.js +40 -3
- package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.d.ts +79 -1
- package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.js +80 -10
- package/dist/controllers/popover-controller/popover-controller.d.ts +11 -0
- package/dist/controllers/popover-controller/popover-controller.js +6 -0
- package/dist/controllers/source-controller/source-controller.d.ts +22 -0
- package/dist/controllers/source-controller/source-controller.js +27 -1
- package/dist/controllers/virtualize-controller/virtualize-controller.d.ts +16 -0
- package/dist/controllers/virtualize-controller/virtualize-controller.js +24 -2
- package/dist/events/events.d.ts +102 -4
- package/dist/events/events.js +84 -0
- package/dist/helix-element/react-bridge.js +6 -4
- package/dist/helix-form-element/helix-form-element.d.ts +131 -2
- package/dist/helix-form-element/helix-form-element.js +216 -6
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/mixins/mixin-chart-source/mixin-chart-source.d.ts +8 -0
- package/dist/mixins/mixin-chart-source/mixin-chart-source.js +4 -0
- package/dist/mixins/mixin-collection/mixin-collection.d.ts +4 -2
- package/dist/mixins/mixin-collection/mixin-collection.js +8 -6
- package/dist/mixins/mixin-controller-open/mixin-controller-open.js +19 -3
- package/dist/mixins/mixin-dismissible/mixin-dismissible.js +39 -0
- package/dist/mixins/mixin-field/mixin-field.js +38 -8
- package/dist/mixins/mixin-picker-editing/mixin-picker-editing.d.ts +8 -3
- package/dist/mixins/mixin-picker-editing/mixin-picker-editing.js +6 -0
- package/dist/mixins/mixin-picker-field/mixin-picker-field.d.ts +27 -4
- package/dist/mixins/mixin-picker-field/mixin-picker-field.js +83 -1
- package/dist/utils/draw-tooltip/draw-tooltip.d.ts +3 -0
- package/dist/utils/draw-tooltip/draw-tooltip.js +5 -0
- package/dist/utils/read-series/read-series.d.ts +28 -0
- package/dist/utils/read-series/read-series.js +31 -4
- package/dist/utils/report-runtime/report-runtime.d.ts +10 -0
- package/dist/utils/scroll-lock/scroll-lock.d.ts +16 -0
- package/dist/utils/scroll-lock/scroll-lock.js +30 -0
- package/dist/utils/written-list/written-list.d.ts +11 -2
- package/dist/utils/written-list/written-list.js +3 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -4
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
2
2
|
/** What the controller needs to know: which element inside is the control. */
|
|
3
3
|
export interface AriaForwardOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Whether a host `aria-label` names the control too.
|
|
6
|
+
*
|
|
7
|
+
* For a control named by its content, a button or a switch, which has no
|
|
8
|
+
* `label` to render it into. A field renders its `aria-label` as a clipped
|
|
9
|
+
* label already, so it leaves this off. A function is asked on every
|
|
10
|
+
* forward, for a subclass that names the control itself.
|
|
11
|
+
*/
|
|
12
|
+
label?: boolean | (() => boolean);
|
|
13
|
+
/**
|
|
14
|
+
* Elements read after whatever names the control from outside, the host's
|
|
15
|
+
* `aria-labelledby` or its `<label>`.
|
|
16
|
+
*
|
|
17
|
+
* For a control whose name is the label and then what it shows, as a
|
|
18
|
+
* native `<select>` is named. Added only where the host gives a name, so a
|
|
19
|
+
* control named by its own template keeps its own.
|
|
20
|
+
*/
|
|
21
|
+
labelledByAfter?: () => Element[];
|
|
22
|
+
/**
|
|
23
|
+
* Whether the control has a name of its own, where its own labels do not
|
|
24
|
+
* say.
|
|
25
|
+
*
|
|
26
|
+
* A file input is labeled by the trigger that opens its picker, "Choose
|
|
27
|
+
* files", which is an action rather than a name, so a page's `<label>`
|
|
28
|
+
* should still name it. Absent, the control's own `aria-label`,
|
|
29
|
+
* `aria-labelledby` and labels with words in them decide.
|
|
30
|
+
*/
|
|
31
|
+
named?: () => boolean;
|
|
4
32
|
/** The focusable thing in the shadow root, or null before it renders. */
|
|
5
33
|
target: () => HTMLElement | null;
|
|
6
34
|
}
|
|
@@ -42,6 +70,13 @@ export interface AriaForwardOptions {
|
|
|
42
70
|
* a framework rendering a tooltip after this element upgrades, or a consumer
|
|
43
71
|
* pointing `aria-describedby` at a different id, changes an attribute and
|
|
44
72
|
* nothing schedules a Lit update.
|
|
73
|
+
*
|
|
74
|
+
* **A `<label>` that names the host names the control too**, when the control
|
|
75
|
+
* has no name of its own. The platform gives a form-associated host its labels
|
|
76
|
+
* and the host is a generic the reader never lands on, so a `<label for>` or a
|
|
77
|
+
* wrapping `<label>` left the textbox unnamed. The labels travel the same way a
|
|
78
|
+
* forwarded `aria-labelledby` does, as element references, and are read again
|
|
79
|
+
* when focus arrives, since a label can be added without the host hearing.
|
|
45
80
|
*/
|
|
46
81
|
export declare class AriaForwardController implements ReactiveController {
|
|
47
82
|
private readonly host;
|
|
@@ -51,7 +86,25 @@ export declare class AriaForwardController implements ReactiveController {
|
|
|
51
86
|
private readonly forwarded;
|
|
52
87
|
/** The control the record above is about, so a replacement resets it. */
|
|
53
88
|
private recorded;
|
|
89
|
+
/** Whether a host `aria-label` is carried down. See the option. */
|
|
90
|
+
private readonly asked;
|
|
91
|
+
private get label();
|
|
92
|
+
/** The component's own answer to whether the control is named. See the option. */
|
|
93
|
+
private readonly named;
|
|
94
|
+
/** What is read after the host's names. See the option. */
|
|
95
|
+
private readonly after;
|
|
54
96
|
constructor(host: ReactiveControllerHost & HTMLElement, options: AriaForwardOptions);
|
|
97
|
+
/**
|
|
98
|
+
* Whether the control has a name that did not come from this controller: an
|
|
99
|
+
* `aria-label`, an `aria-labelledby` of its own, or a label of its own with
|
|
100
|
+
* words in it.
|
|
101
|
+
*/
|
|
102
|
+
private namedByItself;
|
|
103
|
+
/**
|
|
104
|
+
* The labels the platform associated with the host, for a control with no
|
|
105
|
+
* name of its own, and none otherwise.
|
|
106
|
+
*/
|
|
107
|
+
private hostLabels;
|
|
55
108
|
/**
|
|
56
109
|
* Resolves the host's references and hands them to the control.
|
|
57
110
|
*
|
|
@@ -65,5 +118,7 @@ export declare class AriaForwardController implements ReactiveController {
|
|
|
65
118
|
forward(): void;
|
|
66
119
|
hostConnected(): void;
|
|
67
120
|
hostDisconnected(): void;
|
|
121
|
+
/** Forwards again as focus arrives, for a label added since the last update. */
|
|
122
|
+
private readonly refresh;
|
|
68
123
|
hostUpdated(): void;
|
|
69
124
|
}
|
|
@@ -1,16 +1,61 @@
|
|
|
1
1
|
import { elementById } from '../../utils/element-by-id/element-by-id.js';
|
|
2
|
+
const shownText = (node) => {
|
|
3
|
+
if (node instanceof HTMLSlotElement) {
|
|
4
|
+
return node.assignedNodes({ flatten: true }).map(shownText).join('');
|
|
5
|
+
}
|
|
6
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
7
|
+
return node.textContent ?? '';
|
|
8
|
+
}
|
|
9
|
+
if (node instanceof HTMLElement && node.hidden) {
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
return [...node.childNodes].map(shownText).join('');
|
|
13
|
+
};
|
|
2
14
|
const RELATIONSHIPS = [
|
|
3
15
|
['aria-describedby', 'ariaDescribedByElements'],
|
|
4
16
|
['aria-labelledby', 'ariaLabelledByElements'],
|
|
5
17
|
];
|
|
6
|
-
const STATES = [
|
|
18
|
+
const STATES = [
|
|
19
|
+
'aria-expanded',
|
|
20
|
+
'aria-haspopup',
|
|
21
|
+
'aria-controls',
|
|
22
|
+
'aria-invalid',
|
|
23
|
+
'aria-pressed',
|
|
24
|
+
];
|
|
7
25
|
export class AriaForwardController {
|
|
26
|
+
get label() {
|
|
27
|
+
return typeof this.asked === 'function' ? this.asked() : this.asked;
|
|
28
|
+
}
|
|
8
29
|
constructor(host, options) {
|
|
9
30
|
this.forwarded = new Set();
|
|
31
|
+
this.refresh = () => {
|
|
32
|
+
this.forward();
|
|
33
|
+
};
|
|
10
34
|
this.host = host;
|
|
11
35
|
this.target = options.target;
|
|
36
|
+
this.asked = options.label ?? false;
|
|
37
|
+
this.named = options.named;
|
|
38
|
+
this.after = options.labelledByAfter;
|
|
12
39
|
host.addController(this);
|
|
13
40
|
}
|
|
41
|
+
namedByItself(target) {
|
|
42
|
+
if (target.getAttribute('aria-label')) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (!this.forwarded.has('aria-labelledby') && target.hasAttribute('aria-labelledby')) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
const own = target.labels;
|
|
49
|
+
return [...(own ?? [])].some((label) => shownText(label).trim() !== '');
|
|
50
|
+
}
|
|
51
|
+
hostLabels(target) {
|
|
52
|
+
const labels = this.host.labels;
|
|
53
|
+
const named = this.named ? this.named() : this.namedByItself(target);
|
|
54
|
+
if (!labels || labels.length === 0 || named) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
return [...labels].filter((label) => label instanceof HTMLElement);
|
|
58
|
+
}
|
|
14
59
|
forward() {
|
|
15
60
|
const target = this.target();
|
|
16
61
|
if (!target) {
|
|
@@ -20,17 +65,34 @@ export class AriaForwardController {
|
|
|
20
65
|
this.forwarded.clear();
|
|
21
66
|
this.recorded = target;
|
|
22
67
|
}
|
|
68
|
+
for (const attribute of this.label ? [...STATES, 'aria-label'] : STATES) {
|
|
69
|
+
const value = this.host.getAttribute(attribute);
|
|
70
|
+
if (value === null) {
|
|
71
|
+
if (this.forwarded.has(attribute)) {
|
|
72
|
+
target.removeAttribute(attribute);
|
|
73
|
+
this.forwarded.delete(attribute);
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
target.setAttribute(attribute, value);
|
|
78
|
+
this.forwarded.add(attribute);
|
|
79
|
+
}
|
|
23
80
|
for (const [attribute, property] of RELATIONSHIPS) {
|
|
24
81
|
if (!(property in target)) {
|
|
25
82
|
continue;
|
|
26
83
|
}
|
|
27
84
|
const ids = (this.host.getAttribute(attribute) ?? '').split(/\s+/).filter(Boolean);
|
|
28
|
-
const
|
|
85
|
+
const referenced = ids
|
|
29
86
|
.map((id) => elementById(this.host, id))
|
|
30
87
|
.filter((element) => element !== null);
|
|
88
|
+
const elements = referenced.length === 0 && attribute === 'aria-labelledby'
|
|
89
|
+
? this.hostLabels(target)
|
|
90
|
+
: referenced;
|
|
31
91
|
const reflect = target;
|
|
32
92
|
if (elements.length > 0) {
|
|
33
|
-
|
|
93
|
+
const after = attribute === 'aria-labelledby' ? (this.after?.() ?? []) : [];
|
|
94
|
+
const named = elements;
|
|
95
|
+
reflect[property] = [...named, ...after.filter((one) => !named.includes(one))];
|
|
34
96
|
this.forwarded.add(attribute);
|
|
35
97
|
continue;
|
|
36
98
|
}
|
|
@@ -39,28 +101,22 @@ export class AriaForwardController {
|
|
|
39
101
|
this.forwarded.delete(attribute);
|
|
40
102
|
}
|
|
41
103
|
}
|
|
42
|
-
for (const attribute of STATES) {
|
|
43
|
-
const value = this.host.getAttribute(attribute);
|
|
44
|
-
if (value === null) {
|
|
45
|
-
if (this.forwarded.has(attribute)) {
|
|
46
|
-
target.removeAttribute(attribute);
|
|
47
|
-
this.forwarded.delete(attribute);
|
|
48
|
-
}
|
|
49
|
-
continue;
|
|
50
|
-
}
|
|
51
|
-
target.setAttribute(attribute, value);
|
|
52
|
-
this.forwarded.add(attribute);
|
|
53
|
-
}
|
|
54
104
|
}
|
|
55
105
|
hostConnected() {
|
|
56
106
|
this.observer = new MutationObserver(() => this.forward());
|
|
57
107
|
this.observer.observe(this.host, {
|
|
58
|
-
attributeFilter: [
|
|
108
|
+
attributeFilter: [
|
|
109
|
+
...RELATIONSHIPS.map(([attribute]) => attribute),
|
|
110
|
+
...STATES,
|
|
111
|
+
...(this.asked === false ? [] : ['aria-label']),
|
|
112
|
+
],
|
|
59
113
|
});
|
|
114
|
+
this.host.addEventListener('focusin', this.refresh);
|
|
60
115
|
}
|
|
61
116
|
hostDisconnected() {
|
|
62
117
|
this.observer?.disconnect();
|
|
63
118
|
this.observer = undefined;
|
|
119
|
+
this.host.removeEventListener('focusin', this.refresh);
|
|
64
120
|
}
|
|
65
121
|
hostUpdated() {
|
|
66
122
|
this.forward();
|
|
@@ -5,6 +5,11 @@ export interface ArrivalOptions {
|
|
|
5
5
|
text: () => string;
|
|
6
6
|
/** Whether the element is arriving now, rather than with the page. */
|
|
7
7
|
arriving: () => boolean;
|
|
8
|
+
/**
|
|
9
|
+
* How long the region keeps the words once it has said them, in
|
|
10
|
+
* milliseconds. Defaults to seven seconds.
|
|
11
|
+
*/
|
|
12
|
+
hold?: number;
|
|
8
13
|
}
|
|
9
14
|
/**
|
|
10
15
|
* The words of an element that is announced when it appears, for a region that
|
|
@@ -24,6 +29,12 @@ export interface ArrivalOptions {
|
|
|
24
29
|
* second time on the headless tier, which has no sheet to clip the region, and
|
|
25
30
|
* a served page changed its picture on hydrating.
|
|
26
31
|
*
|
|
32
|
+
* **And emptied again once it has been heard.** The words stay in the element
|
|
33
|
+
* itself, so a region that kept its copy put the message in the accessibility
|
|
34
|
+
* tree twice, and a reader moving through the page met it twice. The region
|
|
35
|
+
* keeps the copy for `hold` and then lets it go, which a polite region does not
|
|
36
|
+
* read out, removals being no news.
|
|
37
|
+
*
|
|
27
38
|
* The component renders `said` into its region and calls `say` when what it
|
|
28
39
|
* holds changes, from a `slotchange`.
|
|
29
40
|
*/
|
|
@@ -35,6 +46,7 @@ export declare class ArrivalController implements ReactiveController {
|
|
|
35
46
|
/** The words last accounted for, said or not. */
|
|
36
47
|
private heard;
|
|
37
48
|
private frame;
|
|
49
|
+
private timer;
|
|
38
50
|
constructor(host: ReactiveControllerHost, options: ArrivalOptions);
|
|
39
51
|
hostConnected(): void;
|
|
40
52
|
hostDisconnected(): void;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const HOLD = 7000;
|
|
1
2
|
export class ArrivalController {
|
|
2
3
|
constructor(host, options) {
|
|
3
4
|
this.host = host;
|
|
@@ -22,6 +23,9 @@ export class ArrivalController {
|
|
|
22
23
|
hostDisconnected() {
|
|
23
24
|
cancelAnimationFrame(this.frame);
|
|
24
25
|
this.frame = 0;
|
|
26
|
+
clearTimeout(this.timer);
|
|
27
|
+
this.timer = undefined;
|
|
28
|
+
this.said = '';
|
|
25
29
|
}
|
|
26
30
|
say() {
|
|
27
31
|
const next = this.options.text();
|
|
@@ -31,5 +35,11 @@ export class ArrivalController {
|
|
|
31
35
|
this.heard = next;
|
|
32
36
|
this.said = next;
|
|
33
37
|
this.host.requestUpdate();
|
|
38
|
+
clearTimeout(this.timer);
|
|
39
|
+
this.timer = setTimeout(() => {
|
|
40
|
+
this.timer = undefined;
|
|
41
|
+
this.said = '';
|
|
42
|
+
this.host.requestUpdate();
|
|
43
|
+
}, this.options.hold ?? HOLD);
|
|
34
44
|
}
|
|
35
45
|
}
|
|
@@ -110,6 +110,16 @@ export declare class ChartPointerController implements ReactiveController {
|
|
|
110
110
|
* can change its direction without the chart updating.
|
|
111
111
|
*/
|
|
112
112
|
get mirrored(): boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Whether a pointer event may move the reading, which only a mouse or a pen
|
|
115
|
+
* may.
|
|
116
|
+
*
|
|
117
|
+
* Public for a chart that hears the pointer itself, a donut's slices and a
|
|
118
|
+
* heatmap's cells, so the rule a coarse pointer gets is this one everywhere.
|
|
119
|
+
* Heard unfiltered, a touch opened the bubble under the finger and left it
|
|
120
|
+
* there after the finger lifted.
|
|
121
|
+
*/
|
|
122
|
+
accepts(event: Event): boolean;
|
|
113
123
|
/** Puts the pointer on a reading, for a chart that resolves it some other way. */
|
|
114
124
|
set(at: number): void;
|
|
115
125
|
private readonly move;
|
|
@@ -5,7 +5,7 @@ export class ChartPointerController {
|
|
|
5
5
|
this.at = -1;
|
|
6
6
|
this.axis = 'inline';
|
|
7
7
|
this.move = (event) => {
|
|
8
|
-
if (this.axis === 'none' || (event
|
|
8
|
+
if (this.axis === 'none' || !this.accepts(event)) {
|
|
9
9
|
return;
|
|
10
10
|
}
|
|
11
11
|
const box = this.box();
|
|
@@ -63,6 +63,10 @@ export class ChartPointerController {
|
|
|
63
63
|
get mirrored() {
|
|
64
64
|
return isRtl(this.host);
|
|
65
65
|
}
|
|
66
|
+
accepts(event) {
|
|
67
|
+
const type = event.pointerType;
|
|
68
|
+
return type === 'mouse' || type === 'pen';
|
|
69
|
+
}
|
|
66
70
|
set(at) {
|
|
67
71
|
if (at !== this.at) {
|
|
68
72
|
this.at = at;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HelixCopyEvent } from '../../events/events.js';
|
|
2
|
+
import { deepActiveElement } from '../../utils/focusable-elements/focusable-elements.js';
|
|
2
3
|
const write = async (text) => {
|
|
3
4
|
try {
|
|
4
5
|
await navigator.clipboard.writeText(text);
|
|
@@ -6,6 +7,7 @@ const write = async (text) => {
|
|
|
6
7
|
}
|
|
7
8
|
catch {
|
|
8
9
|
}
|
|
10
|
+
const had = deepActiveElement();
|
|
9
11
|
const carrier = document.createElement('textarea');
|
|
10
12
|
carrier.value = text;
|
|
11
13
|
carrier.setAttribute('readonly', '');
|
|
@@ -22,6 +24,9 @@ const write = async (text) => {
|
|
|
22
24
|
wrote = false;
|
|
23
25
|
}
|
|
24
26
|
carrier.remove();
|
|
27
|
+
if (had instanceof HTMLElement || had instanceof SVGElement) {
|
|
28
|
+
had.focus({ preventScroll: true });
|
|
29
|
+
}
|
|
25
30
|
return wrote;
|
|
26
31
|
};
|
|
27
32
|
export class CopyController {
|
|
@@ -8,6 +8,35 @@ const deepDepth = (node) => {
|
|
|
8
8
|
}
|
|
9
9
|
return depth;
|
|
10
10
|
};
|
|
11
|
+
const modalAround = (event) => {
|
|
12
|
+
for (const node of event.composedPath()) {
|
|
13
|
+
if (node instanceof HTMLDialogElement && node.open) {
|
|
14
|
+
try {
|
|
15
|
+
if (node.matches(':modal')) {
|
|
16
|
+
return node;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
};
|
|
26
|
+
const flatContains = (container, node) => {
|
|
27
|
+
let at = node;
|
|
28
|
+
while (at) {
|
|
29
|
+
if (at === container) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
at =
|
|
33
|
+
at.assignedSlot ??
|
|
34
|
+
at.parentNode ??
|
|
35
|
+
at.host ??
|
|
36
|
+
null;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
};
|
|
11
40
|
const REGISTRY = Symbol.for('fusion.helix.dismiss.listening');
|
|
12
41
|
const globals = globalThis;
|
|
13
42
|
if (globals[REGISTRY] === undefined) {
|
|
@@ -26,15 +55,23 @@ export class DismissController {
|
|
|
26
55
|
return;
|
|
27
56
|
}
|
|
28
57
|
taken.helixDismissTaken = true;
|
|
29
|
-
|
|
30
|
-
let
|
|
31
|
-
|
|
58
|
+
const modal = modalAround(event);
|
|
59
|
+
let deepest;
|
|
60
|
+
let found = -1;
|
|
61
|
+
for (const other of [this, ...listening]) {
|
|
62
|
+
if (modal && !flatContains(modal, other.host)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
32
65
|
const depth = deepDepth(other.host);
|
|
33
66
|
if (depth > found) {
|
|
34
67
|
found = depth;
|
|
35
68
|
deepest = other;
|
|
36
69
|
}
|
|
37
70
|
}
|
|
71
|
+
if (!deepest) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
event.preventDefault();
|
|
38
75
|
deepest.onDismiss();
|
|
39
76
|
};
|
|
40
77
|
this.handleWindowBlur = () => {
|
|
@@ -62,6 +62,15 @@ export interface KeyboardNavigationOptions {
|
|
|
62
62
|
* could not be opened from the keyboard at all.
|
|
63
63
|
*/
|
|
64
64
|
reserved?: () => string[];
|
|
65
|
+
/**
|
|
66
|
+
* The words of an item, which turns on typeahead: a printable key moves to
|
|
67
|
+
* the next item whose words start with what was typed.
|
|
68
|
+
*
|
|
69
|
+
* Off unless given. A set whose items have no words a reader would type, or
|
|
70
|
+
* that holds a text field of its own, leaves it out. See `Typeahead` for how
|
|
71
|
+
* the letters are matched.
|
|
72
|
+
*/
|
|
73
|
+
typeahead?: (item: NavigableItem) => string;
|
|
65
74
|
/**
|
|
66
75
|
* Whether the ends join up, which they do unless a set says otherwise.
|
|
67
76
|
*
|
|
@@ -82,6 +91,54 @@ export interface KeyboardNavigationOptions {
|
|
|
82
91
|
*/
|
|
83
92
|
wrap?: boolean;
|
|
84
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether a press types a character, which is what typeahead reads.
|
|
96
|
+
*
|
|
97
|
+
* One character with no Control, Command or Alt held, so a shortcut such as
|
|
98
|
+
* Control+A is never taken for the letter A. Shift is allowed, because a
|
|
99
|
+
* capital is still a letter.
|
|
100
|
+
*/
|
|
101
|
+
export declare const typesCharacter: (event: KeyboardEvent) => boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Finds an item by the letters a reader types at a set, the way a native
|
|
104
|
+
* `<select>` does.
|
|
105
|
+
*
|
|
106
|
+
* Letters typed within `timeout` milliseconds of each other build one search,
|
|
107
|
+
* so typing "ca" moves to "Canada" past "Cambodia". The same letter typed again
|
|
108
|
+
* moves to the next item that starts with it, so pressing "c" three times visits
|
|
109
|
+
* the first three. A pause starts a new search.
|
|
110
|
+
*
|
|
111
|
+
* Matching ignores case and leading spaces, and searches from the reader's
|
|
112
|
+
* position, wrapping at the end. A search that matches nothing moves nothing.
|
|
113
|
+
*
|
|
114
|
+
* Holds only the letters, so one instance serves any set: the caller passes the
|
|
115
|
+
* words of the items and where the reader is on every key.
|
|
116
|
+
*/
|
|
117
|
+
export declare class Typeahead {
|
|
118
|
+
private letters;
|
|
119
|
+
private last;
|
|
120
|
+
private readonly timeout;
|
|
121
|
+
constructor({ timeout }?: {
|
|
122
|
+
timeout?: number;
|
|
123
|
+
});
|
|
124
|
+
/**
|
|
125
|
+
* Whether a search is under way.
|
|
126
|
+
*
|
|
127
|
+
* A set that chooses on Space asks this first, because a Space typed in the
|
|
128
|
+
* middle of a search is part of the words, as in "New York".
|
|
129
|
+
*/
|
|
130
|
+
typing(now?: number): boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Adds a character to the search, and returns the index of the item it
|
|
133
|
+
* matches, or `-1` where none does.
|
|
134
|
+
*
|
|
135
|
+
* `texts` are the words of the items a reader can reach, in order, and
|
|
136
|
+
* `from` is the index of the item the reader is on, or `-1`.
|
|
137
|
+
*/
|
|
138
|
+
match(key: string, texts: readonly string[], from: number, now?: number): number;
|
|
139
|
+
/** Ends the search, so the next letter starts a new one. */
|
|
140
|
+
reset(): void;
|
|
141
|
+
}
|
|
85
142
|
/**
|
|
86
143
|
* Moves through a set of items with the arrow keys, and chooses as it goes.
|
|
87
144
|
*
|
|
@@ -107,6 +164,11 @@ export interface KeyboardNavigationOptions {
|
|
|
107
164
|
* Disabled items are skipped rather than stopped at, so a set with a gap in it
|
|
108
165
|
* still moves in one press.
|
|
109
166
|
*
|
|
167
|
+
* **Typeahead is opt-in.** Pass `typeahead` with a function that returns an
|
|
168
|
+
* item's words, and a printable key moves to the next item whose words start
|
|
169
|
+
* with the letters typed, choosing it where arriving chooses. A key that a text
|
|
170
|
+
* field inside the set is typing into is left to the field.
|
|
171
|
+
*
|
|
110
172
|
* A set that starts from focus rather than from the selection, or that moves
|
|
111
173
|
* without choosing, says so through `origin` and `selects`. Neither is a reason
|
|
112
174
|
* to keep a hand-written copy, and neither exempts a set from both axes.
|
|
@@ -118,7 +180,23 @@ export declare class KeyboardNavigationController implements ReactiveController
|
|
|
118
180
|
private readonly selects;
|
|
119
181
|
private readonly reserved;
|
|
120
182
|
private readonly wrap;
|
|
121
|
-
|
|
183
|
+
private readonly words;
|
|
184
|
+
private readonly typeahead;
|
|
185
|
+
constructor(host: ReactiveControllerHost & HTMLElement, { items, origin, reserved, selects, typeahead, wrap, }: KeyboardNavigationOptions);
|
|
186
|
+
/** Moves to an item, and chooses it where arriving is choosing. */
|
|
187
|
+
private arrive;
|
|
188
|
+
/**
|
|
189
|
+
* Where the reader is: the item the press came from, or the selection.
|
|
190
|
+
*
|
|
191
|
+
* **Focus first, where nothing is chosen.** A reader who tabs into an
|
|
192
|
+
* unanswered set lands on its first item, and the selection then says `-1`.
|
|
193
|
+
* Starting from `-1`, a forward step lands on that same first item, so the
|
|
194
|
+
* first press appeared to do nothing and the second one moved. A native radio
|
|
195
|
+
* group moves to the second radio on that press, and so does this.
|
|
196
|
+
*/
|
|
197
|
+
private current;
|
|
198
|
+
/** Moves to the item whose words start with what was typed, if any. */
|
|
199
|
+
private handleTypeahead;
|
|
122
200
|
private readonly handleKeydown;
|
|
123
201
|
hostConnected(): void;
|
|
124
202
|
hostDisconnected(): void;
|
|
@@ -1,10 +1,47 @@
|
|
|
1
1
|
import { inlineStep } from '../../utils/inline-step/inline-step.js';
|
|
2
2
|
import { keyClaimed } from '../../utils/key-claimed/key-claimed.js';
|
|
3
3
|
const KEYS = new Set(['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'End', 'Home']);
|
|
4
|
+
export const typesCharacter = (event) => event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
|
|
5
|
+
export class Typeahead {
|
|
6
|
+
constructor({ timeout = 500 } = {}) {
|
|
7
|
+
this.letters = '';
|
|
8
|
+
this.last = 0;
|
|
9
|
+
this.timeout = timeout;
|
|
10
|
+
}
|
|
11
|
+
typing(now = Date.now()) {
|
|
12
|
+
return this.letters !== '' && now - this.last <= this.timeout;
|
|
13
|
+
}
|
|
14
|
+
match(key, texts, from, now = Date.now()) {
|
|
15
|
+
if (!this.typing(now)) {
|
|
16
|
+
this.letters = '';
|
|
17
|
+
}
|
|
18
|
+
this.last = now;
|
|
19
|
+
this.letters += key.toLowerCase();
|
|
20
|
+
const words = texts.map((text) => text.trim().toLowerCase());
|
|
21
|
+
const repeated = [...this.letters].every((letter) => letter === this.letters[0]);
|
|
22
|
+
const needle = repeated ? (this.letters[0] ?? '') : this.letters;
|
|
23
|
+
const start = repeated ? from + 1 : Math.max(from, 0);
|
|
24
|
+
for (let step = 0; step < words.length; step += 1) {
|
|
25
|
+
const at = (start + step) % words.length;
|
|
26
|
+
if (words[at]?.startsWith(needle)) {
|
|
27
|
+
return at;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return -1;
|
|
31
|
+
}
|
|
32
|
+
reset() {
|
|
33
|
+
this.letters = '';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
4
36
|
export class KeyboardNavigationController {
|
|
5
|
-
constructor(host, { items, origin = 'selection', reserved = () => [], selects = () => true, wrap = true, }) {
|
|
37
|
+
constructor(host, { items, origin = 'selection', reserved = () => [], selects = () => true, typeahead, wrap = true, }) {
|
|
38
|
+
this.typeahead = new Typeahead();
|
|
6
39
|
this.handleKeydown = (event) => {
|
|
7
|
-
if (!KEYS.has(event.key)
|
|
40
|
+
if (!KEYS.has(event.key)) {
|
|
41
|
+
this.handleTypeahead(event);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (this.reserved().includes(event.key)) {
|
|
8
45
|
return;
|
|
9
46
|
}
|
|
10
47
|
if (keyClaimed(event)) {
|
|
@@ -14,9 +51,7 @@ export class KeyboardNavigationController {
|
|
|
14
51
|
if (items.length === 0) {
|
|
15
52
|
return;
|
|
16
53
|
}
|
|
17
|
-
const current = this.
|
|
18
|
-
? items.findIndex((item) => item.contains(event.target))
|
|
19
|
-
: items.findIndex((item) => item.selected);
|
|
54
|
+
const current = this.current(items, event);
|
|
20
55
|
if (this.origin === 'focus' && current === -1) {
|
|
21
56
|
return;
|
|
22
57
|
}
|
|
@@ -41,20 +76,55 @@ export class KeyboardNavigationController {
|
|
|
41
76
|
next = last;
|
|
42
77
|
}
|
|
43
78
|
event.preventDefault();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (this.selects()) {
|
|
47
|
-
target?.select();
|
|
48
|
-
}
|
|
79
|
+
this.typeahead.reset();
|
|
80
|
+
this.arrive(items[next]);
|
|
49
81
|
};
|
|
50
82
|
this.host = host;
|
|
51
83
|
this.items = items;
|
|
52
84
|
this.origin = origin;
|
|
53
85
|
this.reserved = reserved;
|
|
54
86
|
this.selects = selects;
|
|
87
|
+
this.words = typeahead;
|
|
55
88
|
this.wrap = wrap;
|
|
56
89
|
host.addController(this);
|
|
57
90
|
}
|
|
91
|
+
arrive(target) {
|
|
92
|
+
target?.focusItem();
|
|
93
|
+
if (this.selects()) {
|
|
94
|
+
target?.select();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
current(items, event) {
|
|
98
|
+
const path = event.composedPath().filter((node) => node instanceof Node);
|
|
99
|
+
const focused = items.findIndex((item) => path.some((node) => item.contains(node)));
|
|
100
|
+
if (this.origin === 'focus') {
|
|
101
|
+
return focused;
|
|
102
|
+
}
|
|
103
|
+
const selected = items.findIndex((item) => item.selected);
|
|
104
|
+
return selected < 0 ? focused : selected;
|
|
105
|
+
}
|
|
106
|
+
handleTypeahead(event) {
|
|
107
|
+
const words = this.words;
|
|
108
|
+
if (!words || !typesCharacter(event) || keyClaimed(event)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (event.key === ' ' && !this.typeahead.typing()) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const items = this.items().filter((item) => !item.disabled);
|
|
115
|
+
if (items.length === 0) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const from = this.current(items, event);
|
|
119
|
+
if (this.origin === 'focus' && from === -1) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const at = this.typeahead.match(event.key, items.map(words), from);
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
if (at >= 0 && at !== from) {
|
|
125
|
+
this.arrive(items[at]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
58
128
|
hostConnected() {
|
|
59
129
|
this.host.addEventListener('keydown', this.handleKeydown);
|
|
60
130
|
}
|
|
@@ -122,6 +122,7 @@ export declare class PopoverController implements ReactiveController {
|
|
|
122
122
|
private readonly dismisser;
|
|
123
123
|
/** Whether the surface is currently shown, so the calls can be idempotent. */
|
|
124
124
|
private shown;
|
|
125
|
+
private readonly host;
|
|
125
126
|
constructor(host: ReactiveControllerHost & HTMLElement, options: PopoverOptions);
|
|
126
127
|
/** Whether the surface is showing. */
|
|
127
128
|
get open(): boolean;
|
|
@@ -152,4 +153,14 @@ export declare class PopoverController implements ReactiveController {
|
|
|
152
153
|
*/
|
|
153
154
|
reposition(): void;
|
|
154
155
|
hostDisconnected(): void;
|
|
156
|
+
/**
|
|
157
|
+
* Asks a reconnected host that is still open to update, so the panel is shown
|
|
158
|
+
* again.
|
|
159
|
+
*
|
|
160
|
+
* The flag above lets it show, and nothing asked it to: a host moved in the
|
|
161
|
+
* DOM while open kept `open` and `aria-expanded="true"` with the panel
|
|
162
|
+
* hidden until something else updated it, and Escape did not close it,
|
|
163
|
+
* because the dismissal is started by showing.
|
|
164
|
+
*/
|
|
165
|
+
hostConnected(): void;
|
|
155
166
|
}
|
|
@@ -4,6 +4,7 @@ export class PopoverController {
|
|
|
4
4
|
constructor(host, options) {
|
|
5
5
|
this.shown = false;
|
|
6
6
|
this.options = options;
|
|
7
|
+
this.host = host;
|
|
7
8
|
this.floating = new FloatingController(host, {
|
|
8
9
|
anchor: options.anchor,
|
|
9
10
|
...(options.arrow === undefined ? {} : { arrow: options.arrow }),
|
|
@@ -85,4 +86,9 @@ export class PopoverController {
|
|
|
85
86
|
hostDisconnected() {
|
|
86
87
|
this.shown = false;
|
|
87
88
|
}
|
|
89
|
+
hostConnected() {
|
|
90
|
+
if (this.options.open?.()) {
|
|
91
|
+
this.host.requestUpdate();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
88
94
|
}
|