@fusion.dev/helix-core 0.0.0-dev
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/LICENSE +21 -0
- package/custom-elements.json +8005 -0
- package/dist/controllers/aria-forward-controller/aria-forward-controller.d.ts +67 -0
- package/dist/controllers/aria-forward-controller/aria-forward-controller.js +146 -0
- package/dist/controllers/chart-pointer-controller/chart-pointer-controller.d.ts +79 -0
- package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +140 -0
- package/dist/controllers/command-controller/command-controller.d.ts +83 -0
- package/dist/controllers/command-controller/command-controller.js +93 -0
- package/dist/controllers/copy-controller/copy-controller.d.ts +58 -0
- package/dist/controllers/copy-controller/copy-controller.js +128 -0
- package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.d.ts +76 -0
- package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.js +67 -0
- package/dist/controllers/dismiss-controller/dismiss-controller.d.ts +58 -0
- package/dist/controllers/dismiss-controller/dismiss-controller.js +165 -0
- package/dist/controllers/drilldown-controller/drilldown-controller.d.ts +127 -0
- package/dist/controllers/drilldown-controller/drilldown-controller.js +171 -0
- package/dist/controllers/floating-controller/floating-controller.d.ts +123 -0
- package/dist/controllers/floating-controller/floating-controller.js +227 -0
- package/dist/controllers/focus-trap-controller/focus-trap-controller.d.ts +26 -0
- package/dist/controllers/focus-trap-controller/focus-trap-controller.js +93 -0
- package/dist/controllers/indicator-controller/indicator-controller.d.ts +97 -0
- package/dist/controllers/indicator-controller/indicator-controller.js +164 -0
- package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.d.ts +113 -0
- package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.js +117 -0
- package/dist/controllers/popover-controller/popover-controller.d.ts +132 -0
- package/dist/controllers/popover-controller/popover-controller.js +150 -0
- package/dist/controllers/source-controller/source-controller.d.ts +55 -0
- package/dist/controllers/source-controller/source-controller.js +94 -0
- package/dist/controllers/token-color-controller/token-color-controller.d.ts +63 -0
- package/dist/controllers/token-color-controller/token-color-controller.js +105 -0
- package/dist/controllers/virtualize-controller/virtualize-controller.d.ts +127 -0
- package/dist/controllers/virtualize-controller/virtualize-controller.js +216 -0
- package/dist/events/element-events.d.ts +4 -0
- package/dist/events/element-events.js +34 -0
- package/dist/events/events.d.ts +743 -0
- package/dist/events/events.js +799 -0
- package/dist/helix-element/helix-element.d.ts +201 -0
- package/dist/helix-element/helix-element.js +695 -0
- package/dist/helix-form-element/helix-form-element.d.ts +152 -0
- package/dist/helix-form-element/helix-form-element.js +194 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +54 -0
- package/dist/mixins/mixin-chart-source/mixin-chart-source.d.ts +137 -0
- package/dist/mixins/mixin-chart-source/mixin-chart-source.js +140 -0
- package/dist/mixins/mixin-collection/index.d.ts +1 -0
- package/dist/mixins/mixin-collection/index.js +1 -0
- package/dist/mixins/mixin-collection/mixin-collection.d.ts +58 -0
- package/dist/mixins/mixin-collection/mixin-collection.js +154 -0
- package/dist/mixins/mixin-controller-active/mixin-controller-active.d.ts +53 -0
- package/dist/mixins/mixin-controller-active/mixin-controller-active.js +155 -0
- package/dist/mixins/mixin-controller-checked/mixin-controller-checked.d.ts +54 -0
- package/dist/mixins/mixin-controller-checked/mixin-controller-checked.js +157 -0
- package/dist/mixins/mixin-controller-open/mixin-controller-open.d.ts +49 -0
- package/dist/mixins/mixin-controller-open/mixin-controller-open.js +176 -0
- package/dist/mixins/mixin-controller-value/mixin-controller-value.d.ts +73 -0
- package/dist/mixins/mixin-controller-value/mixin-controller-value.js +173 -0
- package/dist/mixins/mixin-dismissible/mixin-dismissible.d.ts +41 -0
- package/dist/mixins/mixin-dismissible/mixin-dismissible.js +79 -0
- package/dist/mixins/mixin-field/mixin-field.d.ts +145 -0
- package/dist/mixins/mixin-field/mixin-field.js +423 -0
- package/dist/utils/anchor-element/anchor-element.d.ts +23 -0
- package/dist/utils/anchor-element/anchor-element.js +39 -0
- package/dist/utils/capture-pointer/capture-pointer.d.ts +22 -0
- package/dist/utils/capture-pointer/capture-pointer.js +30 -0
- package/dist/utils/closest-instance/closest-instance.d.ts +25 -0
- package/dist/utils/closest-instance/closest-instance.js +34 -0
- package/dist/utils/create-sheet/create-sheet.d.ts +16 -0
- package/dist/utils/create-sheet/create-sheet.js +16 -0
- package/dist/utils/custom-element/custom-element.d.ts +26 -0
- package/dist/utils/custom-element/custom-element.js +114 -0
- package/dist/utils/draw-icon/draw-icon.d.ts +38 -0
- package/dist/utils/draw-icon/draw-icon.js +39 -0
- package/dist/utils/draw-tooltip/draw-tooltip.d.ts +56 -0
- package/dist/utils/draw-tooltip/draw-tooltip.js +89 -0
- package/dist/utils/elapsed-time/elapsed-time.d.ts +10 -0
- package/dist/utils/elapsed-time/elapsed-time.js +117 -0
- package/dist/utils/focusable-elements/focusable-elements.d.ts +40 -0
- package/dist/utils/focusable-elements/focusable-elements.js +88 -0
- package/dist/utils/format-numbers/format-numbers.d.ts +1 -0
- package/dist/utils/format-numbers/format-numbers.js +29 -0
- package/dist/utils/host-aria/host-aria.d.ts +38 -0
- package/dist/utils/host-aria/host-aria.js +41 -0
- package/dist/utils/inherit-disabled/inherit-disabled.d.ts +33 -0
- package/dist/utils/inherit-disabled/inherit-disabled.js +55 -0
- package/dist/utils/line-path/line-path.d.ts +23 -0
- package/dist/utils/line-path/line-path.js +107 -0
- package/dist/utils/on-unless-false/on-unless-false.d.ts +28 -0
- package/dist/utils/on-unless-false/on-unless-false.js +32 -0
- package/dist/utils/read-series/read-series.d.ts +78 -0
- package/dist/utils/read-series/read-series.js +187 -0
- package/dist/utils/relative-time/relative-time.d.ts +24 -0
- package/dist/utils/relative-time/relative-time.js +84 -0
- package/dist/utils/request-change/request-change.d.ts +48 -0
- package/dist/utils/request-change/request-change.js +7 -0
- package/dist/utils/resolve-active-item/resolve-active-item.d.ts +28 -0
- package/dist/utils/resolve-active-item/resolve-active-item.js +21 -0
- package/dist/utils/restore-value/restore-value.d.ts +32 -0
- package/dist/utils/restore-value/restore-value.js +84 -0
- package/dist/utils/scale/scale.d.ts +23 -0
- package/dist/utils/scale/scale.js +32 -0
- package/dist/utils/scale/ticks.d.ts +27 -0
- package/dist/utils/scale/ticks.js +52 -0
- package/dist/utils/scroll-item-into-view/scroll-item-into-view.d.ts +10 -0
- package/dist/utils/scroll-item-into-view/scroll-item-into-view.js +98 -0
- package/dist/utils/slot-filled/slot-filled.d.ts +26 -0
- package/dist/utils/slot-filled/slot-filled.js +26 -0
- package/dist/utils/tell-disclosure/tell-disclosure.d.ts +51 -0
- package/dist/utils/tell-disclosure/tell-disclosure.js +76 -0
- package/dist/utils/ticker/ticker.d.ts +8 -0
- package/dist/utils/ticker/ticker.js +55 -0
- package/dist/utils/tone-marks/tone-marks.d.ts +52 -0
- package/dist/utils/tone-marks/tone-marks.js +36 -0
- package/dist/utils/value-as/value-as.d.ts +64 -0
- package/dist/utils/value-as/value-as.js +105 -0
- package/package.json +58 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
2
|
+
/**
|
|
3
|
+
* What an indicator needs to know, asked for rather than assumed.
|
|
4
|
+
*/
|
|
5
|
+
export interface IndicatorOptions {
|
|
6
|
+
/** The element that is currently marked, or nothing if none is. */
|
|
7
|
+
active: () => HTMLElement | null | undefined;
|
|
8
|
+
/** The traveling mark. Positioned against the surface. */
|
|
9
|
+
indicator: () => HTMLElement | null | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* The box the mark is positioned against, which is its containing block.
|
|
12
|
+
*
|
|
13
|
+
* Not the host. An absolutely positioned element is placed against its
|
|
14
|
+
* containing block's *padding* box, so this has to be whichever element
|
|
15
|
+
* carries `position: relative`, and that is usually a part rather than the
|
|
16
|
+
* host itself.
|
|
17
|
+
*/
|
|
18
|
+
surface: () => HTMLElement | null | undefined;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Measures the marked child and writes the geometry onto a traveling mark.
|
|
22
|
+
*
|
|
23
|
+
* **Four components had written this and the copies had diverged.** `Navigation`,
|
|
24
|
+
* `Tabs`, `Toggle.Group` and `Outline` each measured an active child and wrote
|
|
25
|
+
* a position onto an indicator, and two of them had bugs the others did not.
|
|
26
|
+
* `Outline`'s own comment counted the repetition rather than resolving it, and
|
|
27
|
+
* the audit found the same shape independently in all four.
|
|
28
|
+
*
|
|
29
|
+
* What the copies disagreed about, and what this settles:
|
|
30
|
+
*
|
|
31
|
+
* **The inline axis is signed.** In a right-to-left document the distance is
|
|
32
|
+
* measured from the surface's right edge and applied as a negative translation,
|
|
33
|
+
* because the mark travels from the inline start and the inline start is on the
|
|
34
|
+
* right. `Navigation` measured from the physical left and applied it from the
|
|
35
|
+
* inline-start edge with an unsigned offset, so its marker went the wrong way
|
|
36
|
+
* in RTL. That is the bug this extraction exists to stop repeating.
|
|
37
|
+
*
|
|
38
|
+
* **The border has to come off both axes.** `getBoundingClientRect` gives the
|
|
39
|
+
* border box and the containing block is the padding box, so a surface with a
|
|
40
|
+
* border of its own puts the mark one border-width out. `Tabs` and
|
|
41
|
+
* `Toggle.Group` each did this arithmetic separately, `Outline` did the block
|
|
42
|
+
* half with `clientTop`, and `Navigation` did neither. `clientLeft` and `clientTop`
|
|
43
|
+
* are the border widths, already resolved, which is why they are used here
|
|
44
|
+
* rather than parsing `borderLeftWidth`.
|
|
45
|
+
*
|
|
46
|
+
* **Nothing is written until there is something to measure.** A zero-width box
|
|
47
|
+
* is a component whose children have not upgraded yet, and writing zeroes would
|
|
48
|
+
* park the mark at the origin and leave it there. The `data-placed` attribute is
|
|
49
|
+
* what a stylesheet waits for, so a mark fades in where it belongs rather than
|
|
50
|
+
* sliding from the corner on first paint.
|
|
51
|
+
*
|
|
52
|
+
* The four custom properties are the contract. What a component does with them
|
|
53
|
+
* is its own business, which is how one controller serves a bar under a tab, a
|
|
54
|
+
* pill filling it, and a rule down the side of a contents page.
|
|
55
|
+
*
|
|
56
|
+
* **Declared here because this is what writes them.** They carry a measurement
|
|
57
|
+
* no stylesheet can take, they are read by whoever draws the mark, and `core`
|
|
58
|
+
* ships no CSS, so there is no reader inside this package and nothing about
|
|
59
|
+
* them is private. `check:hooks` pairs a `@cssproperty` with a rule that reads
|
|
60
|
+
* one, in both directions, and the underscore these used to carry was what
|
|
61
|
+
* exempted them from it.
|
|
62
|
+
*
|
|
63
|
+
* @cssproperty --indicator-block-size - How tall the active item is, in pixels.
|
|
64
|
+
* @cssproperty --indicator-inline-size - How wide the active item is, in pixels.
|
|
65
|
+
* @cssproperty --indicator-x - Where the active item starts, along the inline axis.
|
|
66
|
+
* @cssproperty --indicator-y - Where the active item starts, along the block axis.
|
|
67
|
+
*/
|
|
68
|
+
export declare class IndicatorController implements ReactiveController {
|
|
69
|
+
private readonly host;
|
|
70
|
+
private readonly options;
|
|
71
|
+
private resize;
|
|
72
|
+
constructor(host: ReactiveControllerHost & HTMLElement, options: IndicatorOptions);
|
|
73
|
+
hostConnected(): void;
|
|
74
|
+
hostDisconnected(): void;
|
|
75
|
+
/** Called after every update, which is when the marked child may have moved. */
|
|
76
|
+
hostUpdated(): void;
|
|
77
|
+
/** Where the mark was last put, so a placement can tell a move from a resize. */
|
|
78
|
+
private last;
|
|
79
|
+
/** The guard behind `transitionend`, in case it never comes. */
|
|
80
|
+
private settling;
|
|
81
|
+
/**
|
|
82
|
+
* The listener currently waiting, so the next move can take it off first.
|
|
83
|
+
*
|
|
84
|
+
* **Without this, a move inside a second orphans a listener.** Each placement
|
|
85
|
+
* built a fresh `settle` closure and then called `clearTimeout` on the timer
|
|
86
|
+
* belonging to the *previous* one, which is the only thing that would have
|
|
87
|
+
* removed it: the event itself never arrives under reduced motion, where
|
|
88
|
+
* there is no transition to end. So a reader changing tabs quickly, or any
|
|
89
|
+
* reader at all with motion reduced, left one dead listener per selection on
|
|
90
|
+
* an element that lives as long as the page. The people who asked for less
|
|
91
|
+
* work were the ones accumulating it.
|
|
92
|
+
*/
|
|
93
|
+
private waiting;
|
|
94
|
+
place(): void;
|
|
95
|
+
/** Takes the pending listener and its guard off, wherever they came from. */
|
|
96
|
+
private stopWaiting;
|
|
97
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measures the marked child and writes the geometry onto a traveling mark.
|
|
3
|
+
*
|
|
4
|
+
* **Four components had written this and the copies had diverged.** `Navigation`,
|
|
5
|
+
* `Tabs`, `Toggle.Group` and `Outline` each measured an active child and wrote
|
|
6
|
+
* a position onto an indicator, and two of them had bugs the others did not.
|
|
7
|
+
* `Outline`'s own comment counted the repetition rather than resolving it, and
|
|
8
|
+
* the audit found the same shape independently in all four.
|
|
9
|
+
*
|
|
10
|
+
* What the copies disagreed about, and what this settles:
|
|
11
|
+
*
|
|
12
|
+
* **The inline axis is signed.** In a right-to-left document the distance is
|
|
13
|
+
* measured from the surface's right edge and applied as a negative translation,
|
|
14
|
+
* because the mark travels from the inline start and the inline start is on the
|
|
15
|
+
* right. `Navigation` measured from the physical left and applied it from the
|
|
16
|
+
* inline-start edge with an unsigned offset, so its marker went the wrong way
|
|
17
|
+
* in RTL. That is the bug this extraction exists to stop repeating.
|
|
18
|
+
*
|
|
19
|
+
* **The border has to come off both axes.** `getBoundingClientRect` gives the
|
|
20
|
+
* border box and the containing block is the padding box, so a surface with a
|
|
21
|
+
* border of its own puts the mark one border-width out. `Tabs` and
|
|
22
|
+
* `Toggle.Group` each did this arithmetic separately, `Outline` did the block
|
|
23
|
+
* half with `clientTop`, and `Navigation` did neither. `clientLeft` and `clientTop`
|
|
24
|
+
* are the border widths, already resolved, which is why they are used here
|
|
25
|
+
* rather than parsing `borderLeftWidth`.
|
|
26
|
+
*
|
|
27
|
+
* **Nothing is written until there is something to measure.** A zero-width box
|
|
28
|
+
* is a component whose children have not upgraded yet, and writing zeroes would
|
|
29
|
+
* park the mark at the origin and leave it there. The `data-placed` attribute is
|
|
30
|
+
* what a stylesheet waits for, so a mark fades in where it belongs rather than
|
|
31
|
+
* sliding from the corner on first paint.
|
|
32
|
+
*
|
|
33
|
+
* The four custom properties are the contract. What a component does with them
|
|
34
|
+
* is its own business, which is how one controller serves a bar under a tab, a
|
|
35
|
+
* pill filling it, and a rule down the side of a contents page.
|
|
36
|
+
*
|
|
37
|
+
* **Declared here because this is what writes them.** They carry a measurement
|
|
38
|
+
* no stylesheet can take, they are read by whoever draws the mark, and `core`
|
|
39
|
+
* ships no CSS, so there is no reader inside this package and nothing about
|
|
40
|
+
* them is private. `check:hooks` pairs a `@cssproperty` with a rule that reads
|
|
41
|
+
* one, in both directions, and the underscore these used to carry was what
|
|
42
|
+
* exempted them from it.
|
|
43
|
+
*
|
|
44
|
+
* @cssproperty --indicator-block-size - How tall the active item is, in pixels.
|
|
45
|
+
* @cssproperty --indicator-inline-size - How wide the active item is, in pixels.
|
|
46
|
+
* @cssproperty --indicator-x - Where the active item starts, along the inline axis.
|
|
47
|
+
* @cssproperty --indicator-y - Where the active item starts, along the block axis.
|
|
48
|
+
*/
|
|
49
|
+
export class IndicatorController {
|
|
50
|
+
constructor(host, options) {
|
|
51
|
+
this.host = host;
|
|
52
|
+
this.options = options;
|
|
53
|
+
host.addController(this);
|
|
54
|
+
}
|
|
55
|
+
hostConnected() {
|
|
56
|
+
/* The host's own size is not enough: a strip that keeps its width while its
|
|
57
|
+
children rewrap moves every one of them, and a mark measured against the
|
|
58
|
+
old layout is left behind. Observing the host catches both, because a
|
|
59
|
+
rewrap changes its height. */
|
|
60
|
+
this.resize = new ResizeObserver(() => this.place());
|
|
61
|
+
this.resize.observe(this.host);
|
|
62
|
+
}
|
|
63
|
+
hostDisconnected() {
|
|
64
|
+
this.resize?.disconnect();
|
|
65
|
+
this.resize = undefined;
|
|
66
|
+
/* The host going away is the third way a move can end, and the one nothing
|
|
67
|
+
was watching. A timer holding a closure over a detached element keeps it
|
|
68
|
+
alive for the second it has left to run. */
|
|
69
|
+
this.stopWaiting();
|
|
70
|
+
}
|
|
71
|
+
/** Called after every update, which is when the marked child may have moved. */
|
|
72
|
+
hostUpdated() {
|
|
73
|
+
this.place();
|
|
74
|
+
}
|
|
75
|
+
/*
|
|
76
|
+
* Measures and writes, or gives up without writing anything.
|
|
77
|
+
*
|
|
78
|
+
* Public because `slotchange` is not an update: children arriving is exactly
|
|
79
|
+
* when the first measurement becomes possible, and a component that only
|
|
80
|
+
* placed on its own updates left the mark unplaced for the life of the page.
|
|
81
|
+
*/
|
|
82
|
+
place() {
|
|
83
|
+
const indicator = this.options.indicator();
|
|
84
|
+
const surface = this.options.surface();
|
|
85
|
+
const active = this.options.active();
|
|
86
|
+
if (!indicator || !surface) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (!active) {
|
|
90
|
+
// Nothing is marked, so there is nothing to point at. The attribute goes
|
|
91
|
+
// and the stylesheet takes the mark away with it.
|
|
92
|
+
indicator.removeAttribute('data-placed');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const box = active.getBoundingClientRect();
|
|
96
|
+
const own = surface.getBoundingClientRect();
|
|
97
|
+
if (box.width === 0 && box.height === 0) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
/* `clientLeft` and `clientTop` are the resolved border widths, which is the
|
|
101
|
+
difference between the border box the measurement gives and the padding
|
|
102
|
+
box the mark is placed against. */
|
|
103
|
+
const rtl = getComputedStyle(surface).direction === 'rtl';
|
|
104
|
+
const inline = rtl
|
|
105
|
+
? own.right - box.right - surface.clientLeft
|
|
106
|
+
: box.left - own.left - surface.clientLeft;
|
|
107
|
+
const x = `${rtl ? -inline : inline}px`;
|
|
108
|
+
const y = `${box.top - own.top - surface.clientTop}px`;
|
|
109
|
+
/* `data-moving` while it is on its way, so a stylesheet can say something
|
|
110
|
+
* about the travel itself.
|
|
111
|
+
*
|
|
112
|
+
* There is no CSS state for "a transition is running", and the effect this
|
|
113
|
+
* exists for wants one: a mark that swells as it leaves and settles back
|
|
114
|
+
* when it lands reads as a thing being carried rather than a rectangle
|
|
115
|
+
* being repositioned. The controller is the only thing that knows a move
|
|
116
|
+
* has been asked for, so it says so.
|
|
117
|
+
*
|
|
118
|
+
* Only on a *change*, and never on the first placement: the mark arrives by
|
|
119
|
+
* fading in, and swelling on arrival would be an animation about nothing.
|
|
120
|
+
*
|
|
121
|
+
* Cleared on `transitionend` with a timeout behind it. The event is the
|
|
122
|
+
* honest signal and it does not always come: a move under reduced motion
|
|
123
|
+
* has no transition to end, and neither does one whose computed position
|
|
124
|
+
* turns out identical after a resize. A marker that can stick is worse than
|
|
125
|
+
* one that clears a little early. */
|
|
126
|
+
const moved = this.last !== undefined && (this.last.x !== x || this.last.y !== y);
|
|
127
|
+
this.last = { x, y };
|
|
128
|
+
indicator.style.setProperty('--indicator-x', x);
|
|
129
|
+
indicator.style.setProperty('--indicator-y', y);
|
|
130
|
+
indicator.style.setProperty('--indicator-inline-size', `${box.width}px`);
|
|
131
|
+
indicator.style.setProperty('--indicator-block-size', `${box.height}px`);
|
|
132
|
+
indicator.toggleAttribute('data-placed', true);
|
|
133
|
+
if (!moved) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
indicator.toggleAttribute('data-moving', true);
|
|
137
|
+
this.stopWaiting();
|
|
138
|
+
/* `translate` only. `transitionend` fires once per property, and a
|
|
139
|
+
stylesheet reacting to `data-moving` is very likely to transition
|
|
140
|
+
something shorter than the travel: the outline's mark widens over
|
|
141
|
+
`duration-fast` while it travels over `duration-normal`, so an unfiltered
|
|
142
|
+
listener had the width's own transition end the move that caused it, a
|
|
143
|
+
third of the way through. */
|
|
144
|
+
const settle = (event) => {
|
|
145
|
+
if (event && event.propertyName !== 'translate') {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
indicator.removeAttribute('data-moving');
|
|
149
|
+
this.stopWaiting();
|
|
150
|
+
};
|
|
151
|
+
indicator.addEventListener('transitionend', settle);
|
|
152
|
+
this.waiting = { on: indicator, settle };
|
|
153
|
+
this.settling = setTimeout(settle, 1000);
|
|
154
|
+
}
|
|
155
|
+
/** Takes the pending listener and its guard off, wherever they came from. */
|
|
156
|
+
stopWaiting() {
|
|
157
|
+
clearTimeout(this.settling);
|
|
158
|
+
this.settling = undefined;
|
|
159
|
+
if (this.waiting) {
|
|
160
|
+
this.waiting.on.removeEventListener('transitionend', this.waiting.settle);
|
|
161
|
+
this.waiting = undefined;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
2
|
+
/** What a controller needs of an item to move to it and choose it. */
|
|
3
|
+
export interface NavigableItem {
|
|
4
|
+
/**
|
|
5
|
+
* Whether the press came from inside this item, for `origin: 'focus'`.
|
|
6
|
+
*
|
|
7
|
+
* Every item that uses this is an element, so this is `Node.contains` and
|
|
8
|
+
* nothing has to implement it. It is declared because the controller depends
|
|
9
|
+
* on it, and a structural interface that leaves out what it calls is a
|
|
10
|
+
* `NavigableItem` a component can satisfy and still break.
|
|
11
|
+
*/
|
|
12
|
+
contains(node: Node | null): boolean;
|
|
13
|
+
/** Whether the item can be reached at all. Skipped when it cannot. */
|
|
14
|
+
disabled: boolean;
|
|
15
|
+
/** Moves focus to the item, which each family does its own way. */
|
|
16
|
+
focusItem(): void;
|
|
17
|
+
/** Chooses the item, which for a radio group is the same act as arriving. */
|
|
18
|
+
select(): void;
|
|
19
|
+
/** Whether the item is the current one, which is where movement starts. */
|
|
20
|
+
selected: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** How the controller is told what it is moving through. */
|
|
23
|
+
export interface KeyboardNavigationOptions {
|
|
24
|
+
/** The items, in the order a reader moves through them. */
|
|
25
|
+
items: () => NavigableItem[];
|
|
26
|
+
/**
|
|
27
|
+
* Where a move starts from.
|
|
28
|
+
*
|
|
29
|
+
* `selection` is the radio-group reading and the default: the set has one
|
|
30
|
+
* answer, focus is always on it, and the two never diverge.
|
|
31
|
+
*
|
|
32
|
+
* `focus` is for a set where they can. Tabs is the case: in `manual` mode a
|
|
33
|
+
* reader arrows without choosing, so after one press focus is no longer on
|
|
34
|
+
* the selection, and starting from the selection walks from the same place
|
|
35
|
+
* every time. It is also the only reading that can tell a press on a tab from
|
|
36
|
+
* a press somewhere else in the component, which matters wherever the host
|
|
37
|
+
* holds more than the set (a tab panel is a tab stop of its own).
|
|
38
|
+
*/
|
|
39
|
+
origin?: 'focus' | 'selection';
|
|
40
|
+
/**
|
|
41
|
+
* Whether arriving also chooses.
|
|
42
|
+
*
|
|
43
|
+
* A function rather than a flag: Tabs takes it from `manual`, which a
|
|
44
|
+
* consumer can change at any time, and a value read once in the constructor
|
|
45
|
+
* would be the value the element happened to have when it upgraded.
|
|
46
|
+
*/
|
|
47
|
+
selects?: () => boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Keys this component binds to something else, which this must not take.
|
|
50
|
+
*
|
|
51
|
+
* **A claim, not a preference.** "Both axes, always" is argued below and the
|
|
52
|
+
* argument holds: a reader who guesses the wrong axis and gets nothing is a
|
|
53
|
+
* reader who concludes the set does not answer the keyboard. So this is not a
|
|
54
|
+
* way to support fewer keys. It is a way to say that a key already means
|
|
55
|
+
* something here, and a component that names one owes that key a binding of
|
|
56
|
+
* its own.
|
|
57
|
+
*
|
|
58
|
+
* `Menu` is the case it was written for. Its rows can open a submenu, and the
|
|
59
|
+
* platform's own bindings for that are the horizontal pair: right goes in,
|
|
60
|
+
* left comes back. Those keys are not unhandled in a menu, they are spoken
|
|
61
|
+
* for, and without this the controller would take them first and a submenu
|
|
62
|
+
* could not be opened from the keyboard at all.
|
|
63
|
+
*/
|
|
64
|
+
reserved?: () => string[];
|
|
65
|
+
/**
|
|
66
|
+
* Whether the ends join up. Radio groups wrap; a list of commands does not.
|
|
67
|
+
*/
|
|
68
|
+
wrap?: boolean;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Moves through a set of items with the arrow keys, and chooses as it goes.
|
|
72
|
+
*
|
|
73
|
+
* **Moving and choosing are one act here, not two.** That is the radio group
|
|
74
|
+
* pattern and it is the difference from a listbox: moving focus without
|
|
75
|
+
* choosing leaves a keyboard reader sitting on an item they have not selected,
|
|
76
|
+
* with nothing on screen saying which of the two is true. A pointer user
|
|
77
|
+
* chooses by arriving, so a keyboard user does too.
|
|
78
|
+
*
|
|
79
|
+
* **Both axes, always.** A vertical set still answers left and right, and a
|
|
80
|
+
* horizontal one still answers up and down. Binding only the axis the items
|
|
81
|
+
* happen to be laid out on means a reader who guesses wrong gets nothing, and
|
|
82
|
+
* `orientation` is presentation: the same set of choices reads the same way
|
|
83
|
+
* whichever direction it runs.
|
|
84
|
+
*
|
|
85
|
+
* Disabled items are skipped rather than stopped at, so a set with a gap in it
|
|
86
|
+
* still moves in one press.
|
|
87
|
+
*
|
|
88
|
+
* Written because `Radio.Group` and `Toggle.Group` had this twice, identically,
|
|
89
|
+
* down to the order of the branches. The second copy is what makes it a
|
|
90
|
+
* controller: one is a component's own business, two is a pattern, and the two
|
|
91
|
+
* had not diverged only because nothing had touched them yet.
|
|
92
|
+
*
|
|
93
|
+
* **`Tabs` is the third, and it is what `origin` and `selects` are for.** It had
|
|
94
|
+
* diverged, which is the case the paragraph above was worried about: it started
|
|
95
|
+
* from focus rather than from the selection, and it moved without choosing when
|
|
96
|
+
* `manual` was set. Both are real and neither is a tabs-only idea, so they are
|
|
97
|
+
* options here rather than a reason to keep a fourth copy. What it does *not*
|
|
98
|
+
* keep is its own key set: it bound the horizontal pair only, and both axes is
|
|
99
|
+
* the house position, argued two paragraphs up. A reader on a tab who presses
|
|
100
|
+
* Down now moves along the row instead of getting nothing.
|
|
101
|
+
*/
|
|
102
|
+
export declare class KeyboardNavigationController implements ReactiveController {
|
|
103
|
+
private readonly host;
|
|
104
|
+
private readonly items;
|
|
105
|
+
private readonly origin;
|
|
106
|
+
private readonly selects;
|
|
107
|
+
private readonly reserved;
|
|
108
|
+
private readonly wrap;
|
|
109
|
+
constructor(host: ReactiveControllerHost & HTMLElement, { items, origin, reserved, selects, wrap, }: KeyboardNavigationOptions);
|
|
110
|
+
private readonly handleKeydown;
|
|
111
|
+
hostConnected(): void;
|
|
112
|
+
hostDisconnected(): void;
|
|
113
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** Every key that moves within a set, which is the whole of what this handles. */
|
|
2
|
+
const KEYS = new Set(['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'End', 'Home']);
|
|
3
|
+
/**
|
|
4
|
+
* Moves through a set of items with the arrow keys, and chooses as it goes.
|
|
5
|
+
*
|
|
6
|
+
* **Moving and choosing are one act here, not two.** That is the radio group
|
|
7
|
+
* pattern and it is the difference from a listbox: moving focus without
|
|
8
|
+
* choosing leaves a keyboard reader sitting on an item they have not selected,
|
|
9
|
+
* with nothing on screen saying which of the two is true. A pointer user
|
|
10
|
+
* chooses by arriving, so a keyboard user does too.
|
|
11
|
+
*
|
|
12
|
+
* **Both axes, always.** A vertical set still answers left and right, and a
|
|
13
|
+
* horizontal one still answers up and down. Binding only the axis the items
|
|
14
|
+
* happen to be laid out on means a reader who guesses wrong gets nothing, and
|
|
15
|
+
* `orientation` is presentation: the same set of choices reads the same way
|
|
16
|
+
* whichever direction it runs.
|
|
17
|
+
*
|
|
18
|
+
* Disabled items are skipped rather than stopped at, so a set with a gap in it
|
|
19
|
+
* still moves in one press.
|
|
20
|
+
*
|
|
21
|
+
* Written because `Radio.Group` and `Toggle.Group` had this twice, identically,
|
|
22
|
+
* down to the order of the branches. The second copy is what makes it a
|
|
23
|
+
* controller: one is a component's own business, two is a pattern, and the two
|
|
24
|
+
* had not diverged only because nothing had touched them yet.
|
|
25
|
+
*
|
|
26
|
+
* **`Tabs` is the third, and it is what `origin` and `selects` are for.** It had
|
|
27
|
+
* diverged, which is the case the paragraph above was worried about: it started
|
|
28
|
+
* from focus rather than from the selection, and it moved without choosing when
|
|
29
|
+
* `manual` was set. Both are real and neither is a tabs-only idea, so they are
|
|
30
|
+
* options here rather than a reason to keep a fourth copy. What it does *not*
|
|
31
|
+
* keep is its own key set: it bound the horizontal pair only, and both axes is
|
|
32
|
+
* the house position, argued two paragraphs up. A reader on a tab who presses
|
|
33
|
+
* Down now moves along the row instead of getting nothing.
|
|
34
|
+
*/
|
|
35
|
+
export class KeyboardNavigationController {
|
|
36
|
+
constructor(host, { items, origin = 'selection', reserved = () => [], selects = () => true, wrap = true, }) {
|
|
37
|
+
this.handleKeydown = (event) => {
|
|
38
|
+
if (!KEYS.has(event.key) || this.reserved().includes(event.key)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const items = this.items().filter((item) => !item.disabled);
|
|
42
|
+
if (items.length === 0) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
/* `-1` means two different things and only one of them is a starting point.
|
|
46
|
+
*
|
|
47
|
+
Starting from the selection, `-1` is "nothing is chosen", and the
|
|
48
|
+
arithmetic below relies on it: a forward step lands on the first item and
|
|
49
|
+
a backward step on the last, both of which are where a reader entering an
|
|
50
|
+
unanswered set expects to arrive.
|
|
51
|
+
|
|
52
|
+
Starting from focus, `-1` is "the press did not come from the set", and
|
|
53
|
+
there is nothing to move. The listener is on the host, not on the items,
|
|
54
|
+
and a host can hold more than the set: a tab panel is `tabindex="0"`, so
|
|
55
|
+
an arrow pressed while reading a panel used to move the tabs out from
|
|
56
|
+
under the reader. */
|
|
57
|
+
const current = this.origin === 'focus'
|
|
58
|
+
? items.findIndex((item) => item.contains(event.target))
|
|
59
|
+
: items.findIndex((item) => item.selected);
|
|
60
|
+
if (this.origin === 'focus' && current === -1) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const last = items.length - 1;
|
|
64
|
+
let next = current;
|
|
65
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
|
66
|
+
next = this.wrap ? (current + 1) % items.length : Math.min(current + 1, last);
|
|
67
|
+
}
|
|
68
|
+
else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
|
69
|
+
/* From nothing, backwards is the last, which the arithmetic did not give.
|
|
70
|
+
*
|
|
71
|
+
* `(-1 - 1 + length) % length` is `length - 2`: the *second* to last. So a
|
|
72
|
+
* reader arrowing backwards into an unanswered group landed one short of
|
|
73
|
+
* the end, and the comment above says plainly that it lands on the end.
|
|
74
|
+
* The wrapping form is right for every real index and wrong for the one
|
|
75
|
+
* that is not an index.
|
|
76
|
+
*
|
|
77
|
+
* And the clamp is `Math.max` against the forward branch's `Math.min`,
|
|
78
|
+
* which it was missing. Unclamped this is `-1` on the first item, and
|
|
79
|
+
* `items[-1]` is undefined, so nothing was focused and nothing chosen on a
|
|
80
|
+
* press that `preventDefault` had already swallowed. Holding Left on the
|
|
81
|
+
* first star of a rating got silence, where holding Right on the last one
|
|
82
|
+
* holds its ground. */
|
|
83
|
+
next =
|
|
84
|
+
current < 0
|
|
85
|
+
? last
|
|
86
|
+
: this.wrap
|
|
87
|
+
? (current - 1 + items.length) % items.length
|
|
88
|
+
: Math.max(current - 1, 0);
|
|
89
|
+
}
|
|
90
|
+
else if (event.key === 'Home') {
|
|
91
|
+
next = 0;
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
next = last;
|
|
95
|
+
}
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
const target = items[next];
|
|
98
|
+
target?.focusItem();
|
|
99
|
+
if (this.selects()) {
|
|
100
|
+
target?.select();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
this.host = host;
|
|
104
|
+
this.items = items;
|
|
105
|
+
this.origin = origin;
|
|
106
|
+
this.reserved = reserved;
|
|
107
|
+
this.selects = selects;
|
|
108
|
+
this.wrap = wrap;
|
|
109
|
+
host.addController(this);
|
|
110
|
+
}
|
|
111
|
+
hostConnected() {
|
|
112
|
+
this.host.addEventListener('keydown', this.handleKeydown);
|
|
113
|
+
}
|
|
114
|
+
hostDisconnected() {
|
|
115
|
+
this.host.removeEventListener('keydown', this.handleKeydown);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
2
|
+
import { type Placement } from '../floating-controller/floating-controller.js';
|
|
3
|
+
export interface PopoverOptions {
|
|
4
|
+
/** The element the surface opens against. */
|
|
5
|
+
anchor: () => HTMLElement | null | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* The element that points at the anchor, where the surface has one.
|
|
8
|
+
*
|
|
9
|
+
* Passed through to the floating pass, which positions it against whichever
|
|
10
|
+
* side the surface ended up on. Optional because most surfaces have none: a
|
|
11
|
+
* menu and a select are read as belonging to the control they hang off, and
|
|
12
|
+
* only a bubble has to say which thing it is describing.
|
|
13
|
+
*/
|
|
14
|
+
arrow?: () => HTMLElement | null | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* What counts as inside for the purpose of a press landing elsewhere.
|
|
17
|
+
*
|
|
18
|
+
* Defaults to the surface. A host that draws no box of its own points this at
|
|
19
|
+
* itself instead, so a press on its trigger is inside rather than out.
|
|
20
|
+
*/
|
|
21
|
+
boundary?: () => Element | readonly (Element | null | undefined)[] | null | undefined;
|
|
22
|
+
/** Space between the anchor and the surface, in pixels. */
|
|
23
|
+
offset?: number | (() => number);
|
|
24
|
+
/**
|
|
25
|
+
* A box to keep the surface inside, instead of the viewport.
|
|
26
|
+
*
|
|
27
|
+
* Not `boundary` above, and the two are easy to confuse: that one decides
|
|
28
|
+
* what counts as inside for a *press*, and this one decides what counts as
|
|
29
|
+
* inside for the *placement*. A tooltip on a chart wants the second and has
|
|
30
|
+
* no use for the first.
|
|
31
|
+
*/
|
|
32
|
+
within?: () => Element | null | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* Whether to measure the anchor and place the surface against it.
|
|
35
|
+
*
|
|
36
|
+
* On unless said otherwise, which is every surface that hangs off something:
|
|
37
|
+
* a menu, a select, a tooltip. Off is for a surface whose position is a fact
|
|
38
|
+
* about the page rather than about its trigger, and the one that asked for
|
|
39
|
+
* it is a full-bleed navigation panel: it spans the viewport and sits under
|
|
40
|
+
* the header, so there is nothing to measure and nothing to flip or shift
|
|
41
|
+
* to. Placing it anyway wrote a `translate` of the trigger's own offset over
|
|
42
|
+
* a band that is meant to start at the edge.
|
|
43
|
+
*
|
|
44
|
+
* The rest still applies, which is why this is an option here rather than a
|
|
45
|
+
* reason to do without the controller: the top layer and the dismissal are
|
|
46
|
+
* exactly what such a panel needs, and they are what this bundles. What it
|
|
47
|
+
* gives up is the measuring pass, and the surface's own stylesheet says where
|
|
48
|
+
* it goes.
|
|
49
|
+
*/
|
|
50
|
+
place?: boolean | (() => boolean);
|
|
51
|
+
/**
|
|
52
|
+
* Called when a press outside or an Escape asks for it to close.
|
|
53
|
+
*
|
|
54
|
+
* The host decides what closing means, because it is the only thing that
|
|
55
|
+
* knows what else has to happen: a menu forgets which page it was on, a
|
|
56
|
+
* select puts focus back on its trigger, and a tooltip does neither.
|
|
57
|
+
*/
|
|
58
|
+
onDismiss: () => void;
|
|
59
|
+
/** Preferred side. It moves if that side does not fit. */
|
|
60
|
+
placement?: Placement | (() => Placement);
|
|
61
|
+
/** The panel that floats. */
|
|
62
|
+
surface: () => HTMLElement | null | undefined;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A surface that floats against something, in the top layer, until dismissed.
|
|
66
|
+
*
|
|
67
|
+
* Three things always travel together and were written out longhand three
|
|
68
|
+
* times: the popover API, a `FloatingController` and a `DismissController`.
|
|
69
|
+
* Individually each is small, which is exactly why the copies drifted rather
|
|
70
|
+
* than being noticed. **`Select` never called `showPopover` at all**, so its
|
|
71
|
+
* list was an ordinary positioned panel and any ancestor with `overflow:
|
|
72
|
+
* hidden` clipped it, while a menu two folders away in the same position did
|
|
73
|
+
* not. That is not a difference anyone chose.
|
|
74
|
+
*
|
|
75
|
+
* **The top layer is the whole point.** A floating panel has to escape every
|
|
76
|
+
* clipping ancestor between it and the page, and `position: fixed` does not do
|
|
77
|
+
* that: it is clipped by `overflow` on any ancestor and re-anchored entirely by
|
|
78
|
+
* an ancestor carrying `transform`, `filter` or `contain`. The popover API
|
|
79
|
+
* lifts the element out of the tree's paint order for real, and it is the only
|
|
80
|
+
* thing that does.
|
|
81
|
+
*
|
|
82
|
+
* `manual` rather than `auto`, because the host owns dismissal. `auto`
|
|
83
|
+
* popovers light-dismiss themselves and close each other, which is close to
|
|
84
|
+
* what is wanted and not the same: a select that closed itself before its own
|
|
85
|
+
* change event landed, or a submenu that closed its parent, are both things the
|
|
86
|
+
* platform would do here and neither is right.
|
|
87
|
+
*
|
|
88
|
+
* **The order matters on the way up.** `showPopover` first, then position:
|
|
89
|
+
* measuring an element that is still `display: none` gives zeroes, so a surface
|
|
90
|
+
* positioned before it is shown lands at the origin and slides into place on
|
|
91
|
+
* the next frame. On the way down it is the reverse, and stopping the observer
|
|
92
|
+
* before hiding avoids a final measurement of an element on its way out.
|
|
93
|
+
*
|
|
94
|
+
* What this does not own is *when*. Opening is the host's, because a menu opens
|
|
95
|
+
* on click, a tooltip on hover and a select on either, and none of that is
|
|
96
|
+
* about surfaces.
|
|
97
|
+
*/
|
|
98
|
+
export declare class PopoverController implements ReactiveController {
|
|
99
|
+
private readonly options;
|
|
100
|
+
private readonly floating;
|
|
101
|
+
private readonly dismisser;
|
|
102
|
+
/** Whether the surface is currently shown, so the calls can be idempotent. */
|
|
103
|
+
private shown;
|
|
104
|
+
constructor(host: ReactiveControllerHost & HTMLElement, options: PopoverOptions);
|
|
105
|
+
/** Whether the surface is showing. */
|
|
106
|
+
get open(): boolean;
|
|
107
|
+
/** Whether this one is placed by measurement, read fresh so it may change. */
|
|
108
|
+
private get places();
|
|
109
|
+
/**
|
|
110
|
+
* Shows the surface, positions it, and starts listening for a way out.
|
|
111
|
+
*
|
|
112
|
+
* Safe to call when already open, so a host may call it from whatever
|
|
113
|
+
* combination of a property, an event and a command it likes without keeping
|
|
114
|
+
* track of which one got there first.
|
|
115
|
+
*/
|
|
116
|
+
show(): void;
|
|
117
|
+
/** Hides the surface and stops everything watching it. */
|
|
118
|
+
hide(): void;
|
|
119
|
+
/** Whichever of the two the surface is not already doing. */
|
|
120
|
+
toggle(): void;
|
|
121
|
+
/**
|
|
122
|
+
* Places the surface again, for an anchor that moved without the page moving.
|
|
123
|
+
*
|
|
124
|
+
* `autoUpdate` watches scrolling, resizing and layout shift, which covers
|
|
125
|
+
* everything that moves an anchor *with* its surroundings. It does not cover
|
|
126
|
+
* an absolutely positioned anchor being moved by an inline style, and that is
|
|
127
|
+
* exactly what a chart does: the mark walks from reading to reading while the
|
|
128
|
+
* page holds still, so the bubble was placed once and stayed there.
|
|
129
|
+
*/
|
|
130
|
+
reposition(): void;
|
|
131
|
+
hostDisconnected(): void;
|
|
132
|
+
}
|