@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,171 @@
|
|
|
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
|
+
* **Extracted from `Menu`, which had all of this and did not say so.** The menu
|
|
11
|
+
* held a `page`, derived the rows on it, read a title off the row that opened
|
|
12
|
+
* it, and reset on close; the sandbox's settings dropdown has been a drill-down
|
|
13
|
+
* for as long as it has existed. None of that is about menus. Taking it out is
|
|
14
|
+
* what lets a settings panel, a filter stack or a navigation tree have the same
|
|
15
|
+
* behavior without one of them being a menu that is pretending.
|
|
16
|
+
*
|
|
17
|
+
* **The tree is the state.** A row's page is its own children, assigned to a
|
|
18
|
+
* slot, so the controller holds one reference, the row currently drilled into,
|
|
19
|
+
* and derives everything else from the DOM. The alternative, an id per page and
|
|
20
|
+
* a map beside it, is a second copy of the structure that a consumer can put
|
|
21
|
+
* out of step with the first.
|
|
22
|
+
*
|
|
23
|
+
* **A stack, not a pointer.** `back()` returns to the page above rather than to
|
|
24
|
+
* the root, because a reader three levels down pressed one control and expects
|
|
25
|
+
* one level. The stack is rebuilt from the DOM by walking up from the current
|
|
26
|
+
* row, so it cannot disagree with the tree: a page removed while it was showing
|
|
27
|
+
* takes its ancestors with it, and `atRoot` becomes true rather than the
|
|
28
|
+
* controller holding a reference to something detached.
|
|
29
|
+
*
|
|
30
|
+
* Nothing here decides what a row *is*, what it renders, or what happens when
|
|
31
|
+
* one is chosen. It answers where the reader is and what is on this page.
|
|
32
|
+
*/
|
|
33
|
+
export class DrilldownController {
|
|
34
|
+
constructor(host, options) {
|
|
35
|
+
/** The row whose page is showing, or nothing at the top. */
|
|
36
|
+
this.current = null;
|
|
37
|
+
this.host = host;
|
|
38
|
+
this.options = options;
|
|
39
|
+
host.addController(this);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The row whose page is showing, once the tree has been asked about it.
|
|
43
|
+
*
|
|
44
|
+
* **A held reference can outlive the row it names, and the walk alone does
|
|
45
|
+
* not catch it.** Removing the row a reader is *on* works: its
|
|
46
|
+
* `parentElement` is null and the walk stops. Removing one of its
|
|
47
|
+
* *ancestors* does not, because the subtree comes away intact, so the child
|
|
48
|
+
* still has a parent and the parent still has a parent. Measured on a
|
|
49
|
+
* three-level menu with the middle page removed: `depth` answered 2, `rows`
|
|
50
|
+
* listed a page nobody could see, and `back()` walked to a second detached
|
|
51
|
+
* row before reaching the top.
|
|
52
|
+
*
|
|
53
|
+
* That is the disagreement with the tree the walk exists to prevent, so the
|
|
54
|
+
* question is asked here, once, and every reader below goes through it.
|
|
55
|
+
*/
|
|
56
|
+
get here() {
|
|
57
|
+
return this.current?.isConnected ? this.current : null;
|
|
58
|
+
}
|
|
59
|
+
/** The row whose page is showing, or `null` at the top. */
|
|
60
|
+
get page() {
|
|
61
|
+
return this.here;
|
|
62
|
+
}
|
|
63
|
+
/** Whether the top page is the one showing. */
|
|
64
|
+
get atRoot() {
|
|
65
|
+
return this.here === null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* How deep the reader is, with the top page at zero.
|
|
69
|
+
*
|
|
70
|
+
* Counted by walking up rather than by keeping a number, for the reason the
|
|
71
|
+
* stack is: a count can be wrong about a tree, and the tree is what renders.
|
|
72
|
+
*/
|
|
73
|
+
get depth() {
|
|
74
|
+
let depth = 0;
|
|
75
|
+
for (let row = this.here; row; row = this.parentOf(row)) {
|
|
76
|
+
depth += 1;
|
|
77
|
+
}
|
|
78
|
+
return depth;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* What the page showing is called.
|
|
82
|
+
*
|
|
83
|
+
* The row's own text and not its subtree, because a row that opens a page
|
|
84
|
+
* contains that page's rows: `textContent` would read back the whole thing.
|
|
85
|
+
* Empty at the top, where the panel's own label is the name of the place.
|
|
86
|
+
*/
|
|
87
|
+
get label() {
|
|
88
|
+
return [...(this.here?.childNodes ?? [])]
|
|
89
|
+
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
|
90
|
+
.map((node) => node.textContent?.trim() ?? '')
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.join(' ');
|
|
93
|
+
}
|
|
94
|
+
/** The rows on the page showing, which is what the keyboard moves over. */
|
|
95
|
+
get rows() {
|
|
96
|
+
const slot = this.options.slot ?? 'page';
|
|
97
|
+
const isRow = this.options.isRow ?? (() => true);
|
|
98
|
+
const source = this.here
|
|
99
|
+
? [...this.here.children].filter((child) => child.slot === slot)
|
|
100
|
+
: this.options.root();
|
|
101
|
+
return source.filter(isRow);
|
|
102
|
+
}
|
|
103
|
+
/** Whether a row has a page to drill into. */
|
|
104
|
+
hasPage(row) {
|
|
105
|
+
const slot = this.options.slot ?? 'page';
|
|
106
|
+
return [...row.children].some((child) => child.slot === slot);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Shows a row's page in place of the one showing.
|
|
110
|
+
*
|
|
111
|
+
* Refuses a row with no page rather than drilling into an empty one, which
|
|
112
|
+
* would leave a reader on a blank panel with a back control as its only
|
|
113
|
+
* content and nothing saying why.
|
|
114
|
+
*/
|
|
115
|
+
enter(row) {
|
|
116
|
+
if (!this.hasPage(row)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
this.current = row;
|
|
120
|
+
this.host.requestUpdate();
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
/** Returns to the page above, and reports whether there was one. */
|
|
124
|
+
back() {
|
|
125
|
+
const from = this.here;
|
|
126
|
+
if (from === null) {
|
|
127
|
+
/* Which includes a reader whose page has been taken out from under them:
|
|
128
|
+
they are already at the top, whatever this was still holding. */
|
|
129
|
+
this.current = null;
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
this.current = this.parentOf(from);
|
|
133
|
+
this.host.requestUpdate();
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Returns to the top page.
|
|
138
|
+
*
|
|
139
|
+
* For closing rather than for navigating: a panel that reopens where it was
|
|
140
|
+
* left is showing a reader a place they have no memory of choosing.
|
|
141
|
+
*/
|
|
142
|
+
reset() {
|
|
143
|
+
if (this.current === null) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.current = null;
|
|
147
|
+
this.host.requestUpdate();
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The row that opened the page this row is on.
|
|
151
|
+
*
|
|
152
|
+
* Walks up two steps, because a row sits inside the row that opened it: its
|
|
153
|
+
* parent element *is* that row. Read from the DOM each time rather than
|
|
154
|
+
* stored, so a tree the consumer has changed cannot leave this pointing at
|
|
155
|
+
* something no longer in it.
|
|
156
|
+
*/
|
|
157
|
+
parentOf(row) {
|
|
158
|
+
const slot = this.options.slot ?? 'page';
|
|
159
|
+
if (row.slot !== slot) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const parent = row.parentElement;
|
|
163
|
+
return parent && (this.options.isRow ?? (() => true))(parent)
|
|
164
|
+
? parent
|
|
165
|
+
: null;
|
|
166
|
+
}
|
|
167
|
+
/** Back to the top when the host goes away, so it reopens where it opened. */
|
|
168
|
+
hostDisconnected() {
|
|
169
|
+
this.current = null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { type Placement } from '@floating-ui/dom';
|
|
2
|
+
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
3
|
+
export type { Placement };
|
|
4
|
+
export interface FloatingOptions {
|
|
5
|
+
/** The element being pointed at. */
|
|
6
|
+
anchor: () => HTMLElement | null | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* An element to point at the anchor, positioned against whichever side the
|
|
9
|
+
* floating element landed on.
|
|
10
|
+
*/
|
|
11
|
+
arrow?: () => HTMLElement | null | undefined;
|
|
12
|
+
/** The element being placed. */
|
|
13
|
+
floating: () => HTMLElement | null | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Whether to try another side when the preferred one does not fit.
|
|
16
|
+
*
|
|
17
|
+
* On by default. A menu that opens off the bottom of the window is one the
|
|
18
|
+
* reader cannot use.
|
|
19
|
+
*/
|
|
20
|
+
flip?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Space between the anchor and the floating element, in pixels.
|
|
23
|
+
*
|
|
24
|
+
* A function where it can change. Options are read at construction, and a
|
|
25
|
+
* host's own properties are still at their class defaults then, before any
|
|
26
|
+
* attribute has been parsed.
|
|
27
|
+
*/
|
|
28
|
+
offset?: number | (() => number);
|
|
29
|
+
/**
|
|
30
|
+
* Preferred placement. Defaults to `bottom-start`.
|
|
31
|
+
*
|
|
32
|
+
* A function where it can change. Passing the host's property directly reads
|
|
33
|
+
* it at construction, when it is still the class default, so every instance
|
|
34
|
+
* gets the same placement whatever its markup said.
|
|
35
|
+
*/
|
|
36
|
+
placement?: Placement | (() => Placement);
|
|
37
|
+
/**
|
|
38
|
+
* Whether to cap the floating element to the space available.
|
|
39
|
+
*
|
|
40
|
+
* On by default, written as `--floating-available-block-size` and
|
|
41
|
+
* `--floating-available-width` rather than as a height. A list decides for
|
|
42
|
+
* itself whether to scroll or to shrink; a controller that sets the height
|
|
43
|
+
* has decided that for every element it places.
|
|
44
|
+
*/
|
|
45
|
+
resize?: boolean;
|
|
46
|
+
/** Whether to slide along the side to stay in view. On by default. */
|
|
47
|
+
shift?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* A box to keep the surface inside, instead of the viewport.
|
|
50
|
+
*
|
|
51
|
+
* Applied to `shift` and to nothing else, which is the whole of the design.
|
|
52
|
+
* `shift` slides the surface along the axis it is *not* placed on, so for a
|
|
53
|
+
* bubble above its anchor this clamps x and leaves y alone, and a chart can
|
|
54
|
+
* say "stay within my own width" without also saying "stay within my own
|
|
55
|
+
* height", which would jam the bubble inside the drawing.
|
|
56
|
+
*
|
|
57
|
+
* Giving the same box to `flip` or `size` says the second thing. A tooltip
|
|
58
|
+
* above a reading almost never fits above it *within the chart*, so `flip`
|
|
59
|
+
* would send every one of them below the point, and `size` would compute an
|
|
60
|
+
* available height of nearly nothing and squash the box. Both were tried.
|
|
61
|
+
*/
|
|
62
|
+
within?: () => Element | null | undefined;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Places one element against another, and keeps it there.
|
|
66
|
+
*
|
|
67
|
+
* Positioning is `@floating-ui/dom` rather than arithmetic written here. Two
|
|
68
|
+
* things in particular are why. A `position: fixed` element inside an ancestor
|
|
69
|
+
* carrying `transform`, `filter`, `will-change`, or `contain` is positioned
|
|
70
|
+
* against that ancestor and not the viewport, so viewport coordinates land it
|
|
71
|
+
* somewhere else entirely. And an overlay has to clear its nearest *clipping*
|
|
72
|
+
* ancestor, not just the window, or it is placed correctly inside a scrolling
|
|
73
|
+
* panel and clipped out of sight by it. Both are ordinary here, and both are a
|
|
74
|
+
* long tail of browser behavior rather than a formula.
|
|
75
|
+
*
|
|
76
|
+
* `autoUpdate` keeps the position through scrolling on any ancestor, resizes of
|
|
77
|
+
* either element, and layout shifts.
|
|
78
|
+
*
|
|
79
|
+
* The side the element actually landed on is written back as `data-placement`,
|
|
80
|
+
* so a styled tier can animate from it rather than from the side that was
|
|
81
|
+
* asked for.
|
|
82
|
+
*
|
|
83
|
+
* **The custom properties are declared here because this is what writes them.**
|
|
84
|
+
* Each carries a measurement taken against the viewport that no stylesheet can
|
|
85
|
+
* take for itself, and `core` ships no CSS, so there is no reader inside this
|
|
86
|
+
* package and none of them is private. `check:hooks` pairs a `@cssproperty`
|
|
87
|
+
* with a rule that reads one, in both directions, and the underscore these used
|
|
88
|
+
* to carry was what exempted them from it.
|
|
89
|
+
*
|
|
90
|
+
* @cssproperty --floating-anchor-inline-size - How wide the anchor is, for a panel that matches it.
|
|
91
|
+
* @cssproperty --floating-arrow-x - Where the pointer meets the anchor, along the inline axis.
|
|
92
|
+
* @cssproperty --floating-arrow-y - Where the pointer meets the anchor, along the block axis.
|
|
93
|
+
* @cssproperty --floating-available-block-size - How much room is left below, for a panel that scrolls.
|
|
94
|
+
*/
|
|
95
|
+
export declare class FloatingController implements ReactiveController {
|
|
96
|
+
private readonly options;
|
|
97
|
+
private cleanup;
|
|
98
|
+
/**
|
|
99
|
+
* Which run of placements the results coming back belong to.
|
|
100
|
+
*
|
|
101
|
+
* `computePosition` is async, so a measurement started before `stop()` still
|
|
102
|
+
* resolves after it and wrote its result onto a surface that had just been
|
|
103
|
+
* taken down: the inline `position` and `translate` went back on, and so did
|
|
104
|
+
* `data-placed`, which is the attribute five families gate visibility on.
|
|
105
|
+
* Opening and closing inside one task is all it takes, since the write lands
|
|
106
|
+
* a microtask later.
|
|
107
|
+
*
|
|
108
|
+
* A counter rather than an `AbortController`, because there is nothing to
|
|
109
|
+
* abort: the work is already done by the time the answer arrives and the only
|
|
110
|
+
* question is whether anybody still wants it.
|
|
111
|
+
*/
|
|
112
|
+
private run;
|
|
113
|
+
constructor(host: ReactiveControllerHost & HTMLElement, options: FloatingOptions);
|
|
114
|
+
/** The placement in use after the last measurement. */
|
|
115
|
+
placement: Placement;
|
|
116
|
+
/** Starts placing, and keeps placing until stopped. */
|
|
117
|
+
start(): void;
|
|
118
|
+
/** Stops placing and releases everything it was tracking. */
|
|
119
|
+
stop(): void;
|
|
120
|
+
/** Measures both elements and writes the position. */
|
|
121
|
+
readonly update: () => void;
|
|
122
|
+
hostDisconnected(): void;
|
|
123
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { arrow, autoUpdate, computePosition, flip, offset, shift, size, } from '@floating-ui/dom';
|
|
2
|
+
/**
|
|
3
|
+
* Places one element against another, and keeps it there.
|
|
4
|
+
*
|
|
5
|
+
* Positioning is `@floating-ui/dom` rather than arithmetic written here. Two
|
|
6
|
+
* things in particular are why. A `position: fixed` element inside an ancestor
|
|
7
|
+
* carrying `transform`, `filter`, `will-change`, or `contain` is positioned
|
|
8
|
+
* against that ancestor and not the viewport, so viewport coordinates land it
|
|
9
|
+
* somewhere else entirely. And an overlay has to clear its nearest *clipping*
|
|
10
|
+
* ancestor, not just the window, or it is placed correctly inside a scrolling
|
|
11
|
+
* panel and clipped out of sight by it. Both are ordinary here, and both are a
|
|
12
|
+
* long tail of browser behavior rather than a formula.
|
|
13
|
+
*
|
|
14
|
+
* `autoUpdate` keeps the position through scrolling on any ancestor, resizes of
|
|
15
|
+
* either element, and layout shifts.
|
|
16
|
+
*
|
|
17
|
+
* The side the element actually landed on is written back as `data-placement`,
|
|
18
|
+
* so a styled tier can animate from it rather than from the side that was
|
|
19
|
+
* asked for.
|
|
20
|
+
*
|
|
21
|
+
* **The custom properties are declared here because this is what writes them.**
|
|
22
|
+
* Each carries a measurement taken against the viewport that no stylesheet can
|
|
23
|
+
* take for itself, and `core` ships no CSS, so there is no reader inside this
|
|
24
|
+
* package and none of them is private. `check:hooks` pairs a `@cssproperty`
|
|
25
|
+
* with a rule that reads one, in both directions, and the underscore these used
|
|
26
|
+
* to carry was what exempted them from it.
|
|
27
|
+
*
|
|
28
|
+
* @cssproperty --floating-anchor-inline-size - How wide the anchor is, for a panel that matches it.
|
|
29
|
+
* @cssproperty --floating-arrow-x - Where the pointer meets the anchor, along the inline axis.
|
|
30
|
+
* @cssproperty --floating-arrow-y - Where the pointer meets the anchor, along the block axis.
|
|
31
|
+
* @cssproperty --floating-available-block-size - How much room is left below, for a panel that scrolls.
|
|
32
|
+
*/
|
|
33
|
+
export class FloatingController {
|
|
34
|
+
constructor(host, options) {
|
|
35
|
+
/**
|
|
36
|
+
* Which run of placements the results coming back belong to.
|
|
37
|
+
*
|
|
38
|
+
* `computePosition` is async, so a measurement started before `stop()` still
|
|
39
|
+
* resolves after it and wrote its result onto a surface that had just been
|
|
40
|
+
* taken down: the inline `position` and `translate` went back on, and so did
|
|
41
|
+
* `data-placed`, which is the attribute five families gate visibility on.
|
|
42
|
+
* Opening and closing inside one task is all it takes, since the write lands
|
|
43
|
+
* a microtask later.
|
|
44
|
+
*
|
|
45
|
+
* A counter rather than an `AbortController`, because there is nothing to
|
|
46
|
+
* abort: the work is already done by the time the answer arrives and the only
|
|
47
|
+
* question is whether anybody still wants it.
|
|
48
|
+
*/
|
|
49
|
+
this.run = 0;
|
|
50
|
+
/** The placement in use after the last measurement. */
|
|
51
|
+
this.placement = 'bottom-start';
|
|
52
|
+
/** Measures both elements and writes the position. */
|
|
53
|
+
this.update = () => {
|
|
54
|
+
const anchor = this.options.anchor();
|
|
55
|
+
const floating = this.options.floating();
|
|
56
|
+
if (!anchor || !floating) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const run = this.run;
|
|
60
|
+
const pointer = this.options.arrow?.();
|
|
61
|
+
const gap = typeof this.options.offset === 'function' ? this.options.offset() : this.options.offset;
|
|
62
|
+
const preferred = typeof this.options.placement === 'function'
|
|
63
|
+
? this.options.placement()
|
|
64
|
+
: this.options.placement;
|
|
65
|
+
const middleware = [offset(gap ?? 0)];
|
|
66
|
+
if (this.options.flip !== false) {
|
|
67
|
+
middleware.push(flip());
|
|
68
|
+
}
|
|
69
|
+
if (this.options.shift !== false) {
|
|
70
|
+
const inside = this.options.within?.();
|
|
71
|
+
middleware.push(shift({ padding: 8, ...(inside ? { boundary: inside } : {}) }));
|
|
72
|
+
}
|
|
73
|
+
if (this.options.resize !== false) {
|
|
74
|
+
middleware.push(size({
|
|
75
|
+
/* The height and not the width, which is asymmetric on purpose. A
|
|
76
|
+
panel that runs out of room grows a scrollbar downwards and every
|
|
77
|
+
rule reading this asks for a `max-block-size`; nothing in the
|
|
78
|
+
library has ever asked for the inline twin, so writing it was a
|
|
79
|
+
`setProperty` on every reposition of every floating element for
|
|
80
|
+
nobody. `check:hooks` is what says so out loud: declared and never
|
|
81
|
+
read is a promise on the API page with nothing behind it. It comes
|
|
82
|
+
back the day a rule wants it, together with that rule. */
|
|
83
|
+
apply: ({ availableHeight, elements, rects }) => {
|
|
84
|
+
elements.floating.style.setProperty('--floating-available-block-size', `${Math.round(availableHeight)}px`);
|
|
85
|
+
/* The anchor's width, which a panel that has to match the control
|
|
86
|
+
it hangs off cannot get any other way. `min-inline-size: 100%`
|
|
87
|
+
reads the containing block, and this positions with
|
|
88
|
+
`position: fixed`, so that percentage is the viewport: a select's
|
|
89
|
+
list came out the full width of the window, flush to its left
|
|
90
|
+
edge. It looked like a positioning fault and was a percentage
|
|
91
|
+
resolving against something the CSS could not see. */
|
|
92
|
+
elements.floating.style.setProperty('--floating-anchor-inline-size', `${Math.round(rects.reference.width)}px`);
|
|
93
|
+
},
|
|
94
|
+
padding: 8,
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
if (pointer) {
|
|
98
|
+
middleware.push(arrow({ element: pointer }));
|
|
99
|
+
}
|
|
100
|
+
computePosition(anchor, floating, {
|
|
101
|
+
middleware,
|
|
102
|
+
placement: preferred ?? 'bottom-start',
|
|
103
|
+
// `fixed` so the overlay clears an ancestor with `overflow: hidden`.
|
|
104
|
+
// Floating UI resolves the containing block either way, so this is about
|
|
105
|
+
// clipping rather than about which coordinates are correct.
|
|
106
|
+
strategy: 'fixed',
|
|
107
|
+
}).then(({ middlewareData, placement, x, y }) => {
|
|
108
|
+
/* Stopped while this was being measured, so the answer is about a surface
|
|
109
|
+
nobody is showing any more. Writing it would re-mark a closed panel as
|
|
110
|
+
placed. */
|
|
111
|
+
if (run !== this.run) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
this.placement = placement;
|
|
115
|
+
Object.assign(floating.style, {
|
|
116
|
+
insetBlockStart: '0',
|
|
117
|
+
insetInlineStart: '0',
|
|
118
|
+
position: 'fixed',
|
|
119
|
+
// A translate rather than insets, so the position never lands on a
|
|
120
|
+
// subpixel boundary and blurs the text inside it.
|
|
121
|
+
//
|
|
122
|
+
// **The `translate` property, not `transform`.** They are not
|
|
123
|
+
// interchangeable here. The individual properties compose ahead of
|
|
124
|
+
// `transform`, so a panel that also sets `scale` for an entrance ends
|
|
125
|
+
// up with `scale() translate()`, and a concatenation applies its right
|
|
126
|
+
// hand side first: the position is computed and *then* multiplied by
|
|
127
|
+
// the scale factor. A tooltip 600px down the page scaling from 0.9
|
|
128
|
+
// therefore opens 60px out of place and slides into position, and one
|
|
129
|
+
// scaling from 0 flies in from the viewport corner. As `translate` it
|
|
130
|
+
// composes as `translate() scale()`, the offset is applied in the
|
|
131
|
+
// parent's coordinates, and the scale cannot reach it.
|
|
132
|
+
/* **Written as viewport coordinates, which is what they are.**
|
|
133
|
+
*
|
|
134
|
+
* `computePosition` returns a position in the viewport and this element
|
|
135
|
+
* is `position: fixed`, so the two agree and the translate is the answer
|
|
136
|
+
* unmodified.
|
|
137
|
+
*
|
|
138
|
+
* **A correction used to sit here and it was answering the wrong
|
|
139
|
+
* question.** A chart's bubble was landing hundreds of pixels from its
|
|
140
|
+
* anchor, which looked like an ancestor stealing the containing block:
|
|
141
|
+
* `transform`, `filter` and `backdrop-filter` all do that to a `fixed`
|
|
142
|
+
* descendant, and the page's cards are glass. Measured, the bubble with
|
|
143
|
+
* a zeroed translate sat at (799, 439) rather than the origin, which
|
|
144
|
+
* seemed to confirm it.
|
|
145
|
+
*
|
|
146
|
+
* It was not that. 799 is `(1728 - 130) / 2` and 439 is near
|
|
147
|
+
* `(1003 - 73) / 2`: the bubble was centered in the viewport, which is
|
|
148
|
+
* what the UA stylesheet does to a `popover` through `inset: 0` and
|
|
149
|
+
* `margin: auto`. The chart was rendering the *headless* tooltip, which
|
|
150
|
+
* ships no styles to overrule that; `Tooltip` sets `margin: 0` and the
|
|
151
|
+
* controller writes the insets, so a styled one was never centered and
|
|
152
|
+
* never needed correcting. Fixing the tier fixed the position, and the
|
|
153
|
+
* correction then subtracted a real ancestor's origin from an already
|
|
154
|
+
* correct number and threw every tooltip on the page off by it.
|
|
155
|
+
*
|
|
156
|
+
* Recorded because the evidence for the wrong answer was a measurement,
|
|
157
|
+
* not a guess, and the number that disproved it was arithmetic on the
|
|
158
|
+
* viewport. */
|
|
159
|
+
translate: `${Math.round(x)}px ${Math.round(y)}px`,
|
|
160
|
+
});
|
|
161
|
+
floating.setAttribute('data-placement', placement);
|
|
162
|
+
/* Placed, and it is a separate fact from shown. `computePosition` is
|
|
163
|
+
async, so between the render that reveals a panel and the microtask
|
|
164
|
+
that positions it there is a frame where the panel is visible at
|
|
165
|
+
whatever its stylesheet said, which for an absolutely positioned one
|
|
166
|
+
with no offsets is its host's top-left. Measured on the select: the
|
|
167
|
+
panel appeared 24px up and to the left of the trigger for one frame on
|
|
168
|
+
the first open, and looked correct on every open after, because the
|
|
169
|
+
inline styles from last time were still on it.
|
|
170
|
+
|
|
171
|
+
A panel that wants to fade in on placement selects on this. Nothing has
|
|
172
|
+
to: `data-placement` has been written here all along and this is its
|
|
173
|
+
pair, so a component that ignores both is exactly as it was. */
|
|
174
|
+
floating.setAttribute('data-placed', '');
|
|
175
|
+
const point = middlewareData.arrow;
|
|
176
|
+
if (pointer && point) {
|
|
177
|
+
Object.assign(pointer.style, {
|
|
178
|
+
insetBlockStart: point.y === undefined ? '' : `${point.y}px`,
|
|
179
|
+
insetInlineStart: point.x === undefined ? '' : `${point.x}px`,
|
|
180
|
+
});
|
|
181
|
+
/* Where the pointer ended up, as the center of it, published for
|
|
182
|
+
anything that has to grow out of the thing it points at.
|
|
183
|
+
*
|
|
184
|
+
* A panel scaling in wants its `transform-origin` on its own arrow, and
|
|
185
|
+
* the arrow is not reliably where the placement says: `top-start` puts
|
|
186
|
+
* it near one end, and `shift` slides it anywhere along the edge to
|
|
187
|
+
* keep the panel on screen. Selecting on `data-placement` alone gets
|
|
188
|
+
* the axis right and the position wrong, which reads as the panel
|
|
189
|
+
* growing out of a point next to the trigger rather than out of it.
|
|
190
|
+
*
|
|
191
|
+
* Only the axis the arrow travels on is written. On a `top` placement
|
|
192
|
+
* the arrow moves horizontally and its vertical position is the panel's
|
|
193
|
+
* edge, so `--floating-arrow-y` is left unset and a consumer's fallback
|
|
194
|
+
* stands. Cleared rather than zeroed when absent, so the fallback in
|
|
195
|
+
* `var()` is what applies. */
|
|
196
|
+
floating.style.setProperty('--floating-arrow-x', point.x === undefined ? '' : `${Math.round(point.x + pointer.offsetWidth / 2)}px`);
|
|
197
|
+
floating.style.setProperty('--floating-arrow-y', point.y === undefined ? '' : `${Math.round(point.y + pointer.offsetHeight / 2)}px`);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
this.options = options;
|
|
202
|
+
// Registered so the host's disconnect releases the tracking, which is the
|
|
203
|
+
// whole reason this is a controller rather than a function.
|
|
204
|
+
host.addController(this);
|
|
205
|
+
}
|
|
206
|
+
/** Starts placing, and keeps placing until stopped. */
|
|
207
|
+
start() {
|
|
208
|
+
const anchor = this.options.anchor();
|
|
209
|
+
const floating = this.options.floating();
|
|
210
|
+
if (!anchor || !floating || this.cleanup) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.cleanup = autoUpdate(anchor, floating, this.update);
|
|
214
|
+
}
|
|
215
|
+
/** Stops placing and releases everything it was tracking. */
|
|
216
|
+
stop() {
|
|
217
|
+
// Before the teardown, so a measurement already in flight is disowned
|
|
218
|
+
// rather than racing the attribute removal below.
|
|
219
|
+
this.run += 1;
|
|
220
|
+
this.cleanup?.();
|
|
221
|
+
this.cleanup = undefined;
|
|
222
|
+
this.options.floating()?.removeAttribute('data-placed');
|
|
223
|
+
}
|
|
224
|
+
hostDisconnected() {
|
|
225
|
+
this.stop();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
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
|
+
export declare class FocusTrapController implements ReactiveController {
|
|
15
|
+
private container;
|
|
16
|
+
/** The frame waiting to take focus, so a release can call it off. */
|
|
17
|
+
private frame;
|
|
18
|
+
private previous;
|
|
19
|
+
constructor(host: ReactiveControllerHost);
|
|
20
|
+
private readonly handleKeydown;
|
|
21
|
+
/** Starts trapping, remembering where focus came from. */
|
|
22
|
+
activate(container: HTMLElement): void;
|
|
23
|
+
/** Stops trapping and returns focus to wherever it was. */
|
|
24
|
+
deactivate(): void;
|
|
25
|
+
hostDisconnected(): void;
|
|
26
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
export class FocusTrapController {
|
|
15
|
+
constructor(host) {
|
|
16
|
+
this.container = null;
|
|
17
|
+
this.previous = null;
|
|
18
|
+
this.handleKeydown = (event) => {
|
|
19
|
+
if (event.key !== 'Tab' || !this.container) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const items = focusableElements(this.container);
|
|
23
|
+
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
|
+
event.preventDefault();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const first = items[0];
|
|
30
|
+
const last = items.at(-1);
|
|
31
|
+
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 this read `active === first` as false on
|
|
38
|
+
* the first element and "outside the trap" on every element. Measured
|
|
39
|
+
* against the two branches below, that made shift-Tab jump to the last item
|
|
40
|
+
* from anywhere and forward Tab never wrap at all, which is a trap that
|
|
41
|
+
* leaks in one direction and cannot be walked backwards in the other.
|
|
42
|
+
*
|
|
43
|
+
* An index also gives the outside-entirely case for free: -1 wraps to
|
|
44
|
+
* whichever end the reader was heading for. */
|
|
45
|
+
const at = items.findIndex((item) => item === active || deepContains(item, active));
|
|
46
|
+
if (event.shiftKey) {
|
|
47
|
+
if (at <= 0) {
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
last.focus();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else if (at === -1 || at === items.length - 1) {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
first.focus();
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
host.addController(this);
|
|
58
|
+
}
|
|
59
|
+
/** Starts trapping, remembering where focus came from. */
|
|
60
|
+
activate(container) {
|
|
61
|
+
this.container = container;
|
|
62
|
+
this.previous = deepActiveElement();
|
|
63
|
+
document.addEventListener('keydown', this.handleKeydown, true);
|
|
64
|
+
/* After render, so the container has something to focus, and *held*, so a
|
|
65
|
+
release before the frame runs can cancel it.
|
|
66
|
+
*
|
|
67
|
+
Unheld, an overlay opened and closed inside one frame pulled focus into
|
|
68
|
+
itself a frame after it had gone: the reader is looking at the page, the
|
|
69
|
+
surface is not on it, and their next key goes somewhere they cannot see.
|
|
70
|
+
That is not a contrived sequence. A menu that closes on the same
|
|
71
|
+
activation that opened it does exactly this, and so does any consumer
|
|
72
|
+
toggling `open` twice in one task. */
|
|
73
|
+
this.frame = requestAnimationFrame(() => {
|
|
74
|
+
this.frame = undefined;
|
|
75
|
+
const items = focusableElements(container);
|
|
76
|
+
(items[0] ?? container).focus();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** Stops trapping and returns focus to wherever it was. */
|
|
80
|
+
deactivate() {
|
|
81
|
+
if (this.frame !== undefined) {
|
|
82
|
+
cancelAnimationFrame(this.frame);
|
|
83
|
+
this.frame = undefined;
|
|
84
|
+
}
|
|
85
|
+
document.removeEventListener('keydown', this.handleKeydown, true);
|
|
86
|
+
this.container = null;
|
|
87
|
+
this.previous?.focus();
|
|
88
|
+
this.previous = null;
|
|
89
|
+
}
|
|
90
|
+
hostDisconnected() {
|
|
91
|
+
this.deactivate();
|
|
92
|
+
}
|
|
93
|
+
}
|