@fusion.dev/helix-core 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/custom-elements.json +4 -4
- package/dist/controllers/aria-forward-controller/aria-forward-controller.js +0 -105
- package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +0 -71
- package/dist/controllers/command-controller/command-controller.js +0 -62
- package/dist/controllers/copy-controller/copy-controller.js +0 -63
- package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.js +0 -40
- package/dist/controllers/dismiss-controller/dismiss-controller.js +0 -124
- package/dist/controllers/drilldown-controller/drilldown-controller.js +0 -87
- package/dist/controllers/floating-controller/floating-controller.js +0 -152
- package/dist/controllers/focus-trap-controller/focus-trap-controller.js +0 -35
- package/dist/controllers/indicator-controller/indicator-controller.js +0 -82
- package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.js +0 -46
- package/dist/controllers/popover-controller/popover-controller.js +0 -72
- package/dist/controllers/source-controller/source-controller.js +0 -38
- package/dist/controllers/token-color-controller/token-color-controller.js +0 -51
- package/dist/controllers/virtualize-controller/virtualize-controller.js +0 -128
- package/dist/events/element-events.js +0 -22
- package/dist/events/events.js +0 -386
- package/dist/helix-element/helix-element.d.ts +10 -17
- package/dist/helix-element/helix-element.js +0 -505
- package/dist/helix-form-element/helix-form-element.js +0 -140
- package/dist/mixins/mixin-chart-source/mixin-chart-source.js +0 -77
- package/dist/mixins/mixin-collection/mixin-collection.js +0 -83
- package/dist/mixins/mixin-controller-active/mixin-controller-active.js +0 -82
- package/dist/mixins/mixin-controller-checked/mixin-controller-checked.js +0 -78
- package/dist/mixins/mixin-controller-open/mixin-controller-open.js +0 -93
- package/dist/mixins/mixin-controller-value/mixin-controller-value.js +0 -87
- package/dist/mixins/mixin-dismissible/mixin-dismissible.js +0 -38
- package/dist/mixins/mixin-field/mixin-field.js +0 -277
- package/dist/utils/anchor-element/anchor-element.js +0 -23
- package/dist/utils/capture-pointer/capture-pointer.js +0 -18
- package/dist/utils/closest-instance/closest-instance.js +0 -20
- package/dist/utils/create-sheet/create-sheet.js +0 -14
- package/dist/utils/custom-element/custom-element.js +0 -83
- package/dist/utils/draw-icon/draw-icon.js +0 -28
- package/dist/utils/draw-tooltip/draw-tooltip.js +1 -50
- package/dist/utils/elapsed-time/elapsed-time.js +0 -51
- package/dist/utils/focusable-elements/focusable-elements.js +1 -59
- package/dist/utils/format-numbers/format-numbers.js +0 -16
- package/dist/utils/host-aria/host-aria.js +0 -26
- package/dist/utils/inherit-disabled/inherit-disabled.js +0 -36
- package/dist/utils/line-path/line-path.js +0 -36
- package/dist/utils/on-unless-false/on-unless-false.js +0 -23
- package/dist/utils/read-series/read-series.js +0 -71
- package/dist/utils/relative-time/relative-time.js +0 -43
- package/dist/utils/resolve-active-item/resolve-active-item.js +0 -20
- package/dist/utils/restore-value/restore-value.js +0 -54
- package/dist/utils/scale/scale.js +0 -19
- package/dist/utils/scale/ticks.js +0 -36
- package/dist/utils/scroll-item-into-view/scroll-item-into-view.js +0 -53
- package/dist/utils/slot-filled/slot-filled.js +0 -23
- package/dist/utils/tell-disclosure/tell-disclosure.js +0 -53
- package/dist/utils/ticker/ticker.js +0 -22
- package/dist/utils/tone-marks/tone-marks.js +0 -24
- package/dist/utils/value-as/value-as.js +0 -56
- package/package.json +5 -5
|
@@ -1,22 +1,4 @@
|
|
|
1
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
2
|
const deepDepth = (node) => {
|
|
21
3
|
let depth = 0;
|
|
22
4
|
let at = node;
|
|
@@ -26,48 +8,7 @@ const deepDepth = (node) => {
|
|
|
26
8
|
}
|
|
27
9
|
return depth;
|
|
28
10
|
};
|
|
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
11
|
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 honors 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.
|
|
53
|
-
*
|
|
54
|
-
* Where the frame is same-origin there is a document to listen to, and this
|
|
55
|
-
* listens to it: the same handler, on every reachable frame document, so a
|
|
56
|
-
* press in there arrives as a press whose path holds nothing of the overlay,
|
|
57
|
-
* which is what it is.
|
|
58
|
-
*
|
|
59
|
-
* **The focus signal alone was not enough, and a browser difference is why.**
|
|
60
|
-
* A press in a frame moves focus into it in Chromium, so the window blurs and a
|
|
61
|
-
* frame is what took focus. WebKit does neither: focus stays on the menu item
|
|
62
|
-
* the reader pressed to open the overlay, the window never blurs, and every
|
|
63
|
-
* observable thing about that tap says nothing happened. Measured on the
|
|
64
|
-
* sandbox, whose preview is a frame filling half the tool, the settings menu
|
|
65
|
-
* could not be dismissed by pressing the component it configures.
|
|
66
|
-
*
|
|
67
|
-
* Both signals are kept, because they answer different frames. The listener
|
|
68
|
-
* only reaches same-origin ones; the blur only fires where the browser moves
|
|
69
|
-
* focus. A cross-origin frame in Chromium has the second and not the first.
|
|
70
|
-
*/
|
|
71
12
|
export class DismissController {
|
|
72
13
|
constructor(host, { boundary, onDismiss }) {
|
|
73
14
|
this.active = false;
|
|
@@ -75,31 +16,11 @@ export class DismissController {
|
|
|
75
16
|
if (event.key !== 'Escape') {
|
|
76
17
|
return;
|
|
77
18
|
}
|
|
78
|
-
/* One overlay per press, decided by asking all of them rather than by
|
|
79
|
-
* comparing against whoever ran before.
|
|
80
|
-
*
|
|
81
|
-
* `stopPropagation` cannot arrange this: every open overlay registers this
|
|
82
|
-
* same listener on `document`, so they are siblings on one node where the
|
|
83
|
-
* only ordering is registration, and propagation is about ancestors.
|
|
84
|
-
*
|
|
85
|
-
* Carrying the winning depth on the event does not arrange it either. A
|
|
86
|
-
* handler that runs before any deeper one has no way to know a deeper one
|
|
87
|
-
* exists, so it dismisses and only then loses the comparison. Registration
|
|
88
|
-
* order is the order the overlays
|
|
89
|
-
* *opened*, and opening the outer one first is the ordinary case: a dialog,
|
|
90
|
-
* then a select inside it. Measured, that closed both.
|
|
91
|
-
*
|
|
92
|
-
* So the first handler to see the press takes it for everybody, and picks
|
|
93
|
-
* the winner from every controller currently listening, which is a question
|
|
94
|
-
* that has one answer whenever it is asked. */
|
|
95
19
|
const taken = event;
|
|
96
20
|
if (taken.helixDismissTaken) {
|
|
97
21
|
return;
|
|
98
22
|
}
|
|
99
23
|
taken.helixDismissTaken = true;
|
|
100
|
-
/* Ties go to whoever asked, which is the first to run. Two overlays neither
|
|
101
|
-
of which is inside the other have no better answer available, and exactly
|
|
102
|
-
one closing is the point. */
|
|
103
24
|
let deepest = this;
|
|
104
25
|
let found = deepDepth(this.host);
|
|
105
26
|
for (const other of listening) {
|
|
@@ -111,15 +32,6 @@ export class DismissController {
|
|
|
111
32
|
}
|
|
112
33
|
deepest.onDismiss();
|
|
113
34
|
};
|
|
114
|
-
/**
|
|
115
|
-
* Focus left the document. If a frame took it, that was a press.
|
|
116
|
-
*
|
|
117
|
-
* Deferred by a task, because the order of `blur` against the focus moving is
|
|
118
|
-
* not something every browser agrees on and reading `activeElement` too early
|
|
119
|
-
* gives the element that is losing focus rather than the one taking it. The
|
|
120
|
-
* guard is for the case where the overlay closed for another reason in
|
|
121
|
-
* between, which would otherwise dismiss whatever opened next.
|
|
122
|
-
*/
|
|
123
35
|
this.handleWindowBlur = () => {
|
|
124
36
|
setTimeout(() => {
|
|
125
37
|
if (this.active && deepActiveElement()?.tagName === 'IFRAME') {
|
|
@@ -128,16 +40,6 @@ export class DismissController {
|
|
|
128
40
|
});
|
|
129
41
|
};
|
|
130
42
|
this.handlePointerDown = (event) => {
|
|
131
|
-
// `composedPath` rather than `target`, so a press inside the overlay's own
|
|
132
|
-
// shadow root is still recognized as inside it.
|
|
133
|
-
/* More than one thing can be inside.
|
|
134
|
-
*
|
|
135
|
-
* A control is not always one element: a popover opened by an invoker
|
|
136
|
-
* somewhere else on the page has that invoker as part of it, and a press on
|
|
137
|
-
* it is not a press outside. Treating it as outside closed the panel on
|
|
138
|
-
* `pointerdown` and the invoker's own `--toggle` reopened it on the `click`
|
|
139
|
-
* that followed, so the panel could never be closed by the button that
|
|
140
|
-
* opened it. */
|
|
141
43
|
const inside = this.boundary?.() ?? this.host;
|
|
142
44
|
const boundaries = (Array.isArray(inside) ? inside : [inside]).filter(Boolean);
|
|
143
45
|
const path = event.composedPath();
|
|
@@ -145,35 +47,12 @@ export class DismissController {
|
|
|
145
47
|
this.onDismiss();
|
|
146
48
|
}
|
|
147
49
|
};
|
|
148
|
-
/**
|
|
149
|
-
* The frame documents being listened to, held so the same ones are released.
|
|
150
|
-
*
|
|
151
|
-
* Gathered when the overlay opens rather than watched for: an overlay is open
|
|
152
|
-
* for as long as a reader is deciding something, and a frame that arrives
|
|
153
|
-
* inside that window is a page rebuilding itself under a menu, which is not a
|
|
154
|
-
* case worth a `MutationObserver` on every open surface. Held rather than
|
|
155
|
-
* re-collected on the way out, because a frame removed while the overlay was
|
|
156
|
-
* open would otherwise be missed and one navigated elsewhere would be a
|
|
157
|
-
* different document.
|
|
158
|
-
*/
|
|
159
50
|
this.frames = [];
|
|
160
51
|
this.host = host;
|
|
161
52
|
this.boundary = boundary;
|
|
162
53
|
this.onDismiss = onDismiss;
|
|
163
54
|
host.addController(this);
|
|
164
55
|
}
|
|
165
|
-
/**
|
|
166
|
-
* Every frame document on the page this one is allowed to read.
|
|
167
|
-
*
|
|
168
|
-
* Through the shadow roots as well, because a component that renders a frame
|
|
169
|
-
* renders it inside itself: the sandbox's preview is one, and
|
|
170
|
-
* `document.querySelectorAll` cannot see it.
|
|
171
|
-
*
|
|
172
|
-
* A cross-origin frame throws on `contentDocument` rather than returning
|
|
173
|
-
* null in some browsers, so the read is guarded and the frame is simply
|
|
174
|
-
* skipped. Nothing is lost by skipping it that was not already lost: there is
|
|
175
|
-
* no listener to add to a document this one may not touch.
|
|
176
|
-
*/
|
|
177
56
|
static frameDocuments() {
|
|
178
57
|
const found = [];
|
|
179
58
|
const walk = (root) => {
|
|
@@ -186,7 +65,6 @@ export class DismissController {
|
|
|
186
65
|
}
|
|
187
66
|
}
|
|
188
67
|
catch {
|
|
189
|
-
// Cross-origin. The blur signal is what covers those.
|
|
190
68
|
}
|
|
191
69
|
}
|
|
192
70
|
if (element.shadowRoot) {
|
|
@@ -197,7 +75,6 @@ export class DismissController {
|
|
|
197
75
|
walk(document);
|
|
198
76
|
return found;
|
|
199
77
|
}
|
|
200
|
-
/** Starts listening. */
|
|
201
78
|
activate() {
|
|
202
79
|
if (this.active) {
|
|
203
80
|
return;
|
|
@@ -212,7 +89,6 @@ export class DismissController {
|
|
|
212
89
|
inner.addEventListener('pointerdown', this.handlePointerDown);
|
|
213
90
|
}
|
|
214
91
|
}
|
|
215
|
-
/** Stops listening. */
|
|
216
92
|
deactivate() {
|
|
217
93
|
if (!this.active) {
|
|
218
94
|
return;
|
|
@@ -1,72 +1,19 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One page of a nested set showing at a time, with a way back.
|
|
3
|
-
*
|
|
4
|
-
* A drill-down replaces the panel rather than opening a second one beside it.
|
|
5
|
-
* That is the right shape wherever there is no room for two: on a phone there
|
|
6
|
-
* is no second column, and on a touch screen there is no hover to open one
|
|
7
|
-
* with. What a reader gets instead is one panel, a title saying where they are,
|
|
8
|
-
* and a way back.
|
|
9
|
-
*
|
|
10
|
-
* **None of this is about menus.** A settings panel, a filter stack or a
|
|
11
|
-
* navigation tree gets the same behavior without being a menu that is
|
|
12
|
-
* pretending.
|
|
13
|
-
*
|
|
14
|
-
* **The tree is the state.** A row's page is its own children, assigned to a
|
|
15
|
-
* slot, so the controller holds one reference, the row currently drilled into,
|
|
16
|
-
* and derives everything else from the DOM. The alternative, an id per page and
|
|
17
|
-
* a map beside it, is a second copy of the structure that a consumer can put
|
|
18
|
-
* out of step with the first.
|
|
19
|
-
*
|
|
20
|
-
* **A stack, not a pointer.** `back()` returns to the page above rather than to
|
|
21
|
-
* the root, because a reader three levels down pressed one control and expects
|
|
22
|
-
* one level. The stack is rebuilt from the DOM by walking up from the current
|
|
23
|
-
* row, so it cannot disagree with the tree: a page removed while it was showing
|
|
24
|
-
* takes its ancestors with it, and `atRoot` becomes true rather than the
|
|
25
|
-
* controller holding a reference to something detached.
|
|
26
|
-
*
|
|
27
|
-
* Nothing here decides what a row *is*, what it renders, or what happens when
|
|
28
|
-
* one is chosen. It answers where the reader is and what is on this page.
|
|
29
|
-
*/
|
|
30
1
|
export class DrilldownController {
|
|
31
2
|
constructor(host, options) {
|
|
32
|
-
/** The row whose page is showing, or nothing at the top. */
|
|
33
3
|
this.current = null;
|
|
34
4
|
this.host = host;
|
|
35
5
|
this.options = options;
|
|
36
6
|
host.addController(this);
|
|
37
7
|
}
|
|
38
|
-
/**
|
|
39
|
-
* The row whose page is showing, once the tree has been asked about it.
|
|
40
|
-
*
|
|
41
|
-
* **A held reference can outlive the row it names, and the walk alone does
|
|
42
|
-
* not catch it.** Removing the row a reader is *on* works: its
|
|
43
|
-
* `parentElement` is null and the walk stops. Removing one of its
|
|
44
|
-
* *ancestors* does not, because the subtree comes away intact, so the child
|
|
45
|
-
* still has a parent and the parent still has a parent. Measured on a
|
|
46
|
-
* three-level menu with the middle page removed: `depth` answered 2, `rows`
|
|
47
|
-
* listed a page nobody could see, and `back()` walked to a second detached
|
|
48
|
-
* row before reaching the top.
|
|
49
|
-
*
|
|
50
|
-
* That is the disagreement with the tree the walk exists to prevent, so the
|
|
51
|
-
* question is asked here, once, and every reader below goes through it.
|
|
52
|
-
*/
|
|
53
8
|
get here() {
|
|
54
9
|
return this.current?.isConnected ? this.current : null;
|
|
55
10
|
}
|
|
56
|
-
/** The row whose page is showing, or `null` at the top. */
|
|
57
11
|
get page() {
|
|
58
12
|
return this.here;
|
|
59
13
|
}
|
|
60
|
-
/** Whether the top page is the one showing. */
|
|
61
14
|
get atRoot() {
|
|
62
15
|
return this.here === null;
|
|
63
16
|
}
|
|
64
|
-
/**
|
|
65
|
-
* How deep the reader is, with the top page at zero.
|
|
66
|
-
*
|
|
67
|
-
* Counted by walking up rather than by keeping a number, for the reason the
|
|
68
|
-
* stack is: a count can be wrong about a tree, and the tree is what renders.
|
|
69
|
-
*/
|
|
70
17
|
get depth() {
|
|
71
18
|
let depth = 0;
|
|
72
19
|
for (let row = this.here; row; row = this.parentOf(row)) {
|
|
@@ -74,13 +21,6 @@ export class DrilldownController {
|
|
|
74
21
|
}
|
|
75
22
|
return depth;
|
|
76
23
|
}
|
|
77
|
-
/**
|
|
78
|
-
* What the page showing is called.
|
|
79
|
-
*
|
|
80
|
-
* The row's own text and not its subtree, because a row that opens a page
|
|
81
|
-
* contains that page's rows: `textContent` would read back the whole thing.
|
|
82
|
-
* Empty at the top, where the panel's own label is the name of the place.
|
|
83
|
-
*/
|
|
84
24
|
get label() {
|
|
85
25
|
return [...(this.here?.childNodes ?? [])]
|
|
86
26
|
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
|
@@ -88,7 +28,6 @@ export class DrilldownController {
|
|
|
88
28
|
.filter(Boolean)
|
|
89
29
|
.join(' ');
|
|
90
30
|
}
|
|
91
|
-
/** The rows on the page showing, which is what the keyboard moves over. */
|
|
92
31
|
get rows() {
|
|
93
32
|
const slot = this.options.slot ?? 'page';
|
|
94
33
|
const isRow = this.options.isRow ?? (() => true);
|
|
@@ -97,18 +36,10 @@ export class DrilldownController {
|
|
|
97
36
|
: this.options.root();
|
|
98
37
|
return source.filter(isRow);
|
|
99
38
|
}
|
|
100
|
-
/** Whether a row has a page to drill into. */
|
|
101
39
|
hasPage(row) {
|
|
102
40
|
const slot = this.options.slot ?? 'page';
|
|
103
41
|
return [...row.children].some((child) => child.slot === slot);
|
|
104
42
|
}
|
|
105
|
-
/**
|
|
106
|
-
* Shows a row's page in place of the one showing.
|
|
107
|
-
*
|
|
108
|
-
* Refuses a row with no page rather than drilling into an empty one, which
|
|
109
|
-
* would leave a reader on a blank panel with a back control as its only
|
|
110
|
-
* content and nothing saying why.
|
|
111
|
-
*/
|
|
112
43
|
enter(row) {
|
|
113
44
|
if (!this.hasPage(row)) {
|
|
114
45
|
return false;
|
|
@@ -117,12 +48,9 @@ export class DrilldownController {
|
|
|
117
48
|
this.host.requestUpdate();
|
|
118
49
|
return true;
|
|
119
50
|
}
|
|
120
|
-
/** Returns to the page above, and reports whether there was one. */
|
|
121
51
|
back() {
|
|
122
52
|
const from = this.here;
|
|
123
53
|
if (from === null) {
|
|
124
|
-
/* Which includes a reader whose page has been taken out from under them:
|
|
125
|
-
they are already at the top, whatever this was still holding. */
|
|
126
54
|
this.current = null;
|
|
127
55
|
return false;
|
|
128
56
|
}
|
|
@@ -130,12 +58,6 @@ export class DrilldownController {
|
|
|
130
58
|
this.host.requestUpdate();
|
|
131
59
|
return true;
|
|
132
60
|
}
|
|
133
|
-
/**
|
|
134
|
-
* Returns to the top page.
|
|
135
|
-
*
|
|
136
|
-
* For closing rather than for navigating: a panel that reopens where it was
|
|
137
|
-
* left is showing a reader a place they have no memory of choosing.
|
|
138
|
-
*/
|
|
139
61
|
reset() {
|
|
140
62
|
if (this.current === null) {
|
|
141
63
|
return;
|
|
@@ -143,14 +65,6 @@ export class DrilldownController {
|
|
|
143
65
|
this.current = null;
|
|
144
66
|
this.host.requestUpdate();
|
|
145
67
|
}
|
|
146
|
-
/**
|
|
147
|
-
* The row that opened the page this row is on.
|
|
148
|
-
*
|
|
149
|
-
* Walks up two steps, because a row sits inside the row that opened it: its
|
|
150
|
-
* parent element *is* that row. Read from the DOM each time rather than
|
|
151
|
-
* stored, so a tree the consumer has changed cannot leave this pointing at
|
|
152
|
-
* something no longer in it.
|
|
153
|
-
*/
|
|
154
68
|
parentOf(row) {
|
|
155
69
|
const slot = this.options.slot ?? 'page';
|
|
156
70
|
if (row.slot !== slot) {
|
|
@@ -161,7 +75,6 @@ export class DrilldownController {
|
|
|
161
75
|
? parent
|
|
162
76
|
: null;
|
|
163
77
|
}
|
|
164
|
-
/** Back to the top when the host goes away, so it reopens where it opened. */
|
|
165
78
|
hostDisconnected() {
|
|
166
79
|
this.current = null;
|
|
167
80
|
}
|
|
@@ -1,30 +1,4 @@
|
|
|
1
1
|
import { arrow, autoUpdate, computePosition, flip, offset, shift, size, } from '@floating-ui/dom';
|
|
2
|
-
/**
|
|
3
|
-
* The panel's largest corner radius, which is how far its arrow has to stay
|
|
4
|
-
* from either end.
|
|
5
|
-
*
|
|
6
|
-
* **Without it the arrow is correctly aimed and looks wrong.** Floating UI
|
|
7
|
-
* centers the arrow on the anchor, and on a small trigger that center is inside
|
|
8
|
-
* the panel's own corner: the arrow's base meets the curve instead of a straight
|
|
9
|
-
* run and its outer flank is cut by it. Measured against a 4px radius and an 8px
|
|
10
|
-
* arrow, a 14px trigger put the arrow's near flank 1.5px from the panel's edge
|
|
11
|
-
* and a 20px trigger put it at 4.3px, so anything narrower than the arrow plus
|
|
12
|
-
* two radii is broken by construction. An icon beside a heading is the ordinary
|
|
13
|
-
* way to be under that.
|
|
14
|
-
*
|
|
15
|
-
* `padding` is what Floating UI's `arrow` middleware takes for exactly this, and
|
|
16
|
-
* its own guidance for a rounded surface is the border radius.
|
|
17
|
-
*
|
|
18
|
-
* **Measured rather than named**, so a family that rounds itself differently is
|
|
19
|
-
* right without telling this controller. Reading a token here would put a value
|
|
20
|
-
* from `helix/styles` into a tier that must not name one, and would still be
|
|
21
|
-
* wrong for any component that overrides its own radius. The four corners rather
|
|
22
|
-
* than one, because they can differ and the arrow travels along two of them; the
|
|
23
|
-
* largest clears all of them.
|
|
24
|
-
*
|
|
25
|
-
* A percentage is refused rather than guessed at: it resolves against the box,
|
|
26
|
-
* which is the thing being positioned.
|
|
27
|
-
*/
|
|
28
2
|
const corner = (floating) => {
|
|
29
3
|
const style = getComputedStyle(floating);
|
|
30
4
|
const corners = [
|
|
@@ -45,56 +19,10 @@ const corner = (floating) => {
|
|
|
45
19
|
}
|
|
46
20
|
return largest;
|
|
47
21
|
};
|
|
48
|
-
/**
|
|
49
|
-
* Places one element against another, and keeps it there.
|
|
50
|
-
*
|
|
51
|
-
* Positioning is `@floating-ui/dom` rather than arithmetic written here. Two
|
|
52
|
-
* things in particular are why. A `position: fixed` element inside an ancestor
|
|
53
|
-
* carrying `transform`, `filter`, `will-change`, or `contain` is positioned
|
|
54
|
-
* against that ancestor and not the viewport, so viewport coordinates land it
|
|
55
|
-
* somewhere else entirely. And an overlay has to clear its nearest *clipping*
|
|
56
|
-
* ancestor, not just the window, or it is placed correctly inside a scrolling
|
|
57
|
-
* panel and clipped out of sight by it. Both are ordinary here, and both are a
|
|
58
|
-
* long tail of browser behavior rather than a formula.
|
|
59
|
-
*
|
|
60
|
-
* `autoUpdate` keeps the position through scrolling on any ancestor, resizes of
|
|
61
|
-
* either element, and layout shifts.
|
|
62
|
-
*
|
|
63
|
-
* The side the element actually landed on is written back as `data-placement`,
|
|
64
|
-
* so a styled tier can animate from it rather than from the side that was
|
|
65
|
-
* asked for.
|
|
66
|
-
*
|
|
67
|
-
* **The custom properties are declared here because this is what writes them.**
|
|
68
|
-
* Each carries a measurement taken against the viewport that no stylesheet can
|
|
69
|
-
* take for itself, and `core` ships no CSS, so there is no reader inside this
|
|
70
|
-
* package and none of them is private. the `hooks` rule pairs a documented
|
|
71
|
-
* hook with a rule that reads one, in both directions.
|
|
72
|
-
*
|
|
73
|
-
* @cssproperty --floating-anchor-inline-size - How wide the anchor is, for a panel that matches it.
|
|
74
|
-
* @cssproperty --floating-arrow-x - Where the pointer meets the anchor, along the inline axis.
|
|
75
|
-
* @cssproperty --floating-arrow-y - Where the pointer meets the anchor, along the block axis.
|
|
76
|
-
* @cssproperty --floating-available-block-size - How much room is left below, for a panel that scrolls.
|
|
77
|
-
*/
|
|
78
22
|
export class FloatingController {
|
|
79
23
|
constructor(host, options) {
|
|
80
|
-
/**
|
|
81
|
-
* Which run of placements the results coming back belong to.
|
|
82
|
-
*
|
|
83
|
-
* `computePosition` is async, so a measurement started before `stop()` still
|
|
84
|
-
* resolves after it and would write its result onto a surface that had just
|
|
85
|
-
* been taken down: the inline `position` and `translate` back on, and with
|
|
86
|
-
* them `data-placed`, which is the attribute five families gate visibility
|
|
87
|
-
* on. Opening and closing inside one task is all it takes, since the write
|
|
88
|
-
* lands a microtask later.
|
|
89
|
-
*
|
|
90
|
-
* A counter rather than an `AbortController`, because there is nothing to
|
|
91
|
-
* abort: the work is already done by the time the answer arrives and the only
|
|
92
|
-
* question is whether anybody still wants it.
|
|
93
|
-
*/
|
|
94
24
|
this.run = 0;
|
|
95
|
-
/** The placement in use after the last measurement. */
|
|
96
25
|
this.placement = 'bottom-start';
|
|
97
|
-
/** Measures both elements and writes the position. */
|
|
98
26
|
this.update = () => {
|
|
99
27
|
const anchor = this.options.anchor();
|
|
100
28
|
const floating = this.options.floating();
|
|
@@ -117,22 +45,8 @@ export class FloatingController {
|
|
|
117
45
|
}
|
|
118
46
|
if (this.options.resize !== false) {
|
|
119
47
|
middleware.push(size({
|
|
120
|
-
/* The height and not the width, which is asymmetric on purpose. A
|
|
121
|
-
panel that runs out of room grows a scrollbar downwards and every
|
|
122
|
-
rule reading this asks for a `max-block-size`; nothing asks for the
|
|
123
|
-
inline twin, so writing it would be a `setProperty` on every
|
|
124
|
-
reposition of every floating element for nobody. Declared and never
|
|
125
|
-
read is a promise on the API page with nothing behind it. It comes
|
|
126
|
-
back the day a rule wants it, together with that rule. */
|
|
127
48
|
apply: ({ availableHeight, elements, rects }) => {
|
|
128
49
|
elements.floating.style.setProperty('--floating-available-block-size', `${Math.round(availableHeight)}px`);
|
|
129
|
-
/* The anchor's width, which a panel that has to match the control
|
|
130
|
-
it hangs off cannot get any other way. `min-inline-size: 100%`
|
|
131
|
-
reads the containing block, and this positions with
|
|
132
|
-
`position: fixed`, so that percentage is the viewport: a select's
|
|
133
|
-
list came out the full width of the window, flush to its left
|
|
134
|
-
edge. It looked like a positioning fault and was a percentage
|
|
135
|
-
resolving against something the CSS could not see. */
|
|
136
50
|
elements.floating.style.setProperty('--floating-anchor-inline-size', `${Math.round(rects.reference.width)}px`);
|
|
137
51
|
},
|
|
138
52
|
padding: 8,
|
|
@@ -144,14 +58,8 @@ export class FloatingController {
|
|
|
144
58
|
computePosition(anchor, floating, {
|
|
145
59
|
middleware,
|
|
146
60
|
placement: preferred ?? 'bottom-start',
|
|
147
|
-
// `fixed` so the overlay clears an ancestor with `overflow: hidden`.
|
|
148
|
-
// Floating UI resolves the containing block either way, so this is about
|
|
149
|
-
// clipping rather than about which coordinates are correct.
|
|
150
61
|
strategy: 'fixed',
|
|
151
62
|
}).then(({ middlewareData, placement, x, y }) => {
|
|
152
|
-
/* Stopped while this was being measured, so the answer is about a surface
|
|
153
|
-
nobody is showing any more. Writing it would re-mark a closed panel as
|
|
154
|
-
placed. */
|
|
155
63
|
if (run !== this.run) {
|
|
156
64
|
return;
|
|
157
65
|
}
|
|
@@ -160,48 +68,9 @@ export class FloatingController {
|
|
|
160
68
|
insetBlockStart: '0',
|
|
161
69
|
insetInlineStart: '0',
|
|
162
70
|
position: 'fixed',
|
|
163
|
-
// A translate rather than insets, so the position never lands on a
|
|
164
|
-
// subpixel boundary and blurs the text inside it.
|
|
165
|
-
//
|
|
166
|
-
// **The `translate` property, not `transform`.** They are not
|
|
167
|
-
// interchangeable here. The individual properties compose ahead of
|
|
168
|
-
// `transform`, so a panel that also sets `scale` for an entrance ends
|
|
169
|
-
// up with `scale() translate()`, and a concatenation applies its right
|
|
170
|
-
// hand side first: the position is computed and *then* multiplied by
|
|
171
|
-
// the scale factor. A tooltip 600px down the page scaling from 0.9
|
|
172
|
-
// therefore opens 60px out of place and slides into position, and one
|
|
173
|
-
// scaling from 0 flies in from the viewport corner. As `translate` it
|
|
174
|
-
// composes as `translate() scale()`, the offset is applied in the
|
|
175
|
-
// parent's coordinates, and the scale cannot reach it.
|
|
176
|
-
/* **Written as viewport coordinates, which is what they are.**
|
|
177
|
-
*
|
|
178
|
-
* `computePosition` returns a position in the viewport and this element
|
|
179
|
-
* is `position: fixed`, so the two agree and the translate is the answer
|
|
180
|
-
* unmodified. **Never subtract an ancestor's origin from it.**
|
|
181
|
-
*
|
|
182
|
-
* A surface landing hundreds of pixels from its anchor looks like an
|
|
183
|
-
* ancestor stealing the containing block, which `transform`, `filter`
|
|
184
|
-
* and `backdrop-filter` all do to a `fixed` descendant. Check the tier
|
|
185
|
-
* first: the UA stylesheet centers a `popover` through `inset: 0` and
|
|
186
|
-
* `margin: auto`, and the headless tooltip ships no styles to overrule
|
|
187
|
-
* it, so it sits mid-viewport with a zeroed translate while `Tooltip`,
|
|
188
|
-
* which sets `margin: 0`, never does. A correction added for that
|
|
189
|
-
* subtracts a real ancestor's origin from an already correct number and
|
|
190
|
-
* throws every tooltip on the page off by it. */
|
|
191
71
|
translate: `${Math.round(x)}px ${Math.round(y)}px`,
|
|
192
72
|
});
|
|
193
73
|
floating.setAttribute('data-placement', placement);
|
|
194
|
-
/* Placed, and it is a separate fact from shown. `computePosition` is
|
|
195
|
-
async, so between the render that reveals a panel and the microtask
|
|
196
|
-
that positions it there is a frame where the panel is visible at
|
|
197
|
-
whatever its stylesheet said, which for an absolutely positioned one
|
|
198
|
-
with no offsets is its host's top-left. Measured on the select: the
|
|
199
|
-
panel appeared 24px up and to the left of the trigger for one frame on
|
|
200
|
-
the first open, and looked correct on every open after, because the
|
|
201
|
-
inline styles from last time were still on it.
|
|
202
|
-
|
|
203
|
-
A panel that wants to fade in on placement selects on this. Nothing
|
|
204
|
-
has to. */
|
|
205
74
|
floating.setAttribute('data-placed', '');
|
|
206
75
|
const point = middlewareData.arrow;
|
|
207
76
|
if (pointer && point) {
|
|
@@ -209,32 +78,14 @@ export class FloatingController {
|
|
|
209
78
|
insetBlockStart: point.y === undefined ? '' : `${point.y}px`,
|
|
210
79
|
insetInlineStart: point.x === undefined ? '' : `${point.x}px`,
|
|
211
80
|
});
|
|
212
|
-
/* Where the pointer ended up, as the center of it, published for
|
|
213
|
-
anything that has to grow out of the thing it points at.
|
|
214
|
-
*
|
|
215
|
-
* A panel scaling in wants its `transform-origin` on its own arrow, and
|
|
216
|
-
* the arrow is not reliably where the placement says: `top-start` puts
|
|
217
|
-
* it near one end, and `shift` slides it anywhere along the edge to
|
|
218
|
-
* keep the panel on screen. Selecting on `data-placement` alone gets
|
|
219
|
-
* the axis right and the position wrong, which reads as the panel
|
|
220
|
-
* growing out of a point next to the trigger rather than out of it.
|
|
221
|
-
*
|
|
222
|
-
* Only the axis the arrow travels on is written. On a `top` placement
|
|
223
|
-
* the arrow moves horizontally and its vertical position is the panel's
|
|
224
|
-
* edge, so `--floating-arrow-y` is left unset and a consumer's fallback
|
|
225
|
-
* stands. Cleared rather than zeroed when absent, so the fallback in
|
|
226
|
-
* `var()` is what applies. */
|
|
227
81
|
floating.style.setProperty('--floating-arrow-x', point.x === undefined ? '' : `${Math.round(point.x + pointer.offsetWidth / 2)}px`);
|
|
228
82
|
floating.style.setProperty('--floating-arrow-y', point.y === undefined ? '' : `${Math.round(point.y + pointer.offsetHeight / 2)}px`);
|
|
229
83
|
}
|
|
230
84
|
});
|
|
231
85
|
};
|
|
232
86
|
this.options = options;
|
|
233
|
-
// Registered so the host's disconnect releases the tracking, which is the
|
|
234
|
-
// whole reason this is a controller rather than a function.
|
|
235
87
|
host.addController(this);
|
|
236
88
|
}
|
|
237
|
-
/** Starts placing, and keeps placing until stopped. */
|
|
238
89
|
start() {
|
|
239
90
|
const anchor = this.options.anchor();
|
|
240
91
|
const floating = this.options.floating();
|
|
@@ -243,10 +94,7 @@ export class FloatingController {
|
|
|
243
94
|
}
|
|
244
95
|
this.cleanup = autoUpdate(anchor, floating, this.update);
|
|
245
96
|
}
|
|
246
|
-
/** Stops placing and releases everything it was tracking. */
|
|
247
97
|
stop() {
|
|
248
|
-
// Before the teardown, so a measurement already in flight is disowned
|
|
249
|
-
// rather than racing the attribute removal below.
|
|
250
98
|
this.run += 1;
|
|
251
99
|
this.cleanup?.();
|
|
252
100
|
this.cleanup = undefined;
|
|
@@ -1,16 +1,4 @@
|
|
|
1
1
|
import { deepActiveElement, deepContains, focusableElements, } from '../../utils/focusable-elements/focusable-elements.js';
|
|
2
|
-
/**
|
|
3
|
-
* Keeps Tab inside a container while it is open, and gives focus back after.
|
|
4
|
-
*
|
|
5
|
-
* A dialog that does not trap focus is one a keyboard user tabs straight out
|
|
6
|
-
* of, into a page they cannot see and cannot get back from. Restoring focus on
|
|
7
|
-
* release matters just as much: without it, closing a drawer drops the reader
|
|
8
|
-
* at the top of the document.
|
|
9
|
-
*
|
|
10
|
-
* Finding the focusable elements is `focusableElements`, which is a utility
|
|
11
|
-
* rather than part of this, because that half is what roving tabindex and
|
|
12
|
-
* arrow-key navigation need and neither of them traps anything.
|
|
13
|
-
*/
|
|
14
2
|
export class FocusTrapController {
|
|
15
3
|
constructor(host) {
|
|
16
4
|
this.container = null;
|
|
@@ -21,25 +9,12 @@ export class FocusTrapController {
|
|
|
21
9
|
}
|
|
22
10
|
const items = focusableElements(this.container);
|
|
23
11
|
if (items.length === 0) {
|
|
24
|
-
// Nothing to move to, so keep focus where it is rather than letting Tab
|
|
25
|
-
// escape into the page behind.
|
|
26
12
|
event.preventDefault();
|
|
27
13
|
return;
|
|
28
14
|
}
|
|
29
15
|
const first = items[0];
|
|
30
16
|
const last = items.at(-1);
|
|
31
17
|
const active = deepActiveElement();
|
|
32
|
-
/* Which item holds focus, rather than which item *is* focus.
|
|
33
|
-
*
|
|
34
|
-
* `focusableElements` returns hosts: a `helix-button` delegates focus, so
|
|
35
|
-
* the list holds the component and `deepActiveElement` returns the
|
|
36
|
-
* `<button>` in its shadow root. They are never equal, and the container
|
|
37
|
-
* never `contains` the second, so an identity check reads "outside the
|
|
38
|
-
* trap" on every element: against the two branches below, shift-Tab jumps
|
|
39
|
-
* to the last item from anywhere and forward Tab never wraps at all.
|
|
40
|
-
*
|
|
41
|
-
* An index also gives the outside-entirely case for free: -1 wraps to
|
|
42
|
-
* whichever end the reader was heading for. */
|
|
43
18
|
const at = items.findIndex((item) => item === active || deepContains(item, active));
|
|
44
19
|
if (event.shiftKey) {
|
|
45
20
|
if (at <= 0) {
|
|
@@ -54,26 +29,16 @@ export class FocusTrapController {
|
|
|
54
29
|
};
|
|
55
30
|
host.addController(this);
|
|
56
31
|
}
|
|
57
|
-
/** Starts trapping, remembering where focus came from. */
|
|
58
32
|
activate(container) {
|
|
59
33
|
this.container = container;
|
|
60
34
|
this.previous = deepActiveElement();
|
|
61
35
|
document.addEventListener('keydown', this.handleKeydown, true);
|
|
62
|
-
/* After render, so the container has something to focus, and *held*, so a
|
|
63
|
-
release before the frame runs can cancel it.
|
|
64
|
-
*
|
|
65
|
-
Unheld, an overlay opened and closed inside one frame pulls focus into
|
|
66
|
-
itself a frame after it has gone: the reader is looking at the page, the
|
|
67
|
-
surface is not on it, and their next key goes somewhere they cannot see.
|
|
68
|
-
A menu that closes on the same activation that opened it does exactly
|
|
69
|
-
this, and so does any consumer toggling `open` twice in one task. */
|
|
70
36
|
this.frame = requestAnimationFrame(() => {
|
|
71
37
|
this.frame = undefined;
|
|
72
38
|
const items = focusableElements(container);
|
|
73
39
|
(items[0] ?? container).focus();
|
|
74
40
|
});
|
|
75
41
|
}
|
|
76
|
-
/** Stops trapping and returns focus to wherever it was. */
|
|
77
42
|
deactivate() {
|
|
78
43
|
if (this.frame !== undefined) {
|
|
79
44
|
cancelAnimationFrame(this.frame);
|