@fusion.dev/helix-core 0.1.0 → 0.3.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.
Files changed (56) hide show
  1. package/custom-elements.json +4 -4
  2. package/dist/controllers/aria-forward-controller/aria-forward-controller.js +0 -105
  3. package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +0 -71
  4. package/dist/controllers/command-controller/command-controller.js +0 -62
  5. package/dist/controllers/copy-controller/copy-controller.js +0 -63
  6. package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.js +0 -40
  7. package/dist/controllers/dismiss-controller/dismiss-controller.js +0 -124
  8. package/dist/controllers/drilldown-controller/drilldown-controller.js +0 -87
  9. package/dist/controllers/floating-controller/floating-controller.js +0 -152
  10. package/dist/controllers/focus-trap-controller/focus-trap-controller.js +0 -35
  11. package/dist/controllers/indicator-controller/indicator-controller.js +0 -82
  12. package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.js +0 -46
  13. package/dist/controllers/popover-controller/popover-controller.js +0 -72
  14. package/dist/controllers/source-controller/source-controller.js +0 -38
  15. package/dist/controllers/token-color-controller/token-color-controller.js +0 -51
  16. package/dist/controllers/virtualize-controller/virtualize-controller.js +0 -128
  17. package/dist/events/element-events.js +0 -22
  18. package/dist/events/events.js +0 -386
  19. package/dist/helix-element/helix-element.d.ts +10 -17
  20. package/dist/helix-element/helix-element.js +0 -505
  21. package/dist/helix-form-element/helix-form-element.js +0 -140
  22. package/dist/mixins/mixin-chart-source/mixin-chart-source.js +0 -77
  23. package/dist/mixins/mixin-collection/mixin-collection.js +0 -83
  24. package/dist/mixins/mixin-controller-active/mixin-controller-active.js +0 -82
  25. package/dist/mixins/mixin-controller-checked/mixin-controller-checked.js +0 -78
  26. package/dist/mixins/mixin-controller-open/mixin-controller-open.js +0 -93
  27. package/dist/mixins/mixin-controller-value/mixin-controller-value.js +0 -87
  28. package/dist/mixins/mixin-dismissible/mixin-dismissible.js +0 -38
  29. package/dist/mixins/mixin-field/mixin-field.js +0 -277
  30. package/dist/utils/anchor-element/anchor-element.js +0 -23
  31. package/dist/utils/capture-pointer/capture-pointer.js +0 -18
  32. package/dist/utils/closest-instance/closest-instance.js +0 -20
  33. package/dist/utils/create-sheet/create-sheet.js +0 -14
  34. package/dist/utils/custom-element/custom-element.js +0 -83
  35. package/dist/utils/draw-icon/draw-icon.js +0 -28
  36. package/dist/utils/draw-tooltip/draw-tooltip.js +1 -50
  37. package/dist/utils/elapsed-time/elapsed-time.js +0 -51
  38. package/dist/utils/focusable-elements/focusable-elements.js +1 -59
  39. package/dist/utils/format-numbers/format-numbers.js +0 -16
  40. package/dist/utils/host-aria/host-aria.js +0 -26
  41. package/dist/utils/inherit-disabled/inherit-disabled.js +0 -36
  42. package/dist/utils/line-path/line-path.js +0 -36
  43. package/dist/utils/on-unless-false/on-unless-false.js +0 -23
  44. package/dist/utils/read-series/read-series.js +0 -71
  45. package/dist/utils/relative-time/relative-time.js +0 -43
  46. package/dist/utils/resolve-active-item/resolve-active-item.js +0 -20
  47. package/dist/utils/restore-value/restore-value.js +0 -54
  48. package/dist/utils/scale/scale.js +0 -19
  49. package/dist/utils/scale/ticks.js +0 -36
  50. package/dist/utils/scroll-item-into-view/scroll-item-into-view.js +0 -53
  51. package/dist/utils/slot-filled/slot-filled.js +0 -23
  52. package/dist/utils/tell-disclosure/tell-disclosure.js +0 -53
  53. package/dist/utils/ticker/ticker.js +0 -22
  54. package/dist/utils/tone-marks/tone-marks.js +0 -24
  55. package/dist/utils/value-as/value-as.js +0 -56
  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);