@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.
Files changed (115) hide show
  1. package/LICENSE +21 -0
  2. package/custom-elements.json +8005 -0
  3. package/dist/controllers/aria-forward-controller/aria-forward-controller.d.ts +67 -0
  4. package/dist/controllers/aria-forward-controller/aria-forward-controller.js +146 -0
  5. package/dist/controllers/chart-pointer-controller/chart-pointer-controller.d.ts +79 -0
  6. package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +140 -0
  7. package/dist/controllers/command-controller/command-controller.d.ts +83 -0
  8. package/dist/controllers/command-controller/command-controller.js +93 -0
  9. package/dist/controllers/copy-controller/copy-controller.d.ts +58 -0
  10. package/dist/controllers/copy-controller/copy-controller.js +128 -0
  11. package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.d.ts +76 -0
  12. package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.js +67 -0
  13. package/dist/controllers/dismiss-controller/dismiss-controller.d.ts +58 -0
  14. package/dist/controllers/dismiss-controller/dismiss-controller.js +165 -0
  15. package/dist/controllers/drilldown-controller/drilldown-controller.d.ts +127 -0
  16. package/dist/controllers/drilldown-controller/drilldown-controller.js +171 -0
  17. package/dist/controllers/floating-controller/floating-controller.d.ts +123 -0
  18. package/dist/controllers/floating-controller/floating-controller.js +227 -0
  19. package/dist/controllers/focus-trap-controller/focus-trap-controller.d.ts +26 -0
  20. package/dist/controllers/focus-trap-controller/focus-trap-controller.js +93 -0
  21. package/dist/controllers/indicator-controller/indicator-controller.d.ts +97 -0
  22. package/dist/controllers/indicator-controller/indicator-controller.js +164 -0
  23. package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.d.ts +113 -0
  24. package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.js +117 -0
  25. package/dist/controllers/popover-controller/popover-controller.d.ts +132 -0
  26. package/dist/controllers/popover-controller/popover-controller.js +150 -0
  27. package/dist/controllers/source-controller/source-controller.d.ts +55 -0
  28. package/dist/controllers/source-controller/source-controller.js +94 -0
  29. package/dist/controllers/token-color-controller/token-color-controller.d.ts +63 -0
  30. package/dist/controllers/token-color-controller/token-color-controller.js +105 -0
  31. package/dist/controllers/virtualize-controller/virtualize-controller.d.ts +127 -0
  32. package/dist/controllers/virtualize-controller/virtualize-controller.js +216 -0
  33. package/dist/events/element-events.d.ts +4 -0
  34. package/dist/events/element-events.js +34 -0
  35. package/dist/events/events.d.ts +743 -0
  36. package/dist/events/events.js +799 -0
  37. package/dist/helix-element/helix-element.d.ts +201 -0
  38. package/dist/helix-element/helix-element.js +695 -0
  39. package/dist/helix-form-element/helix-form-element.d.ts +152 -0
  40. package/dist/helix-form-element/helix-form-element.js +194 -0
  41. package/dist/index.d.ts +54 -0
  42. package/dist/index.js +54 -0
  43. package/dist/mixins/mixin-chart-source/mixin-chart-source.d.ts +137 -0
  44. package/dist/mixins/mixin-chart-source/mixin-chart-source.js +140 -0
  45. package/dist/mixins/mixin-collection/index.d.ts +1 -0
  46. package/dist/mixins/mixin-collection/index.js +1 -0
  47. package/dist/mixins/mixin-collection/mixin-collection.d.ts +58 -0
  48. package/dist/mixins/mixin-collection/mixin-collection.js +154 -0
  49. package/dist/mixins/mixin-controller-active/mixin-controller-active.d.ts +53 -0
  50. package/dist/mixins/mixin-controller-active/mixin-controller-active.js +155 -0
  51. package/dist/mixins/mixin-controller-checked/mixin-controller-checked.d.ts +54 -0
  52. package/dist/mixins/mixin-controller-checked/mixin-controller-checked.js +157 -0
  53. package/dist/mixins/mixin-controller-open/mixin-controller-open.d.ts +49 -0
  54. package/dist/mixins/mixin-controller-open/mixin-controller-open.js +176 -0
  55. package/dist/mixins/mixin-controller-value/mixin-controller-value.d.ts +73 -0
  56. package/dist/mixins/mixin-controller-value/mixin-controller-value.js +173 -0
  57. package/dist/mixins/mixin-dismissible/mixin-dismissible.d.ts +41 -0
  58. package/dist/mixins/mixin-dismissible/mixin-dismissible.js +79 -0
  59. package/dist/mixins/mixin-field/mixin-field.d.ts +145 -0
  60. package/dist/mixins/mixin-field/mixin-field.js +423 -0
  61. package/dist/utils/anchor-element/anchor-element.d.ts +23 -0
  62. package/dist/utils/anchor-element/anchor-element.js +39 -0
  63. package/dist/utils/capture-pointer/capture-pointer.d.ts +22 -0
  64. package/dist/utils/capture-pointer/capture-pointer.js +30 -0
  65. package/dist/utils/closest-instance/closest-instance.d.ts +25 -0
  66. package/dist/utils/closest-instance/closest-instance.js +34 -0
  67. package/dist/utils/create-sheet/create-sheet.d.ts +16 -0
  68. package/dist/utils/create-sheet/create-sheet.js +16 -0
  69. package/dist/utils/custom-element/custom-element.d.ts +26 -0
  70. package/dist/utils/custom-element/custom-element.js +114 -0
  71. package/dist/utils/draw-icon/draw-icon.d.ts +38 -0
  72. package/dist/utils/draw-icon/draw-icon.js +39 -0
  73. package/dist/utils/draw-tooltip/draw-tooltip.d.ts +56 -0
  74. package/dist/utils/draw-tooltip/draw-tooltip.js +89 -0
  75. package/dist/utils/elapsed-time/elapsed-time.d.ts +10 -0
  76. package/dist/utils/elapsed-time/elapsed-time.js +117 -0
  77. package/dist/utils/focusable-elements/focusable-elements.d.ts +40 -0
  78. package/dist/utils/focusable-elements/focusable-elements.js +88 -0
  79. package/dist/utils/format-numbers/format-numbers.d.ts +1 -0
  80. package/dist/utils/format-numbers/format-numbers.js +29 -0
  81. package/dist/utils/host-aria/host-aria.d.ts +38 -0
  82. package/dist/utils/host-aria/host-aria.js +41 -0
  83. package/dist/utils/inherit-disabled/inherit-disabled.d.ts +33 -0
  84. package/dist/utils/inherit-disabled/inherit-disabled.js +55 -0
  85. package/dist/utils/line-path/line-path.d.ts +23 -0
  86. package/dist/utils/line-path/line-path.js +107 -0
  87. package/dist/utils/on-unless-false/on-unless-false.d.ts +28 -0
  88. package/dist/utils/on-unless-false/on-unless-false.js +32 -0
  89. package/dist/utils/read-series/read-series.d.ts +78 -0
  90. package/dist/utils/read-series/read-series.js +187 -0
  91. package/dist/utils/relative-time/relative-time.d.ts +24 -0
  92. package/dist/utils/relative-time/relative-time.js +84 -0
  93. package/dist/utils/request-change/request-change.d.ts +48 -0
  94. package/dist/utils/request-change/request-change.js +7 -0
  95. package/dist/utils/resolve-active-item/resolve-active-item.d.ts +28 -0
  96. package/dist/utils/resolve-active-item/resolve-active-item.js +21 -0
  97. package/dist/utils/restore-value/restore-value.d.ts +32 -0
  98. package/dist/utils/restore-value/restore-value.js +84 -0
  99. package/dist/utils/scale/scale.d.ts +23 -0
  100. package/dist/utils/scale/scale.js +32 -0
  101. package/dist/utils/scale/ticks.d.ts +27 -0
  102. package/dist/utils/scale/ticks.js +52 -0
  103. package/dist/utils/scroll-item-into-view/scroll-item-into-view.d.ts +10 -0
  104. package/dist/utils/scroll-item-into-view/scroll-item-into-view.js +98 -0
  105. package/dist/utils/slot-filled/slot-filled.d.ts +26 -0
  106. package/dist/utils/slot-filled/slot-filled.js +26 -0
  107. package/dist/utils/tell-disclosure/tell-disclosure.d.ts +51 -0
  108. package/dist/utils/tell-disclosure/tell-disclosure.js +76 -0
  109. package/dist/utils/ticker/ticker.d.ts +8 -0
  110. package/dist/utils/ticker/ticker.js +55 -0
  111. package/dist/utils/tone-marks/tone-marks.d.ts +52 -0
  112. package/dist/utils/tone-marks/tone-marks.js +36 -0
  113. package/dist/utils/value-as/value-as.d.ts +64 -0
  114. package/dist/utils/value-as/value-as.js +105 -0
  115. 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
+ }