@fusion.dev/helix-core 0.0.0-dev

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