@fusion.dev/helix-core 0.142.1 → 0.144.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 +10436 -3804
- package/dist/controllers/aria-forward-controller/aria-forward-controller.d.ts +65 -0
- package/dist/controllers/aria-forward-controller/aria-forward-controller.js +77 -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 +99 -0
- package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +107 -2
- package/dist/controllers/chart-pointer-controller/reading-step.d.ts +57 -0
- package/dist/controllers/chart-pointer-controller/reading-step.js +45 -0
- package/dist/controllers/copy-controller/copy-controller.d.ts +11 -6
- package/dist/controllers/copy-controller/copy-controller.js +16 -9
- package/dist/controllers/delay-controller/delay-controller.d.ts +50 -0
- package/dist/controllers/delay-controller/delay-controller.js +54 -0
- package/dist/controllers/dismiss-controller/dismiss-controller.js +40 -3
- package/dist/controllers/focus-scope-controller/focus-scope-controller.d.ts +56 -0
- package/dist/controllers/focus-scope-controller/focus-scope-controller.js +71 -0
- 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/open-phase-controller/open-phase-controller.d.ts +49 -0
- package/dist/controllers/open-phase-controller/open-phase-controller.js +41 -0
- 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 +38 -0
- package/dist/controllers/source-controller/source-controller.js +71 -2
- package/dist/controllers/status-controller/status-controller.d.ts +47 -0
- package/dist/controllers/status-controller/status-controller.js +34 -0
- package/dist/controllers/swipe-controller/swipe-controller.d.ts +61 -0
- package/dist/controllers/swipe-controller/swipe-controller.js +106 -0
- 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 +137 -4
- package/dist/events/events.js +96 -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 +30 -9
- package/dist/index.js +29 -8
- package/dist/mixins/mixin-chart-source/mixin-chart-source.d.ts +15 -2
- package/dist/mixins/mixin-chart-source/mixin-chart-source.js +9 -1
- 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-checked/mixin-controller-checked.d.ts +10 -0
- package/dist/mixins/mixin-controller-checked/mixin-controller-checked.js +9 -0
- package/dist/mixins/mixin-controller-open/mixin-controller-open.js +19 -3
- package/dist/mixins/mixin-dismissible/mixin-dismissible.d.ts +2 -0
- package/dist/mixins/mixin-dismissible/mixin-dismissible.js +43 -0
- package/dist/mixins/mixin-field/mixin-field.d.ts +2 -0
- package/dist/mixins/mixin-field/mixin-field.js +46 -8
- package/dist/mixins/mixin-picker-editing/mixin-picker-editing.d.ts +23 -8
- package/dist/mixins/mixin-picker-editing/mixin-picker-editing.js +25 -1
- package/dist/mixins/mixin-picker-field/mixin-picker-field.d.ts +29 -5
- package/dist/mixins/mixin-picker-field/mixin-picker-field.js +94 -1
- package/dist/mixins/mixin-segmented-field/mixin-segmented-field.d.ts +67 -0
- package/dist/mixins/mixin-segmented-field/mixin-segmented-field.js +243 -0
- package/dist/utils/arrow/arrow.d.ts +15 -0
- package/dist/utils/arrow/arrow.js +2 -0
- package/dist/utils/audit-name/audit-name.d.ts +28 -0
- package/dist/utils/audit-name/audit-name.js +95 -0
- package/dist/utils/calendar/calendar.d.ts +117 -0
- package/dist/utils/calendar/calendar.js +96 -0
- package/dist/utils/chord/chord.d.ts +43 -0
- package/dist/utils/chord/chord.js +53 -0
- package/dist/utils/clock/clock.d.ts +87 -0
- package/dist/utils/clock/clock.js +68 -0
- package/dist/utils/color-name/color-name.d.ts +28 -0
- package/dist/utils/color-name/color-name.js +150 -0
- package/dist/utils/count-label/count-label.d.ts +13 -0
- package/dist/utils/count-label/count-label.js +5 -0
- package/dist/utils/css-color/css-color.d.ts +12 -0
- package/dist/utils/css-color/css-color.js +2 -2
- package/dist/utils/date-segments/date-segments.d.ts +122 -0
- package/dist/utils/date-segments/date-segments.js +206 -0
- package/dist/utils/draw-dismiss/draw-dismiss.d.ts +30 -0
- package/dist/utils/draw-dismiss/draw-dismiss.js +16 -0
- package/dist/utils/draw-tooltip/draw-tooltip.d.ts +15 -18
- package/dist/utils/draw-tooltip/draw-tooltip.js +16 -5
- package/dist/utils/draw-tooltip/spoken-reading.d.ts +10 -0
- package/dist/utils/draw-tooltip/spoken-reading.js +10 -0
- package/dist/utils/follow-link/follow-link.d.ts +46 -0
- package/dist/utils/follow-link/follow-link.js +37 -0
- package/dist/utils/heading-in/heading-in.d.ts +19 -0
- package/dist/utils/heading-in/heading-in.js +14 -0
- package/dist/utils/number-format/number-format.d.ts +23 -0
- package/dist/utils/number-format/number-format.js +55 -0
- package/dist/utils/platform/platform.d.ts +23 -0
- package/dist/utils/platform/platform.js +11 -0
- package/dist/utils/read-color/read-color.d.ts +14 -0
- package/dist/utils/read-color/read-color.js +13 -0
- package/dist/utils/read-series/read-series.d.ts +34 -0
- package/dist/utils/read-series/read-series.js +32 -4
- package/dist/utils/reflect-elements/reflect-elements.d.ts +37 -0
- package/dist/utils/reflect-elements/reflect-elements.js +40 -0
- package/dist/utils/relative-time/relative-time.d.ts +13 -1
- package/dist/utils/relative-time/relative-time.js +7 -4
- package/dist/utils/report-runtime/report-runtime.d.ts +12 -0
- package/dist/utils/roving-stop/roving-stop.d.ts +20 -0
- package/dist/utils/roving-stop/roving-stop.js +3 -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/swipe/swipe.d.ts +63 -0
- package/dist/utils/swipe/swipe.js +48 -0
- package/dist/utils/tab-stops/tab-stops.d.ts +16 -0
- package/dist/utils/tab-stops/tab-stops.js +22 -0
- package/dist/utils/value-as/value-as.d.ts +20 -0
- package/dist/utils/value-as/value-as.js +16 -0
- package/dist/utils/write-time/write-time.d.ts +11 -1
- package/dist/utils/write-time/write-time.js +7 -7
- package/dist/utils/written-list/written-list.d.ts +29 -2
- package/dist/utils/written-list/written-list.js +11 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const vertical = (key) => (key === 'ArrowDown' ? 1 : key === 'ArrowUp' ? -1 : 0);
|
|
2
|
+
const inline = (key, rtl) => key === 'ArrowRight' ? (rtl ? -1 : 1) : key === 'ArrowLeft' ? (rtl ? 1 : -1) : 0;
|
|
3
|
+
export const alongReadings = (key, layout) => {
|
|
4
|
+
const single = layout.grid > 1 || layout.series < 2;
|
|
5
|
+
if (layout.axis === 'block') {
|
|
6
|
+
const down = vertical(key);
|
|
7
|
+
return down !== 0 || !single ? down : inline(key, layout.rtl);
|
|
8
|
+
}
|
|
9
|
+
const across = inline(key, layout.rtl);
|
|
10
|
+
if (across !== 0) {
|
|
11
|
+
return across * Math.max(1, layout.grid);
|
|
12
|
+
}
|
|
13
|
+
return single ? vertical(key) : 0;
|
|
14
|
+
};
|
|
15
|
+
export const amongSeries = (key, layout) => {
|
|
16
|
+
if (layout.grid > 1 || layout.series < 2) {
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
return layout.axis === 'block' ? inline(key, layout.rtl) : vertical(key);
|
|
20
|
+
};
|
|
21
|
+
export const stepReading = (key, from, layout) => {
|
|
22
|
+
const last = layout.count - 1;
|
|
23
|
+
if (last < 0) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
if (key === 'Home') {
|
|
27
|
+
return { at: 0, series: from.series };
|
|
28
|
+
}
|
|
29
|
+
if (key === 'End') {
|
|
30
|
+
return { at: last, series: from.series };
|
|
31
|
+
}
|
|
32
|
+
const along = alongReadings(key, layout);
|
|
33
|
+
if (along !== 0) {
|
|
34
|
+
const at = from.at < 0 ? (along > 0 ? 0 : last) : Math.min(Math.max(from.at + along, 0), last);
|
|
35
|
+
return { at, series: from.series };
|
|
36
|
+
}
|
|
37
|
+
const among = amongSeries(key, layout);
|
|
38
|
+
if (among !== 0) {
|
|
39
|
+
return {
|
|
40
|
+
at: Math.max(from.at, 0),
|
|
41
|
+
series: Math.min(Math.max(from.series + among, -1), layout.series - 1),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
};
|
|
@@ -24,10 +24,10 @@ export interface CopyOptions {
|
|
|
24
24
|
* in more places than it looks and a control that does nothing is
|
|
25
25
|
* indistinguishable from a broken one. See `write` above.
|
|
26
26
|
*
|
|
27
|
-
* **Where neither route is allowed, the
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
27
|
+
* **Where neither route is allowed, the copy has failed**, and `failed` holds
|
|
28
|
+
* for the moment a confirmation would. A component decides whether to say so:
|
|
29
|
+
* `CopyButton` names itself with its `error-label`, where Color's chip does
|
|
30
|
+
* not flash at all.
|
|
31
31
|
*
|
|
32
32
|
* **The event is announced after the write, not before it.** It is not
|
|
33
33
|
* cancelable for the same reason: by the time anything could be said, the text
|
|
@@ -42,15 +42,20 @@ export declare class CopyController implements ReactiveController {
|
|
|
42
42
|
private readonly options;
|
|
43
43
|
/** Whether the last write succeeded and is still being confirmed. */
|
|
44
44
|
copied: boolean;
|
|
45
|
+
/** Whether the last copy put nothing on the clipboard, and is still being said. */
|
|
46
|
+
failed: boolean;
|
|
45
47
|
private timer;
|
|
46
48
|
constructor(host: ReactiveControllerHost & EventTarget, options?: CopyOptions);
|
|
47
49
|
/**
|
|
48
50
|
* Writes to the clipboard, and confirms it if the platform allowed it.
|
|
51
|
+
* Resolves to whether the text is on the clipboard.
|
|
49
52
|
*
|
|
50
53
|
* Empty text is not copied. An empty clipboard is not a thing a reader ever
|
|
51
|
-
* asks for, and confirming one is worse than doing nothing.
|
|
54
|
+
* asks for, and confirming one is worse than doing nothing. Empty text and a
|
|
55
|
+
* refused write each hold `failed` for the same moment a copy holds
|
|
56
|
+
* `copied`, for a component that says so.
|
|
52
57
|
*/
|
|
53
|
-
copy(text: string): Promise<
|
|
58
|
+
copy(text: string): Promise<boolean>;
|
|
54
59
|
hostDisconnected(): void;
|
|
55
60
|
private reset;
|
|
56
61
|
}
|
|
@@ -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 {
|
|
@@ -29,30 +34,32 @@ export class CopyController {
|
|
|
29
34
|
this.host = host;
|
|
30
35
|
this.options = options;
|
|
31
36
|
this.copied = false;
|
|
37
|
+
this.failed = false;
|
|
32
38
|
host.addController(this);
|
|
33
39
|
}
|
|
34
40
|
async copy(text) {
|
|
35
|
-
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
if (!(await write(text))) {
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
+
const wrote = text !== '' && (await write(text));
|
|
41
42
|
if (this.host instanceof HTMLElement && !this.host.isConnected) {
|
|
42
|
-
return;
|
|
43
|
+
return wrote;
|
|
43
44
|
}
|
|
44
|
-
this.copied =
|
|
45
|
+
this.copied = wrote;
|
|
46
|
+
this.failed = !wrote;
|
|
45
47
|
this.host.requestUpdate();
|
|
46
|
-
|
|
48
|
+
if (wrote) {
|
|
49
|
+
this.host.dispatchEvent(new HelixCopyEvent({ value: text }));
|
|
50
|
+
}
|
|
47
51
|
this.reset();
|
|
48
52
|
this.timer = setTimeout(() => {
|
|
49
53
|
this.copied = false;
|
|
54
|
+
this.failed = false;
|
|
50
55
|
this.host.requestUpdate();
|
|
51
56
|
}, this.options.after ?? 1200);
|
|
57
|
+
return wrote;
|
|
52
58
|
}
|
|
53
59
|
hostDisconnected() {
|
|
54
60
|
this.reset();
|
|
55
61
|
this.copied = false;
|
|
62
|
+
this.failed = false;
|
|
56
63
|
}
|
|
57
64
|
reset() {
|
|
58
65
|
if (this.timer !== undefined) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
2
|
+
/** What a `DelayController` is for. */
|
|
3
|
+
export interface DelayOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Whether the host renders `elapsed`, and so updates when it moves.
|
|
6
|
+
* Defaults to `true`; a wait that only runs `done`, a debounce, leaves its
|
|
7
|
+
* host alone.
|
|
8
|
+
*/
|
|
9
|
+
renders?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A wait an element starts and restarts: whether it has run out, and what runs
|
|
13
|
+
* when it does.
|
|
14
|
+
*
|
|
15
|
+
* Avatar's `fallback-delay`, Image's `placeholder-delay` and Loader's `delay`
|
|
16
|
+
* each hold a region back until a wait is over, and Listbox's `filter-delay`
|
|
17
|
+
* holds a filter back until typing pauses. Each kept a timer, a flag and the
|
|
18
|
+
* clean-up by hand; this keeps the three together, and clears the timer when
|
|
19
|
+
* the element leaves the page.
|
|
20
|
+
*
|
|
21
|
+
* `elapsed` is what a template reads, and the host updates when it moves,
|
|
22
|
+
* unless `renders` is false.
|
|
23
|
+
* Leaving the page stops the wait without forgetting that it ran out, so a
|
|
24
|
+
* loader moved to another place in the page does not wait again.
|
|
25
|
+
*/
|
|
26
|
+
export declare class DelayController implements ReactiveController {
|
|
27
|
+
private readonly host;
|
|
28
|
+
private readonly options;
|
|
29
|
+
/** Whether the last wait started has run out. */
|
|
30
|
+
elapsed: boolean;
|
|
31
|
+
private timer;
|
|
32
|
+
private done;
|
|
33
|
+
constructor(host: ReactiveControllerHost, options?: DelayOptions);
|
|
34
|
+
/** Whether a wait is running. */
|
|
35
|
+
get pending(): boolean;
|
|
36
|
+
hostConnected(): void;
|
|
37
|
+
hostDisconnected(): void;
|
|
38
|
+
/**
|
|
39
|
+
* Starts the wait over: `elapsed` goes back to false, and after `ms`
|
|
40
|
+
* milliseconds it turns true and `done` runs. No `ms` starts no wait, which
|
|
41
|
+
* leaves `elapsed` false.
|
|
42
|
+
*/
|
|
43
|
+
start(ms?: number, done?: () => void): void;
|
|
44
|
+
/** Stops a running wait, leaving `elapsed` as it is. */
|
|
45
|
+
cancel(): void;
|
|
46
|
+
/** Ends a running wait now, as though it had run out. Nothing happens where none is running. */
|
|
47
|
+
flush(): void;
|
|
48
|
+
private finish;
|
|
49
|
+
private mark;
|
|
50
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export class DelayController {
|
|
2
|
+
constructor(host, options = {}) {
|
|
3
|
+
this.host = host;
|
|
4
|
+
this.options = options;
|
|
5
|
+
this.elapsed = false;
|
|
6
|
+
this.timer = undefined;
|
|
7
|
+
this.done = undefined;
|
|
8
|
+
host.addController(this);
|
|
9
|
+
}
|
|
10
|
+
get pending() {
|
|
11
|
+
return this.timer !== undefined;
|
|
12
|
+
}
|
|
13
|
+
hostConnected() { }
|
|
14
|
+
hostDisconnected() {
|
|
15
|
+
this.cancel();
|
|
16
|
+
}
|
|
17
|
+
start(ms, done) {
|
|
18
|
+
this.cancel();
|
|
19
|
+
this.mark(false);
|
|
20
|
+
if (ms === undefined) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
this.done = done;
|
|
24
|
+
this.timer = setTimeout(() => this.finish(), ms);
|
|
25
|
+
}
|
|
26
|
+
cancel() {
|
|
27
|
+
clearTimeout(this.timer);
|
|
28
|
+
this.timer = undefined;
|
|
29
|
+
this.done = undefined;
|
|
30
|
+
}
|
|
31
|
+
flush() {
|
|
32
|
+
if (this.timer === undefined) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
clearTimeout(this.timer);
|
|
36
|
+
this.finish();
|
|
37
|
+
}
|
|
38
|
+
finish() {
|
|
39
|
+
const done = this.done;
|
|
40
|
+
this.timer = undefined;
|
|
41
|
+
this.done = undefined;
|
|
42
|
+
this.mark(true);
|
|
43
|
+
done?.();
|
|
44
|
+
}
|
|
45
|
+
mark(elapsed) {
|
|
46
|
+
if (elapsed === this.elapsed) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
this.elapsed = elapsed;
|
|
50
|
+
if (this.options.renders !== false) {
|
|
51
|
+
this.host.requestUpdate();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -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 = () => {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
2
|
+
export interface FocusScopeOptions {
|
|
3
|
+
/**
|
|
4
|
+
* The elements whose controls are inside the scope, in order. For a surface
|
|
5
|
+
* that holds a slot, these are the elements assigned to it: a query on the
|
|
6
|
+
* surface stops at the slot and never reaches them.
|
|
7
|
+
*/
|
|
8
|
+
contents: () => readonly Element[];
|
|
9
|
+
/** Whether `Tab` is kept inside the scope, wrapping at either end. */
|
|
10
|
+
contain?: () => boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Where focus goes on the way in: the first control inside, or the surface
|
|
13
|
+
* itself. The first control unless said, falling back to the surface where
|
|
14
|
+
* there is none.
|
|
15
|
+
*/
|
|
16
|
+
start?: 'first' | 'surface';
|
|
17
|
+
/** The box focus is kept in, which takes focus itself where nothing inside can. */
|
|
18
|
+
surface: () => HTMLElement | null | undefined;
|
|
19
|
+
}
|
|
20
|
+
/** The controls among some elements and inside them, in order. */
|
|
21
|
+
export declare const controlsIn: (elements: readonly Element[]) => HTMLElement[];
|
|
22
|
+
/**
|
|
23
|
+
* Moves focus into a surface and back out of it, and can keep `Tab` inside
|
|
24
|
+
* while it is open.
|
|
25
|
+
*
|
|
26
|
+
* The part of an overlay that is about focus and nothing else: a popover that
|
|
27
|
+
* moves focus in as it opens, a modal popover that keeps it there, a toast
|
|
28
|
+
* region a hotkey takes focus to. Each of them decides *when*; this does the
|
|
29
|
+
* moving, and remembers where focus came from so `leave` can give it back.
|
|
30
|
+
*
|
|
31
|
+
* **Back only from inside.** A surface closed by a press somewhere else has not
|
|
32
|
+
* taken focus with it, and pulling focus back would move a reader who had
|
|
33
|
+
* already gone where they meant to.
|
|
34
|
+
*/
|
|
35
|
+
export declare class FocusScopeController implements ReactiveController {
|
|
36
|
+
private readonly host;
|
|
37
|
+
private readonly options;
|
|
38
|
+
/** Where focus was when it was moved in. */
|
|
39
|
+
private origin;
|
|
40
|
+
constructor(host: ReactiveControllerHost & HTMLElement, options: FocusScopeOptions);
|
|
41
|
+
hostConnected(): void;
|
|
42
|
+
hostDisconnected(): void;
|
|
43
|
+
/** Whether focus is in the surface or anything assigned into it. */
|
|
44
|
+
get holdsFocus(): boolean;
|
|
45
|
+
/** The controls inside the scope, in order. */
|
|
46
|
+
get controls(): HTMLElement[];
|
|
47
|
+
/** Moves focus into the scope, and remembers where it was. */
|
|
48
|
+
enter(): void;
|
|
49
|
+
/**
|
|
50
|
+
* Gives focus back to where it was before `enter`, if focus is still inside
|
|
51
|
+
* the scope. Returns whether it did.
|
|
52
|
+
*/
|
|
53
|
+
leave(): boolean;
|
|
54
|
+
/** `Tab` at either end wraps to the other, while the scope is containing. */
|
|
55
|
+
private readonly handleKeydown;
|
|
56
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { deepActiveElement, deepContains, focusableElements, } from '../../utils/focusable-elements/focusable-elements.js';
|
|
2
|
+
const takesFocus = (element) => element instanceof HTMLElement &&
|
|
3
|
+
(element.matches('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [contenteditable]:not([contenteditable="false"]), [tabindex]:not([tabindex="-1"])') ||
|
|
4
|
+
element.shadowRoot?.delegatesFocus === true);
|
|
5
|
+
export const controlsIn = (elements) => elements.flatMap((one) => [
|
|
6
|
+
...(takesFocus(one) ? [one] : []),
|
|
7
|
+
...(one instanceof HTMLElement ? focusableElements(one) : []),
|
|
8
|
+
]);
|
|
9
|
+
export class FocusScopeController {
|
|
10
|
+
constructor(host, options) {
|
|
11
|
+
this.handleKeydown = (event) => {
|
|
12
|
+
if (event.key !== 'Tab' || event.defaultPrevented || !this.options.contain?.()) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const controls = this.controls;
|
|
16
|
+
const active = deepActiveElement();
|
|
17
|
+
const first = controls[0];
|
|
18
|
+
const last = controls[controls.length - 1];
|
|
19
|
+
if (!first || !last) {
|
|
20
|
+
event.preventDefault();
|
|
21
|
+
this.options.surface()?.focus({ preventScroll: true });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const at = (control) => active !== null && deepContains(control, active);
|
|
25
|
+
if (event.shiftKey && (at(first) || active === this.options.surface())) {
|
|
26
|
+
event.preventDefault();
|
|
27
|
+
last.focus();
|
|
28
|
+
}
|
|
29
|
+
else if (!event.shiftKey && at(last)) {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
first.focus();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
this.host = host;
|
|
35
|
+
this.options = options;
|
|
36
|
+
host.addController(this);
|
|
37
|
+
}
|
|
38
|
+
hostConnected() {
|
|
39
|
+
this.host.addEventListener('keydown', this.handleKeydown);
|
|
40
|
+
}
|
|
41
|
+
hostDisconnected() {
|
|
42
|
+
this.host.removeEventListener('keydown', this.handleKeydown);
|
|
43
|
+
}
|
|
44
|
+
get holdsFocus() {
|
|
45
|
+
const active = deepActiveElement();
|
|
46
|
+
const surface = this.options.surface();
|
|
47
|
+
return (active !== null &&
|
|
48
|
+
((surface !== null && surface !== undefined && deepContains(surface, active)) ||
|
|
49
|
+
this.options.contents().some((one) => deepContains(one, active))));
|
|
50
|
+
}
|
|
51
|
+
get controls() {
|
|
52
|
+
return controlsIn(this.options.contents());
|
|
53
|
+
}
|
|
54
|
+
enter() {
|
|
55
|
+
const active = deepActiveElement();
|
|
56
|
+
if (active instanceof HTMLElement && !this.holdsFocus) {
|
|
57
|
+
this.origin = active;
|
|
58
|
+
}
|
|
59
|
+
const first = this.options.start === 'surface' ? undefined : this.controls[0];
|
|
60
|
+
(first ?? this.options.surface())?.focus({ preventScroll: true });
|
|
61
|
+
}
|
|
62
|
+
leave() {
|
|
63
|
+
const origin = this.origin;
|
|
64
|
+
this.origin = undefined;
|
|
65
|
+
if (!origin?.isConnected || !this.holdsFocus) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
origin.focus({ preventScroll: true });
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -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
|
}
|