@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,128 @@
|
|
|
1
|
+
import { HelixCopyEvent } from '../../events/events.js';
|
|
2
|
+
/**
|
|
3
|
+
* Writes to the clipboard, by whichever of the two routes this context allows.
|
|
4
|
+
*
|
|
5
|
+
* The async API first, because it is the one with a future and the only one that
|
|
6
|
+
* reports failure. It is gated in more places than it looks: an insecure origin,
|
|
7
|
+
* a document that is not focused, and a Permissions Policy that defaults to
|
|
8
|
+
* `self`, which a sandboxed iframe without `allow-same-origin` is not.
|
|
9
|
+
*
|
|
10
|
+
* `execCommand` second, deprecated and still the more widely permitted of the
|
|
11
|
+
* two: it is synchronous and rides the user gesture that called it rather than
|
|
12
|
+
* asking for a permission, so it works in a sandboxed frame where the modern one
|
|
13
|
+
* is refused. A textarea rather than the selection, because the text being
|
|
14
|
+
* copied is not on the page: it is a value.
|
|
15
|
+
*
|
|
16
|
+
* `fixed` and `opacity: 0` rather than off-screen, because scrolling to a
|
|
17
|
+
* focused element is what a negative offset causes, and a copy that jumps the
|
|
18
|
+
* page is worse than one that does not happen.
|
|
19
|
+
*/
|
|
20
|
+
const write = async (text) => {
|
|
21
|
+
try {
|
|
22
|
+
await navigator.clipboard.writeText(text);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* Fall through. There is nothing to report yet. */
|
|
27
|
+
}
|
|
28
|
+
const carrier = document.createElement('textarea');
|
|
29
|
+
carrier.value = text;
|
|
30
|
+
carrier.setAttribute('readonly', '');
|
|
31
|
+
carrier.setAttribute('aria-hidden', 'true');
|
|
32
|
+
carrier.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
|
|
33
|
+
document.body.append(carrier);
|
|
34
|
+
/* Focused as well as selected. `select()` focuses in a browser and does not
|
|
35
|
+
everywhere, and `execCommand('copy')` copies the *document's* selection, so
|
|
36
|
+
an unfocused textarea copies whatever was selected on the page instead. */
|
|
37
|
+
carrier.focus();
|
|
38
|
+
carrier.select();
|
|
39
|
+
let wrote = false;
|
|
40
|
+
try {
|
|
41
|
+
wrote = document.execCommand('copy');
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
wrote = false;
|
|
45
|
+
}
|
|
46
|
+
carrier.remove();
|
|
47
|
+
return wrote;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Puts text on the clipboard and holds a confirmation for a moment.
|
|
51
|
+
*
|
|
52
|
+
* A controller rather than a component, because copying is a behavior and not a
|
|
53
|
+
* shape. Color's chip is a swatch that copies, and a copy button is a button
|
|
54
|
+
* that copies, and those two have nothing in common to look at: what they share
|
|
55
|
+
* is the write, the confirmation that follows it, the timer that ends it and the
|
|
56
|
+
* event that announces it. Written twice, the second one would get the failure
|
|
57
|
+
* case wrong.
|
|
58
|
+
*
|
|
59
|
+
* **There are two routes and both are tried**, because the modern one is gated
|
|
60
|
+
* in more places than it looks: an insecure origin, an unfocused document, and a
|
|
61
|
+
* Permissions Policy defaulting to `self`, which a sandboxed iframe is not. The
|
|
62
|
+
* component using this reported as not working, and a control that does nothing
|
|
63
|
+
* is indistinguishable from a broken one. See `write` above.
|
|
64
|
+
*
|
|
65
|
+
* **Where neither route is allowed, the failure is silence.** The confirmation
|
|
66
|
+
* does not appear, which is the honest report: a message about a clipboard is
|
|
67
|
+
* worse than a chip that did not flash, and there is nothing a reader could do
|
|
68
|
+
* about a permission policy anyway.
|
|
69
|
+
*
|
|
70
|
+
* **The event is announced after the write, not before it.** It is not
|
|
71
|
+
* cancelable for the same reason: by the time anything could be said, the text
|
|
72
|
+
* is already on the clipboard, and an event that cannot prevent what it
|
|
73
|
+
* describes should not pretend otherwise.
|
|
74
|
+
*
|
|
75
|
+
* The timer is cleared when the host leaves the document, or a component removed
|
|
76
|
+
* mid-confirmation asks a detached element to re-render.
|
|
77
|
+
*/
|
|
78
|
+
export class CopyController {
|
|
79
|
+
constructor(host, options = {}) {
|
|
80
|
+
this.host = host;
|
|
81
|
+
this.options = options;
|
|
82
|
+
/** Whether the last write succeeded and is still being confirmed. */
|
|
83
|
+
this.copied = false;
|
|
84
|
+
host.addController(this);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Writes to the clipboard, and confirms it if the platform allowed it.
|
|
88
|
+
*
|
|
89
|
+
* Empty text is not copied. An empty clipboard is not a thing a reader ever
|
|
90
|
+
* asks for, and confirming one is worse than doing nothing.
|
|
91
|
+
*/
|
|
92
|
+
async copy(text) {
|
|
93
|
+
if (text === '') {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (!(await write(text))) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
/* **The host may have gone while the clipboard was being written.** Writing
|
|
100
|
+
is asynchronous, `hostDisconnected` runs the moment the element leaves,
|
|
101
|
+
and it clears a timer that does not exist yet: everything below this line
|
|
102
|
+
used to run afterwards regardless, starting a fresh timer on a detached
|
|
103
|
+
host and calling `requestUpdate` on it a second later. Checked here
|
|
104
|
+
rather than in `reset`, because the thing to skip is the whole
|
|
105
|
+
confirmation and not only its clock. */
|
|
106
|
+
if (this.host instanceof HTMLElement && !this.host.isConnected) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this.copied = true;
|
|
110
|
+
this.host.requestUpdate();
|
|
111
|
+
this.host.dispatchEvent(new HelixCopyEvent({ value: text }));
|
|
112
|
+
this.reset();
|
|
113
|
+
this.timer = setTimeout(() => {
|
|
114
|
+
this.copied = false;
|
|
115
|
+
this.host.requestUpdate();
|
|
116
|
+
}, this.options.after ?? 1200);
|
|
117
|
+
}
|
|
118
|
+
hostDisconnected() {
|
|
119
|
+
this.reset();
|
|
120
|
+
this.copied = false;
|
|
121
|
+
}
|
|
122
|
+
reset() {
|
|
123
|
+
if (this.timer !== undefined) {
|
|
124
|
+
clearTimeout(this.timer);
|
|
125
|
+
this.timer = undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
2
|
+
import { type DisableableItem } from '../../utils/inherit-disabled/inherit-disabled.js';
|
|
3
|
+
/** What the controller needs of the group it is cascading through. */
|
|
4
|
+
export interface DisabledCascadeOptions {
|
|
5
|
+
/** The members, read fresh each pass. */
|
|
6
|
+
items: () => DisableableItem[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A host that can be out of play, which is every group this runs on.
|
|
10
|
+
*
|
|
11
|
+
* `renderRoot` is named here because `ReactiveControllerHost` does not carry
|
|
12
|
+
* it: the interface is the scheduling contract and says nothing about where a
|
|
13
|
+
* host renders. Every Lit element has one, and this needs it to hear
|
|
14
|
+
* `slotchange`, which stops at the root that owns the slot.
|
|
15
|
+
*/
|
|
16
|
+
type DisableableHost = ReactiveControllerHost & HTMLElement & {
|
|
17
|
+
disabled: boolean;
|
|
18
|
+
renderRoot: DocumentFragment | HTMLElement;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Pushes a group's `disabled` onto its members, on every update, unasked.
|
|
22
|
+
*
|
|
23
|
+
* **The cascade itself is `inheritDisabled`, and this is what makes it
|
|
24
|
+
* automatic.** Four groups called that function by hand from inside whichever
|
|
25
|
+
* method they happened to use for writing to their items: `place` in two of
|
|
26
|
+
* them, `sync` in a third, `place` again in the fourth. That works, and it is
|
|
27
|
+
* the wrong shape for three reasons.
|
|
28
|
+
*
|
|
29
|
+
* It runs when that method runs. A group whose items changed without going
|
|
30
|
+
* through it left them holding the last answer, and which paths reach it is a
|
|
31
|
+
* different set in each of the four.
|
|
32
|
+
*
|
|
33
|
+
* It has to be remembered. A fifth group gets no cascade at all, and nothing
|
|
34
|
+
* says so: the property exists, the attribute reflects, and the members simply
|
|
35
|
+
* ignore it. That is the failure this exists to make impossible, because it is
|
|
36
|
+
* invisible in review and only shows up when someone disables a set.
|
|
37
|
+
*
|
|
38
|
+
* And it adds no API. A group already has `disabled`, from `HelixFormElement`
|
|
39
|
+
* or from the consumer; nothing here introduces a property or a method for
|
|
40
|
+
* anyone to call. By this repo's own rule that makes it a controller and not a
|
|
41
|
+
* mixin: it reacts to a lifecycle and nothing else.
|
|
42
|
+
*
|
|
43
|
+
* So a group states its members once, at construction, and never thinks about
|
|
44
|
+
* it again:
|
|
45
|
+
*
|
|
46
|
+
* new DisabledCascadeController(this, { items: () => this.items })
|
|
47
|
+
*
|
|
48
|
+
* **Runs after every render, and on every `slotchange`.** The second half is
|
|
49
|
+
* not an extra: a member arriving in the light DOM changes no property, so Lit
|
|
50
|
+
* schedules nothing, and a render-only cascade leaves a member slotted into an
|
|
51
|
+
* already-disabled group enabled. Measured before it was added, on a radio
|
|
52
|
+
* group holding two disabled members with a third inserted: `110`, and the
|
|
53
|
+
* third was live in a set nobody could use.
|
|
54
|
+
*
|
|
55
|
+
* The listener is on the render root rather than on the host, because
|
|
56
|
+
* `slotchange` bubbles but is not composed: it reaches the shadow root that
|
|
57
|
+
* owns the slot and stops there. On the host it would never fire at all.
|
|
58
|
+
*
|
|
59
|
+
* After render rather than before, because the items are read from the DOM and
|
|
60
|
+
* a newly slotted member is not there until the render that assigns it.
|
|
61
|
+
*
|
|
62
|
+
* Idempotent, so running on every update costs a comparison per member and
|
|
63
|
+
* nothing else: `inheritDisabled` writes only where the answer changed and
|
|
64
|
+
* remembers what each member said for itself, which is what lets a group be
|
|
65
|
+
* re-enabled without freeing a member that was disabled on its own.
|
|
66
|
+
*/
|
|
67
|
+
export declare class DisabledCascadeController implements ReactiveController {
|
|
68
|
+
private readonly host;
|
|
69
|
+
private readonly items;
|
|
70
|
+
constructor(host: DisableableHost, { items }: DisabledCascadeOptions);
|
|
71
|
+
private readonly cascade;
|
|
72
|
+
hostConnected(): void;
|
|
73
|
+
hostDisconnected(): void;
|
|
74
|
+
hostUpdated(): void;
|
|
75
|
+
}
|
|
76
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { inheritDisabled, } from '../../utils/inherit-disabled/inherit-disabled.js';
|
|
2
|
+
/**
|
|
3
|
+
* Pushes a group's `disabled` onto its members, on every update, unasked.
|
|
4
|
+
*
|
|
5
|
+
* **The cascade itself is `inheritDisabled`, and this is what makes it
|
|
6
|
+
* automatic.** Four groups called that function by hand from inside whichever
|
|
7
|
+
* method they happened to use for writing to their items: `place` in two of
|
|
8
|
+
* them, `sync` in a third, `place` again in the fourth. That works, and it is
|
|
9
|
+
* the wrong shape for three reasons.
|
|
10
|
+
*
|
|
11
|
+
* It runs when that method runs. A group whose items changed without going
|
|
12
|
+
* through it left them holding the last answer, and which paths reach it is a
|
|
13
|
+
* different set in each of the four.
|
|
14
|
+
*
|
|
15
|
+
* It has to be remembered. A fifth group gets no cascade at all, and nothing
|
|
16
|
+
* says so: the property exists, the attribute reflects, and the members simply
|
|
17
|
+
* ignore it. That is the failure this exists to make impossible, because it is
|
|
18
|
+
* invisible in review and only shows up when someone disables a set.
|
|
19
|
+
*
|
|
20
|
+
* And it adds no API. A group already has `disabled`, from `HelixFormElement`
|
|
21
|
+
* or from the consumer; nothing here introduces a property or a method for
|
|
22
|
+
* anyone to call. By this repo's own rule that makes it a controller and not a
|
|
23
|
+
* mixin: it reacts to a lifecycle and nothing else.
|
|
24
|
+
*
|
|
25
|
+
* So a group states its members once, at construction, and never thinks about
|
|
26
|
+
* it again:
|
|
27
|
+
*
|
|
28
|
+
* new DisabledCascadeController(this, { items: () => this.items })
|
|
29
|
+
*
|
|
30
|
+
* **Runs after every render, and on every `slotchange`.** The second half is
|
|
31
|
+
* not an extra: a member arriving in the light DOM changes no property, so Lit
|
|
32
|
+
* schedules nothing, and a render-only cascade leaves a member slotted into an
|
|
33
|
+
* already-disabled group enabled. Measured before it was added, on a radio
|
|
34
|
+
* group holding two disabled members with a third inserted: `110`, and the
|
|
35
|
+
* third was live in a set nobody could use.
|
|
36
|
+
*
|
|
37
|
+
* The listener is on the render root rather than on the host, because
|
|
38
|
+
* `slotchange` bubbles but is not composed: it reaches the shadow root that
|
|
39
|
+
* owns the slot and stops there. On the host it would never fire at all.
|
|
40
|
+
*
|
|
41
|
+
* After render rather than before, because the items are read from the DOM and
|
|
42
|
+
* a newly slotted member is not there until the render that assigns it.
|
|
43
|
+
*
|
|
44
|
+
* Idempotent, so running on every update costs a comparison per member and
|
|
45
|
+
* nothing else: `inheritDisabled` writes only where the answer changed and
|
|
46
|
+
* remembers what each member said for itself, which is what lets a group be
|
|
47
|
+
* re-enabled without freeing a member that was disabled on its own.
|
|
48
|
+
*/
|
|
49
|
+
export class DisabledCascadeController {
|
|
50
|
+
constructor(host, { items }) {
|
|
51
|
+
this.cascade = () => {
|
|
52
|
+
inheritDisabled(this.host.disabled, this.items());
|
|
53
|
+
};
|
|
54
|
+
this.host = host;
|
|
55
|
+
this.items = items;
|
|
56
|
+
host.addController(this);
|
|
57
|
+
}
|
|
58
|
+
hostConnected() {
|
|
59
|
+
this.host.renderRoot.addEventListener('slotchange', this.cascade);
|
|
60
|
+
}
|
|
61
|
+
hostDisconnected() {
|
|
62
|
+
this.host.renderRoot.removeEventListener('slotchange', this.cascade);
|
|
63
|
+
}
|
|
64
|
+
hostUpdated() {
|
|
65
|
+
this.cascade();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
2
|
+
export interface DismissOptions {
|
|
3
|
+
/**
|
|
4
|
+
* The element a press must land inside to count as interacting rather than
|
|
5
|
+
* leaving. Defaults to the host.
|
|
6
|
+
*
|
|
7
|
+
* It has to be overridable because an overlay's own scrim is usually inside
|
|
8
|
+
* its shadow root, and a press on the scrim is exactly what "outside" means
|
|
9
|
+
* to a reader. Measured against the host, that press is inside the composed
|
|
10
|
+
* path and the overlay never closes. A drawer points this at its panel.
|
|
11
|
+
*/
|
|
12
|
+
boundary?: () => Element | readonly (Element | null | undefined)[] | null | undefined;
|
|
13
|
+
/** Called when the reader asks to close. */
|
|
14
|
+
onDismiss: () => void;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Closes an overlay on Escape or on a pointer press outside it.
|
|
18
|
+
*
|
|
19
|
+
* Both, because they are different intentions with the same meaning: Escape is
|
|
20
|
+
* the keyboard's way out and clicking away is the pointer's, and an overlay
|
|
21
|
+
* that honours only one is one half its readers cannot dismiss.
|
|
22
|
+
*
|
|
23
|
+
* The outside press is watched on `pointerdown` rather than `click`, so a press
|
|
24
|
+
* that starts outside and drags across the overlay still counts as leaving
|
|
25
|
+
* rather than as interacting.
|
|
26
|
+
*
|
|
27
|
+
* A press inside a frame is the third case, and it needs its own signal. The
|
|
28
|
+
* frame swallows the event, so the document that owns the overlay never sees a
|
|
29
|
+
* `pointerdown` at all, and where the frame is cross-origin there is nothing to
|
|
30
|
+
* listen to inside it either. What is observable is that the window lost focus
|
|
31
|
+
* and a frame on this page gained it, which is exactly a press the document
|
|
32
|
+
* cannot see. Leaving the browser altogether blurs the window too, so what took
|
|
33
|
+
* focus is the part that distinguishes them.
|
|
34
|
+
*/
|
|
35
|
+
export declare class DismissController implements ReactiveController {
|
|
36
|
+
private readonly host;
|
|
37
|
+
private readonly boundary;
|
|
38
|
+
private readonly onDismiss;
|
|
39
|
+
private active;
|
|
40
|
+
constructor(host: ReactiveControllerHost & HTMLElement, { boundary, onDismiss }: DismissOptions);
|
|
41
|
+
private readonly handleKeydown;
|
|
42
|
+
/**
|
|
43
|
+
* Focus left the document. If a frame took it, that was a press.
|
|
44
|
+
*
|
|
45
|
+
* Deferred by a task, because the order of `blur` against the focus moving is
|
|
46
|
+
* not something every browser agrees on and reading `activeElement` too early
|
|
47
|
+
* gives the element that is losing focus rather than the one taking it. The
|
|
48
|
+
* guard is for the case where the overlay closed for another reason in
|
|
49
|
+
* between, which would otherwise dismiss whatever opened next.
|
|
50
|
+
*/
|
|
51
|
+
private readonly handleWindowBlur;
|
|
52
|
+
private readonly handlePointerDown;
|
|
53
|
+
/** Starts listening. */
|
|
54
|
+
activate(): void;
|
|
55
|
+
/** Stops listening. */
|
|
56
|
+
deactivate(): void;
|
|
57
|
+
hostDisconnected(): void;
|
|
58
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { deepActiveElement } from '../../utils/focusable-elements/focusable-elements.js';
|
|
2
|
+
/*
|
|
3
|
+
* The element that actually has focus, through any number of shadow roots.
|
|
4
|
+
*
|
|
5
|
+
* `document.activeElement` stops at the outermost host: focus inside a shadow
|
|
6
|
+
* tree is reported as the element containing that tree, because what is inside
|
|
7
|
+
* it is not the document's business. So a frame in a component's shadow root
|
|
8
|
+
* reads as the component, and a check for a frame never matches one. Each root
|
|
9
|
+
* keeps its own `activeElement`, so descending through them gives the element
|
|
10
|
+
* the reader is really on.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* How deeply nested an overlay's host is, counting shadow boundaries as well.
|
|
14
|
+
*
|
|
15
|
+
* The innermost overlay is the one a reader means by Escape, and "innermost" has
|
|
16
|
+
* to be measured rather than assumed from listener order: every overlay listens
|
|
17
|
+
* on `document`, so they run in the order they opened, which is not the order
|
|
18
|
+
* they are nested in.
|
|
19
|
+
*/
|
|
20
|
+
const deepDepth = (node) => {
|
|
21
|
+
let depth = 0;
|
|
22
|
+
let at = node;
|
|
23
|
+
while (at) {
|
|
24
|
+
at = at.parentNode ?? at.host ?? null;
|
|
25
|
+
depth += 1;
|
|
26
|
+
}
|
|
27
|
+
return depth;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Every overlay currently listening, so one press can be settled by asking.
|
|
31
|
+
*
|
|
32
|
+
* Module-level because that is the scope the question has: "which open overlay
|
|
33
|
+
* is innermost" is about the page, and no single controller can answer it from
|
|
34
|
+
* what it holds. Membership is exactly `activate`/`deactivate`, and
|
|
35
|
+
* `hostDisconnected` calls the second, so an overlay torn down rather than
|
|
36
|
+
* closed leaves too.
|
|
37
|
+
*/
|
|
38
|
+
const listening = new Set();
|
|
39
|
+
/**
|
|
40
|
+
* Closes an overlay on Escape or on a pointer press outside it.
|
|
41
|
+
*
|
|
42
|
+
* Both, because they are different intentions with the same meaning: Escape is
|
|
43
|
+
* the keyboard's way out and clicking away is the pointer's, and an overlay
|
|
44
|
+
* that honours only one is one half its readers cannot dismiss.
|
|
45
|
+
*
|
|
46
|
+
* The outside press is watched on `pointerdown` rather than `click`, so a press
|
|
47
|
+
* that starts outside and drags across the overlay still counts as leaving
|
|
48
|
+
* rather than as interacting.
|
|
49
|
+
*
|
|
50
|
+
* A press inside a frame is the third case, and it needs its own signal. The
|
|
51
|
+
* frame swallows the event, so the document that owns the overlay never sees a
|
|
52
|
+
* `pointerdown` at all, and where the frame is cross-origin there is nothing to
|
|
53
|
+
* listen to inside it either. What is observable is that the window lost focus
|
|
54
|
+
* and a frame on this page gained it, which is exactly a press the document
|
|
55
|
+
* cannot see. Leaving the browser altogether blurs the window too, so what took
|
|
56
|
+
* focus is the part that distinguishes them.
|
|
57
|
+
*/
|
|
58
|
+
export class DismissController {
|
|
59
|
+
constructor(host, { boundary, onDismiss }) {
|
|
60
|
+
this.active = false;
|
|
61
|
+
this.handleKeydown = (event) => {
|
|
62
|
+
if (event.key !== 'Escape') {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
/* One overlay per press, decided by asking all of them rather than by
|
|
66
|
+
* comparing against whoever ran before.
|
|
67
|
+
*
|
|
68
|
+
* `stopPropagation` cannot arrange this: every open overlay registers this
|
|
69
|
+
* same listener on `document`, so they are siblings on one node where the
|
|
70
|
+
* only ordering is registration, and propagation is about ancestors.
|
|
71
|
+
*
|
|
72
|
+
* Carrying the winning depth on the event does not arrange it either, which
|
|
73
|
+
* is the version this replaced. A handler that runs before any deeper one
|
|
74
|
+
* has no way to know a deeper one exists, so it dismisses and only then
|
|
75
|
+
* loses the comparison. Registration order is the order the overlays
|
|
76
|
+
* *opened*, and opening the outer one first is the ordinary case: a dialog,
|
|
77
|
+
* then a select inside it. Measured, that closed both.
|
|
78
|
+
*
|
|
79
|
+
* So the first handler to see the press takes it for everybody, and picks
|
|
80
|
+
* the winner from every controller currently listening, which is a question
|
|
81
|
+
* that has one answer whenever it is asked. */
|
|
82
|
+
const taken = event;
|
|
83
|
+
if (taken.helixDismissTaken) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
taken.helixDismissTaken = true;
|
|
87
|
+
/* Ties go to whoever asked, which is the first to run. Two overlays neither
|
|
88
|
+
of which is inside the other have no better answer available, and exactly
|
|
89
|
+
one closing is the point. */
|
|
90
|
+
let deepest = this;
|
|
91
|
+
let found = deepDepth(this.host);
|
|
92
|
+
for (const other of listening) {
|
|
93
|
+
const depth = deepDepth(other.host);
|
|
94
|
+
if (depth > found) {
|
|
95
|
+
found = depth;
|
|
96
|
+
deepest = other;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
deepest.onDismiss();
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Focus left the document. If a frame took it, that was a press.
|
|
103
|
+
*
|
|
104
|
+
* Deferred by a task, because the order of `blur` against the focus moving is
|
|
105
|
+
* not something every browser agrees on and reading `activeElement` too early
|
|
106
|
+
* gives the element that is losing focus rather than the one taking it. The
|
|
107
|
+
* guard is for the case where the overlay closed for another reason in
|
|
108
|
+
* between, which would otherwise dismiss whatever opened next.
|
|
109
|
+
*/
|
|
110
|
+
this.handleWindowBlur = () => {
|
|
111
|
+
setTimeout(() => {
|
|
112
|
+
if (this.active && deepActiveElement()?.tagName === 'IFRAME') {
|
|
113
|
+
this.onDismiss();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
this.handlePointerDown = (event) => {
|
|
118
|
+
// `composedPath` rather than `target`, so a press inside the overlay's own
|
|
119
|
+
// shadow root is still recognized as inside it.
|
|
120
|
+
/* More than one thing can be inside.
|
|
121
|
+
*
|
|
122
|
+
* A control is not always one element: a popover opened by an invoker
|
|
123
|
+
* somewhere else on the page has that invoker as part of it, and a press on
|
|
124
|
+
* it is not a press outside. Treating it as outside closed the panel on
|
|
125
|
+
* `pointerdown` and the invoker's own `--toggle` reopened it on the `click`
|
|
126
|
+
* that followed, so the panel could never be closed by the button that
|
|
127
|
+
* opened it. */
|
|
128
|
+
const inside = this.boundary?.() ?? this.host;
|
|
129
|
+
const boundaries = (Array.isArray(inside) ? inside : [inside]).filter(Boolean);
|
|
130
|
+
const path = event.composedPath();
|
|
131
|
+
if (!boundaries.some((element) => path.includes(element))) {
|
|
132
|
+
this.onDismiss();
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
this.host = host;
|
|
136
|
+
this.boundary = boundary;
|
|
137
|
+
this.onDismiss = onDismiss;
|
|
138
|
+
host.addController(this);
|
|
139
|
+
}
|
|
140
|
+
/** Starts listening. */
|
|
141
|
+
activate() {
|
|
142
|
+
if (this.active) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this.active = true;
|
|
146
|
+
listening.add(this);
|
|
147
|
+
document.addEventListener('keydown', this.handleKeydown);
|
|
148
|
+
document.addEventListener('pointerdown', this.handlePointerDown);
|
|
149
|
+
window.addEventListener('blur', this.handleWindowBlur);
|
|
150
|
+
}
|
|
151
|
+
/** Stops listening. */
|
|
152
|
+
deactivate() {
|
|
153
|
+
if (!this.active) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.active = false;
|
|
157
|
+
listening.delete(this);
|
|
158
|
+
document.removeEventListener('keydown', this.handleKeydown);
|
|
159
|
+
document.removeEventListener('pointerdown', this.handlePointerDown);
|
|
160
|
+
window.removeEventListener('blur', this.handleWindowBlur);
|
|
161
|
+
}
|
|
162
|
+
hostDisconnected() {
|
|
163
|
+
this.deactivate();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
2
|
+
/** How the controller is told what it is drilling through. */
|
|
3
|
+
export interface DrilldownOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Whether an element counts as a row.
|
|
6
|
+
*
|
|
7
|
+
* Defaults to any element. A container holding things other than rows says so
|
|
8
|
+
* here, rather than the controller guessing from a tag name it cannot know.
|
|
9
|
+
*/
|
|
10
|
+
isRow?: (element: Element) => boolean;
|
|
11
|
+
/** The rows of the top page, in the order a reader moves through them. */
|
|
12
|
+
root: () => Element[];
|
|
13
|
+
/**
|
|
14
|
+
* The slot a row's own page is assigned to. Defaults to `page`.
|
|
15
|
+
*
|
|
16
|
+
* The nesting is the structure: a row that opens a page *contains* that
|
|
17
|
+
* page's rows. There are no ids to wire and nothing to keep in step, and a
|
|
18
|
+
* consumer who moves a row in the markup has moved its page with it.
|
|
19
|
+
*/
|
|
20
|
+
slot?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* One page of a nested set showing at a time, with a way back.
|
|
24
|
+
*
|
|
25
|
+
* A drill-down replaces the panel rather than opening a second one beside it.
|
|
26
|
+
* That is the right shape wherever there is no room for two: on a phone there
|
|
27
|
+
* is no second column, and on a touch screen there is no hover to open one
|
|
28
|
+
* with. What a reader gets instead is one panel, a title saying where they are,
|
|
29
|
+
* and a way back.
|
|
30
|
+
*
|
|
31
|
+
* **Extracted from `Menu`, which had all of this and did not say so.** The menu
|
|
32
|
+
* held a `page`, derived the rows on it, read a title off the row that opened
|
|
33
|
+
* it, and reset on close; the sandbox's settings dropdown has been a drill-down
|
|
34
|
+
* for as long as it has existed. None of that is about menus. Taking it out is
|
|
35
|
+
* what lets a settings panel, a filter stack or a navigation tree have the same
|
|
36
|
+
* behavior without one of them being a menu that is pretending.
|
|
37
|
+
*
|
|
38
|
+
* **The tree is the state.** A row's page is its own children, assigned to a
|
|
39
|
+
* slot, so the controller holds one reference, the row currently drilled into,
|
|
40
|
+
* and derives everything else from the DOM. The alternative, an id per page and
|
|
41
|
+
* a map beside it, is a second copy of the structure that a consumer can put
|
|
42
|
+
* out of step with the first.
|
|
43
|
+
*
|
|
44
|
+
* **A stack, not a pointer.** `back()` returns to the page above rather than to
|
|
45
|
+
* the root, because a reader three levels down pressed one control and expects
|
|
46
|
+
* one level. The stack is rebuilt from the DOM by walking up from the current
|
|
47
|
+
* row, so it cannot disagree with the tree: a page removed while it was showing
|
|
48
|
+
* takes its ancestors with it, and `atRoot` becomes true rather than the
|
|
49
|
+
* controller holding a reference to something detached.
|
|
50
|
+
*
|
|
51
|
+
* Nothing here decides what a row *is*, what it renders, or what happens when
|
|
52
|
+
* one is chosen. It answers where the reader is and what is on this page.
|
|
53
|
+
*/
|
|
54
|
+
export declare class DrilldownController<Row extends Element = Element> implements ReactiveController {
|
|
55
|
+
private readonly host;
|
|
56
|
+
private readonly options;
|
|
57
|
+
/** The row whose page is showing, or nothing at the top. */
|
|
58
|
+
private current;
|
|
59
|
+
constructor(host: ReactiveControllerHost, options: DrilldownOptions);
|
|
60
|
+
/**
|
|
61
|
+
* The row whose page is showing, once the tree has been asked about it.
|
|
62
|
+
*
|
|
63
|
+
* **A held reference can outlive the row it names, and the walk alone does
|
|
64
|
+
* not catch it.** Removing the row a reader is *on* works: its
|
|
65
|
+
* `parentElement` is null and the walk stops. Removing one of its
|
|
66
|
+
* *ancestors* does not, because the subtree comes away intact, so the child
|
|
67
|
+
* still has a parent and the parent still has a parent. Measured on a
|
|
68
|
+
* three-level menu with the middle page removed: `depth` answered 2, `rows`
|
|
69
|
+
* listed a page nobody could see, and `back()` walked to a second detached
|
|
70
|
+
* row before reaching the top.
|
|
71
|
+
*
|
|
72
|
+
* That is the disagreement with the tree the walk exists to prevent, so the
|
|
73
|
+
* question is asked here, once, and every reader below goes through it.
|
|
74
|
+
*/
|
|
75
|
+
private get here();
|
|
76
|
+
/** The row whose page is showing, or `null` at the top. */
|
|
77
|
+
get page(): Row | null;
|
|
78
|
+
/** Whether the top page is the one showing. */
|
|
79
|
+
get atRoot(): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* How deep the reader is, with the top page at zero.
|
|
82
|
+
*
|
|
83
|
+
* Counted by walking up rather than by keeping a number, for the reason the
|
|
84
|
+
* stack is: a count can be wrong about a tree, and the tree is what renders.
|
|
85
|
+
*/
|
|
86
|
+
get depth(): number;
|
|
87
|
+
/**
|
|
88
|
+
* What the page showing is called.
|
|
89
|
+
*
|
|
90
|
+
* The row's own text and not its subtree, because a row that opens a page
|
|
91
|
+
* contains that page's rows: `textContent` would read back the whole thing.
|
|
92
|
+
* Empty at the top, where the panel's own label is the name of the place.
|
|
93
|
+
*/
|
|
94
|
+
get label(): string;
|
|
95
|
+
/** The rows on the page showing, which is what the keyboard moves over. */
|
|
96
|
+
get rows(): Row[];
|
|
97
|
+
/** Whether a row has a page to drill into. */
|
|
98
|
+
hasPage(row: Element): boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Shows a row's page in place of the one showing.
|
|
101
|
+
*
|
|
102
|
+
* Refuses a row with no page rather than drilling into an empty one, which
|
|
103
|
+
* would leave a reader on a blank panel with a back control as its only
|
|
104
|
+
* content and nothing saying why.
|
|
105
|
+
*/
|
|
106
|
+
enter(row: Row): boolean;
|
|
107
|
+
/** Returns to the page above, and reports whether there was one. */
|
|
108
|
+
back(): boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Returns to the top page.
|
|
111
|
+
*
|
|
112
|
+
* For closing rather than for navigating: a panel that reopens where it was
|
|
113
|
+
* left is showing a reader a place they have no memory of choosing.
|
|
114
|
+
*/
|
|
115
|
+
reset(): void;
|
|
116
|
+
/**
|
|
117
|
+
* The row that opened the page this row is on.
|
|
118
|
+
*
|
|
119
|
+
* Walks up two steps, because a row sits inside the row that opened it: its
|
|
120
|
+
* parent element *is* that row. Read from the DOM each time rather than
|
|
121
|
+
* stored, so a tree the consumer has changed cannot leave this pointing at
|
|
122
|
+
* something no longer in it.
|
|
123
|
+
*/
|
|
124
|
+
private parentOf;
|
|
125
|
+
/** Back to the top when the host goes away, so it reopens where it opened. */
|
|
126
|
+
hostDisconnected(): void;
|
|
127
|
+
}
|