@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,695 @@
|
|
|
1
|
+
import { css, LitElement } from 'lit';
|
|
2
|
+
import { unsafeStatic } from 'lit/static-html.js';
|
|
3
|
+
import { eventsForTag } from '../events/element-events.js';
|
|
4
|
+
import { helixEventNames } from '../events/events.js';
|
|
5
|
+
/**
|
|
6
|
+
* Applied to every Helix component's shadow root so component CSS can rely on
|
|
7
|
+
* border-box on both sides of the shadow boundary. The light DOM gets the same
|
|
8
|
+
* rule from `@fusion.dev/helix-styles/base.css`.
|
|
9
|
+
*/
|
|
10
|
+
const BOX_SIZING = css `
|
|
11
|
+
*,
|
|
12
|
+
::before,
|
|
13
|
+
::after {
|
|
14
|
+
box-sizing: border-box;
|
|
15
|
+
}
|
|
16
|
+
`;
|
|
17
|
+
/**
|
|
18
|
+
* Makes `hidden` work, which it does not otherwise.
|
|
19
|
+
*
|
|
20
|
+
* The UA stylesheet's `[hidden] { display: none }` is user-agent origin, so any
|
|
21
|
+
* author declaration beats it, and a component whose `:host` sets a `display`
|
|
22
|
+
* is doing exactly that. `<helix-grid hidden>` stayed visible; so did
|
|
23
|
+
* `<helix-empty hidden>`, `<helix-flex hidden>` and fifty-four others. The
|
|
24
|
+
* audit found three of them and the real count was fifty-seven, because every
|
|
25
|
+
* component that lays itself out has the same fault and nothing connects them.
|
|
26
|
+
*
|
|
27
|
+
* Here rather than in each sheet, for the reason `box-sizing` is here: it is a
|
|
28
|
+
* fact about every component, and fifty-seven copies is fifty-seven chances to
|
|
29
|
+
* forget the fifty-eighth.
|
|
30
|
+
*
|
|
31
|
+
* **The selector is (0,3,0) on purpose, and the obvious (0,2,0) is not enough.**
|
|
32
|
+
* A bare `:host([hidden])` beats a bare `:host`, which is what the first version
|
|
33
|
+
* of this rule assumed and it was wrong: six components set their `display` from
|
|
34
|
+
* an attribute selector at the *same* (0,2,0) and win on source order because
|
|
35
|
+
* they are injected after this. Measured, all five still rendered while hidden:
|
|
36
|
+
* `flex[inline]` at `inline-flex`, `divider[orientation="vertical"]` at
|
|
37
|
+
* `inline-block`, and `text[as]`, `checkbox[appearance="card"]` and
|
|
38
|
+
* `radio[appearance="card"]` at `block`. The `:not()` lifts it a step and it
|
|
39
|
+
* wins from first position, so the rule can stay prepended.
|
|
40
|
+
*
|
|
41
|
+
* **And the `:not()` is load-bearing beyond specificity.** `hidden="until-found"`
|
|
42
|
+
* is how the platform lets find-in-page reveal collapsed content: the browser
|
|
43
|
+
* removes the attribute when a match is inside. Hiding it with `display: none`
|
|
44
|
+
* makes it unfindable, so the one form of `hidden` that is *meant* to be
|
|
45
|
+
* searchable is the one form this must not touch.
|
|
46
|
+
*
|
|
47
|
+
* `!important` is deliberately not used. A component with a real reason to stay
|
|
48
|
+
* laid out while hidden should be able to say so, and none of them can if this
|
|
49
|
+
* is unanswerable.
|
|
50
|
+
*/
|
|
51
|
+
const HIDDEN = css `
|
|
52
|
+
:host([hidden]:not([hidden='until-found'])) {
|
|
53
|
+
display: none;
|
|
54
|
+
}
|
|
55
|
+
`;
|
|
56
|
+
/**
|
|
57
|
+
* Makes an element a scroll container, where a component says it is one.
|
|
58
|
+
*
|
|
59
|
+
* **A box that scrolls is the headless tier's, and the headless tier had no way to say
|
|
60
|
+
* so.** `overflow` is what creates a scrollport: without it `scrollTop` is
|
|
61
|
+
* permanently zero and every scroll API on the element is inert. So a component
|
|
62
|
+
* that reads `scrollTop` and cannot declare `overflow` is one whose behavior
|
|
63
|
+
* lives in somebody else's stylesheet, and six of them do exactly that.
|
|
64
|
+
*
|
|
65
|
+
* Measured before deciding: `helix-headless-scroll-area`, the component whose
|
|
66
|
+
* entire purpose is scrolling, computes `overflow-y: visible` on its viewport
|
|
67
|
+
* with `scrollHeight` equal to `clientHeight`. Standing alone it is a div. The
|
|
68
|
+
* virtual list was worse, because it *acts* on the reading: #174 is the day it
|
|
69
|
+
* pegged a renderer measuring a box that could not scroll.
|
|
70
|
+
*
|
|
71
|
+
* **It goes here rather than in a stylesheet, because this is where the other
|
|
72
|
+
* three already are.** `BOX_SIZING`, `HIDDEN` and `SETTLED` are token-free
|
|
73
|
+
* structural rules `core` ships into every shadow root, and `HIDDEN` is the
|
|
74
|
+
* exact same category: the rules argue `hidden` is base-tier state because the
|
|
75
|
+
* platform acts on it, and the way the headless tier makes the platform act is a
|
|
76
|
+
* CSS rule shipped from here. "The headless tier has no CSS" has never meant
|
|
77
|
+
* `core` has none; it means `helix/headless` must not depend on
|
|
78
|
+
* `@fusion.dev/helix-styles`, and a rule with no token in it does not.
|
|
79
|
+
*
|
|
80
|
+
* **And a rule rather than an inline style, which was the first idea and is
|
|
81
|
+
* wrong.** An inline `style` on a rendered part outranks every author rule
|
|
82
|
+
* short of `!important`, so a consumer wanting `overflow: hidden` on that part
|
|
83
|
+
* would have to fight it, and this repository's own notes say what that is: it
|
|
84
|
+
* moves the fight onto consumers, who then need `!important` of their own. A
|
|
85
|
+
* shadow rule loses to a document rule, so `::part(viewport) { overflow: … }`
|
|
86
|
+
* from a consumer simply wins, which is the right way round.
|
|
87
|
+
*
|
|
88
|
+
* Both axes, and nothing else. Which *kind* of scrolling, how much room,
|
|
89
|
+
* `overscroll-behavior`, `scroll-snap`, a gutter, a scrollbar treatment: those
|
|
90
|
+
* are decisions and stay in `helix/styled`. This is only the mechanism, and the
|
|
91
|
+
* styled sheet is prepended-after so it overrides at equal specificity anyway.
|
|
92
|
+
*
|
|
93
|
+
* **A component marks a box whose scroll state it reads, and no other**, which
|
|
94
|
+
* is narrower than "every box that ends up scrolling" and is the line that took
|
|
95
|
+
* a measurement to find. `Megamenu`'s panel scrolls, and only because the
|
|
96
|
+
* styled tier gives it a `max-block-size`: that is a decision, the `overflow`
|
|
97
|
+
* that serves it belongs with it, and the headless tier never asks the panel
|
|
98
|
+
* where it is scrolled to. Marking it anyway moved a heading four pixels,
|
|
99
|
+
* because `overflow` establishes a block formatting context and margins that
|
|
100
|
+
* had been collapsing stopped. A mechanism with a layout consequence is one to
|
|
101
|
+
* apply exactly where it is needed.
|
|
102
|
+
*
|
|
103
|
+
* Three qualify: `ScrollArea`, `VirtualList` and `Carousel`, each of which
|
|
104
|
+
* reads `scrollTop` or `scrollLeft` and acts on the answer.
|
|
105
|
+
*/
|
|
106
|
+
const SCROLLER = css `
|
|
107
|
+
[data-scroller] {
|
|
108
|
+
overflow: auto;
|
|
109
|
+
}
|
|
110
|
+
`;
|
|
111
|
+
/**
|
|
112
|
+
* Stops a component animating its own arrival.
|
|
113
|
+
*
|
|
114
|
+
* A shadow root's stylesheets are adopted when the element upgrades, which is
|
|
115
|
+
* after the element has already had a computed style. So the first resolution
|
|
116
|
+
* that sees a component's own sheet is a *change*, from whatever the property's
|
|
117
|
+
* initial value is to whatever the sheet says, and any `transition` the sheet
|
|
118
|
+
* declares on that property runs against it. Nothing was interacted with and
|
|
119
|
+
* nothing changed state: the component is animating the fact that its CSS
|
|
120
|
+
* arrived.
|
|
121
|
+
*
|
|
122
|
+
* It shows wherever the initial value and the resting value differ visibly, and
|
|
123
|
+
* `border-color` is the worst case, because its initial value is `currentColor`.
|
|
124
|
+
* App Header reserves its border box and paints it `transparent` by default, so
|
|
125
|
+
* on every load and every cross-document navigation it faded a one pixel
|
|
126
|
+
* `currentColor` hairline out over `--helix-duration-normal`: near-black in
|
|
127
|
+
* light mode, which reads as an intentional rule under the bar, and near-white
|
|
128
|
+
* in dark mode, which reads as a bright sliver under the masthead. Measured on
|
|
129
|
+
* the landing page with nothing touched and no pointer in the window, two
|
|
130
|
+
* families ran five such transitions: App Header's bottom border and all four
|
|
131
|
+
* of Card's.
|
|
132
|
+
*
|
|
133
|
+
* Here rather than in those two sheets, for the reason `box-sizing` and
|
|
134
|
+
* `hidden` are here. It is a fact about every component that declares a
|
|
135
|
+
* transition, the count of those grows, and a sheet cannot see the problem: the
|
|
136
|
+
* rule that is wrong is the one that looks most ordinary.
|
|
137
|
+
*
|
|
138
|
+
* **`!important`, because specificity cannot be relied on.** A component's own
|
|
139
|
+
* transition can be declared on `:host` (0,1,0) or on a part under a host
|
|
140
|
+
* condition (0,3,0), and this rule has to beat both without knowing which. From
|
|
141
|
+
* inside the shadow tree it is also the only weight that outranks a consumer's
|
|
142
|
+
* document rule, which would otherwise re-introduce the flash from outside.
|
|
143
|
+
*
|
|
144
|
+
* The state is set a frame after the first update rather than in
|
|
145
|
+
* `firstUpdated`, and that is the whole mechanism rather than a detail. Lit's
|
|
146
|
+
* upgrade, render and `firstUpdated` all run in one microtask checkpoint, so a
|
|
147
|
+
* flag set there lands in the same style resolution as the adopted sheets and
|
|
148
|
+
* the transition runs anyway. A frame later, the resting value has already been
|
|
149
|
+
* resolved with transitions off, so enabling them changes nothing and there is
|
|
150
|
+
* nothing left to animate.
|
|
151
|
+
*
|
|
152
|
+
* **`:state()` and not an attribute, and that was a bug for three routes before
|
|
153
|
+
* it was a preference.** This was `[data-settled]`, written onto the host with
|
|
154
|
+
* `setAttribute`, and the host is the element React owns. A definition that
|
|
155
|
+
* lands during module evaluation has a frame to spend before React arrives, so
|
|
156
|
+
* the element wrote its own bookkeeping onto itself and React then hydrated a
|
|
157
|
+
* DOM that had gained an attribute since the server wrote it. Measured across
|
|
158
|
+
* three routes at the time: `/helix` clean, `/helix/theme` one diff and
|
|
159
|
+
* `/helix/sandbox` three, every one of them a `data-settled=""` no server sent.
|
|
160
|
+
*
|
|
161
|
+
* It was worked around three times, in `use-sandbox-editor.ts`, in the sandbox
|
|
162
|
+
* barrel and again on the reference pages, each deferring a registration so the
|
|
163
|
+
* frame fell the other side of hydration. Every one of those is a fix for the
|
|
164
|
+
* timing rather than for the cause: the cause is that a component's private
|
|
165
|
+
* bookkeeping was being kept somewhere React reconciles.
|
|
166
|
+
*
|
|
167
|
+
* A custom state is not in the DOM. It carries the fact to this stylesheet and
|
|
168
|
+
* nothing to the markup, so a server's output and a hydrated client's agree by
|
|
169
|
+
* construction. Pagination reached the same conclusion for `:state(empty)` and
|
|
170
|
+
* says so in its own sheet.
|
|
171
|
+
*/
|
|
172
|
+
const SETTLED = css `
|
|
173
|
+
:host(:not(:state(settled))),
|
|
174
|
+
:host(:not(:state(settled))) * {
|
|
175
|
+
transition: none !important;
|
|
176
|
+
}
|
|
177
|
+
`;
|
|
178
|
+
/**
|
|
179
|
+
* The prefix React puts on the props bag it stashes on every node it owns.
|
|
180
|
+
*
|
|
181
|
+
* The rest of the key is a random suffix generated per page, so the whole thing
|
|
182
|
+
* has to be discovered rather than written down.
|
|
183
|
+
*/
|
|
184
|
+
const REACT_PROPS = '__reactProps$';
|
|
185
|
+
/**
|
|
186
|
+
* The discovered key, which is the same for every node on a page.
|
|
187
|
+
*
|
|
188
|
+
* Cached because `helix-caret` fires on every keystroke and a scan per dispatch
|
|
189
|
+
* would be a scan per keystroke. Re-scanned whenever the cached key comes back
|
|
190
|
+
* empty, so a second React root with a suffix of its own still resolves.
|
|
191
|
+
*
|
|
192
|
+
* **A miss must not erase it, and it did.** The re-scan wrote its own result
|
|
193
|
+
* back unconditionally, so any dispatch from a node React does not own found
|
|
194
|
+
* nothing and set the key to `undefined`, and the next React-owned node paid
|
|
195
|
+
* for a full scan again. Most elements in this library are rendered by Lit
|
|
196
|
+
* inside a shadow root and are exactly that kind of node, so on a real page the
|
|
197
|
+
* cache was being cleared constantly by the components least able to benefit
|
|
198
|
+
* from it. Measured, 20,000 dispatches: 5.3ms with a warm cache against 20.3ms
|
|
199
|
+
* alternating one owned node with one that is not, which is roughly twice the
|
|
200
|
+
* per-dispatch cost for the half that should have been free.
|
|
201
|
+
*
|
|
202
|
+
* So a miss now leaves the cache alone and only a *different* key replaces it,
|
|
203
|
+
* which keeps the second-React-root case the note above is about.
|
|
204
|
+
*
|
|
205
|
+
* **What is left is a scan per dispatch on a page with no React at all**, since
|
|
206
|
+
* there is no key to find and no honest way to conclude one will never appear:
|
|
207
|
+
* a root can mount at any time, and memoizing "this node is not React's" would
|
|
208
|
+
* silently disable the binding for any element that dispatches before React
|
|
209
|
+
* commits, which is the exact failure this whole mechanism exists to repair.
|
|
210
|
+
* It is around half a microsecond and it is filed rather than hidden. See #320.
|
|
211
|
+
*/
|
|
212
|
+
let reactPropsKey;
|
|
213
|
+
/** The props React is holding for a node, if React is holding any. */
|
|
214
|
+
const reactPropsOf = (node) => {
|
|
215
|
+
const stash = node;
|
|
216
|
+
if (reactPropsKey !== undefined && stash[reactPropsKey] !== undefined) {
|
|
217
|
+
return stash[reactPropsKey];
|
|
218
|
+
}
|
|
219
|
+
const found = Object.keys(stash).find((key) => key.startsWith(REACT_PROPS));
|
|
220
|
+
if (found === undefined) {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
reactPropsKey = found;
|
|
224
|
+
return stash[found];
|
|
225
|
+
};
|
|
226
|
+
/** Elements whose handler props have been read once. */
|
|
227
|
+
const audited = new WeakSet();
|
|
228
|
+
/**
|
|
229
|
+
* Properties that were public API until 19 Aug 2026 and are events now.
|
|
230
|
+
*
|
|
231
|
+
* Worth naming for a release or two: they were documented, a consumer may have
|
|
232
|
+
* written one, and removing a property is silent in a way removing an event is
|
|
233
|
+
* not.
|
|
234
|
+
*/
|
|
235
|
+
const REMOVED = new Set(['onActiveChange', 'onCheckedChange', 'onOpenChange', 'onValueChange']);
|
|
236
|
+
/**
|
|
237
|
+
* Says so when a handler prop is attached to nothing.
|
|
238
|
+
*
|
|
239
|
+
* **TypeScript will not catch any of this, and that is not our tooling's
|
|
240
|
+
* fault.** A JSX attribute whose name is not a valid identifier is exempt from
|
|
241
|
+
* checking on an intrinsic element, and every one of ours carries a hyphen. So
|
|
242
|
+
* `onhelix-opne` type-checks, `on:helix-open` type-checks, and a made-up
|
|
243
|
+
* `totally-invented` prop type-checks, all against fully generated types that
|
|
244
|
+
* do reject a wrong *value* on a normal attribute. Measured with `tsc`.
|
|
245
|
+
*
|
|
246
|
+
* That leaves runtime as the only place a typo can be caught, and React's props
|
|
247
|
+
* bag as the only place to look, which the binding above already reads. Once
|
|
248
|
+
* per element, like the controlled-change audit, and for the same reason: a
|
|
249
|
+
* warning that repeats on every keystroke is one a consumer silences.
|
|
250
|
+
*
|
|
251
|
+
* `on:helix-open` gets its own message because it is not a mistake so much as
|
|
252
|
+
* the wrong dialect. It is what the Solid types offer, correctly, and React
|
|
253
|
+
* binds it as an event named `:helix-open`.
|
|
254
|
+
*/
|
|
255
|
+
const auditReactProps = (host, props) => {
|
|
256
|
+
if (audited.has(host)) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
audited.add(host);
|
|
260
|
+
const tag = host instanceof Element ? `<${host.localName}>` : 'A Helix element';
|
|
261
|
+
for (const key of Object.keys(props)) {
|
|
262
|
+
if (key.startsWith('on:')) {
|
|
263
|
+
console.warn(`${tag} was given \`${key}\`, which is Solid's syntax. React binds the name after \`on\` ` +
|
|
264
|
+
`verbatim, so this listens for an event called \`${key.slice(2)}\`. Write \`on${key.slice(3)}\`.`);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (REMOVED.has(key)) {
|
|
268
|
+
console.warn(`${tag} was given \`${key}\`, which was removed. It never worked from JSX, because React ` +
|
|
269
|
+
`binds any \`on\` prop as an event of that name. Listen for the element's change event instead.`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (!key.startsWith('on') || !key.includes('-')) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const name = key.slice(2);
|
|
276
|
+
if (!helixEventNames.has(name)) {
|
|
277
|
+
console.warn(`${tag} was given \`${key}\`, and nothing dispatches \`${name}\`. TypeScript cannot ` +
|
|
278
|
+
`check a hyphenated JSX prop, so this is the only place it shows up.`);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
/* **A real name on the element that does not send it**, which is the mistake
|
|
282
|
+
made while writing a *correct* event and so the one the name check could
|
|
283
|
+
never see. The event usually does reach this element, being composed and
|
|
284
|
+
bubbling, but React binds an `on` prop by reading it as the element itself
|
|
285
|
+
dispatches: one merely passing through never calls this host's own
|
|
286
|
+
`dispatchEvent`, so the prop is recorded and never wired.
|
|
287
|
+
|
|
288
|
+
Silent when nothing registered the tag, which is a component from outside
|
|
289
|
+
this library or a package built before the map existed. */
|
|
290
|
+
const mine = host instanceof Element ? eventsForTag(host.localName) : undefined;
|
|
291
|
+
if (mine && !mine.has(name)) {
|
|
292
|
+
console.warn(`${tag} was given \`${key}\`, but it dispatches ${[...mine].map((one) => `\`${one}\``).join(', ')}. ` +
|
|
293
|
+
`\`${name}\` is sent by a different element, and React only wires an \`on\` prop on the ` +
|
|
294
|
+
'element that dispatches, so this handler will never run. Put it on that element.');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
/*
|
|
299
|
+
* Binds the handler React is holding for this event, if it has not bound it.
|
|
300
|
+
*
|
|
301
|
+
* **React attaches no custom element listener while hydrating.** Measured in
|
|
302
|
+
* Chrome against 19.2.8: on a server-rendered page every `onhelix-*` prop is
|
|
303
|
+
* dead, whatever it is called, until React commits an update for some other
|
|
304
|
+
* reason, at which point it silently starts working. That is why it looks fine
|
|
305
|
+
* while developing and fails on a page somebody actually loaded. It is React's
|
|
306
|
+
* own bug, filed as facebook/react#35446 and unfixed, and it is not something a
|
|
307
|
+
* consumer can be asked to work around per component.
|
|
308
|
+
*
|
|
309
|
+
* The handler is on the node the whole time: React stashes the props it is
|
|
310
|
+
* holding under a key of its own, hydration included. So the element reads it
|
|
311
|
+
* and binds it itself, immediately before announcing, which is late enough that
|
|
312
|
+
* hydration has certainly finished and early enough that this dispatch reaches
|
|
313
|
+
* the listener just added.
|
|
314
|
+
*
|
|
315
|
+
* **The raw prop function is bound, never a wrapper, and that is what makes it
|
|
316
|
+
* safe.** Two independent mechanisms stop it firing twice. If React has already
|
|
317
|
+
* bound this handler, `addEventListener` with the same type, callback and
|
|
318
|
+
* capture flag is a no-op by specification. And when React next commits, it
|
|
319
|
+
* removes the previous handler before attaching its own, which matches ours
|
|
320
|
+
* exactly, so ownership passes over cleanly. Measured across a commit: four
|
|
321
|
+
* dispatches, four calls.
|
|
322
|
+
*
|
|
323
|
+
* If React ever renames its key this finds nothing and does nothing, which
|
|
324
|
+
* returns the pre-hydration behavior rather than breaking anything.
|
|
325
|
+
*
|
|
326
|
+
* **Relayed native events are handled too, on a different rule.** See
|
|
327
|
+
* `reactPropFor`: a bubbling one already reaches React's delegated listener and
|
|
328
|
+
* must not be bound twice, and a non-bubbling one reaches nothing at all.
|
|
329
|
+
*/
|
|
330
|
+
/**
|
|
331
|
+
* Events React already wires on a custom element, whatever their bubbling.
|
|
332
|
+
*
|
|
333
|
+
* `setPropOnCustomElement` calls `listenToNonDelegatedEvent` for these two by
|
|
334
|
+
* name, and assigns a no-op `onclick` so the delegated path picks up clicks.
|
|
335
|
+
* Nothing here relays them today; the list exists so that the day something
|
|
336
|
+
* does, it does not arrive twice.
|
|
337
|
+
*/
|
|
338
|
+
const REACT_WIRES = new Set(['scroll', 'scrollend']);
|
|
339
|
+
/**
|
|
340
|
+
* The prop React would be holding for this event, if it is holding one.
|
|
341
|
+
*
|
|
342
|
+
* **A relayed native event needs a different answer from one of ours, and
|
|
343
|
+
* getting it wrong doubles every call.** Measured against React 19.2.8:
|
|
344
|
+
*
|
|
345
|
+
* ```
|
|
346
|
+
* onhelix-before-change <- helix-before-change REACHED (React binds it with addEventListener)
|
|
347
|
+
* onChange <- change REACHED (React's delegated root listener)
|
|
348
|
+
* onLoad <- load not reached
|
|
349
|
+
* onError <- error not reached
|
|
350
|
+
* onEnded <- ended not reached
|
|
351
|
+
* ```
|
|
352
|
+
*
|
|
353
|
+
* React binds a hyphenated prop directly on the element, so our own events are
|
|
354
|
+
* reached by name and are safe to bind again: same type, same callback, same
|
|
355
|
+
* capture flag is a no-op by specification. A native name is different. React
|
|
356
|
+
* recognises `onChange` and `onLoad` as its own registration names and attaches
|
|
357
|
+
* nothing to the element for either; it relies on a delegated listener at the
|
|
358
|
+
* root, which sees an event only if it **bubbles**, and on a non-delegated list
|
|
359
|
+
* it sets up for `<video>`, `<audio>` and `<img>` and never for a custom
|
|
360
|
+
* element.
|
|
361
|
+
*
|
|
362
|
+
* So a bubbling relay already arrives and must not be bound again: React's
|
|
363
|
+
* listener is at the root rather than on the element, so nothing deduplicates
|
|
364
|
+
* it and the consumer's handler runs twice. A non-bubbling relay arrives
|
|
365
|
+
* nowhere, and is the bug this exists to close: `<helix-avatar>` relays `load`
|
|
366
|
+
* and `error`, `<helix-video>` relays `ended`, `play` and `pause`, all of them
|
|
367
|
+
* declared public API, all of them silent in React until now. An avatar
|
|
368
|
+
* fallback is a shipped feature that did nothing.
|
|
369
|
+
*
|
|
370
|
+
* The name is React's own casing, `onError` rather than `onerror`, because that
|
|
371
|
+
* is what a consumer writes and therefore what is in the props bag. Every
|
|
372
|
+
* native we relay is a single word, so capitalising the first letter is the
|
|
373
|
+
* whole of the rule; a two-word name like `loadeddata` would be `onLoadedData`
|
|
374
|
+
* and needs the list extending rather than the rule generalising.
|
|
375
|
+
*/
|
|
376
|
+
const reactPropFor = (event) => {
|
|
377
|
+
if (event.type.includes('-')) {
|
|
378
|
+
return `on${event.type}`;
|
|
379
|
+
}
|
|
380
|
+
if (event.bubbles || REACT_WIRES.has(event.type)) {
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
return `on${event.type.charAt(0).toUpperCase()}${event.type.slice(1)}`;
|
|
384
|
+
};
|
|
385
|
+
const bindReactHandler = (host, event) => {
|
|
386
|
+
const props = reactPropsOf(host);
|
|
387
|
+
if (props === undefined) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
auditReactProps(host, props);
|
|
391
|
+
const key = reactPropFor(event);
|
|
392
|
+
const handler = key === undefined ? undefined : props[key];
|
|
393
|
+
if (typeof handler === 'function') {
|
|
394
|
+
host.addEventListener(event.type, handler);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
/**
|
|
398
|
+
* Assigns the object and function properties React is holding and never applied.
|
|
399
|
+
*
|
|
400
|
+
* **The same hydration bug as the handlers above, and it needed its own fix.**
|
|
401
|
+
* React skips every prop it would have to apply imperatively while hydrating,
|
|
402
|
+
* not only listeners. Strings, numbers and booleans arrive anyway, because the
|
|
403
|
+
* server wrote them as attributes and Lit converts them back. An object or a
|
|
404
|
+
* function arrives by no route at all, and both halves of React agree to drop
|
|
405
|
+
* it: the server renderer reaches its custom element branch, finds
|
|
406
|
+
* `typeof value === 'object'` and `continue`s, so nothing is written into the
|
|
407
|
+
* HTML; and the client's hydrate path only diffs and warns for a custom element,
|
|
408
|
+
* never reaching `setPropOnCustomElement`. Two skips that match, so there is no
|
|
409
|
+
* mismatch to report and no warning anywhere.
|
|
410
|
+
*
|
|
411
|
+
* **The binding trick above does not transfer, which is why this is separate.**
|
|
412
|
+
* A listener only has to exist by the time somebody interacts, so binding it at
|
|
413
|
+
* dispatch is late enough that hydration has certainly finished and early enough
|
|
414
|
+
* to catch the event being sent. A property has to be right before the first
|
|
415
|
+
* paint. `connectedCallback` is the obvious early moment and is the wrong one,
|
|
416
|
+
* measured: an element upgrades when its definition loads, which falls either
|
|
417
|
+
* side of hydration depending on how the consumer imported it, and on the side
|
|
418
|
+
* this is for it is too early to see anything.
|
|
419
|
+
*
|
|
420
|
+
* **Only what the class declared, and only what could not have arrived as an
|
|
421
|
+
* attribute.** The first is what stops an arbitrary JSX prop being written onto
|
|
422
|
+
* the element; the second is what keeps this to the properties that are actually
|
|
423
|
+
* broken, rather than re-applying a string that is already correct. Three
|
|
424
|
+
* qualify today, `rows` on the data table and `cell` and `compare` on a table
|
|
425
|
+
* column, and the other ten `attribute: false` properties in the library are
|
|
426
|
+
* booleans and strings a parent writes at runtime, which no consumer passes and
|
|
427
|
+
* this never touches.
|
|
428
|
+
*
|
|
429
|
+
* **`null` is the one place this does not reproduce a client mount, and it is
|
|
430
|
+
* deliberate.** `typeof null` is `'object'`, and React assigns a null prop on a
|
|
431
|
+
* client mount while dropping it on the server, so it is genuinely inside the
|
|
432
|
+
* gap this closes. Assigning it would faithfully reproduce a crash: the server
|
|
433
|
+
* left the class default standing, and replacing that default with `null` is the
|
|
434
|
+
* one case where closing the gap makes the page worse than leaving it open.
|
|
435
|
+
*
|
|
436
|
+
* Idempotent, so the handover needs no bookkeeping. Lit compares by identity, so
|
|
437
|
+
* React's next commit assigning the same value schedules no update, and one
|
|
438
|
+
* assigning a different value is a change whoever writes it.
|
|
439
|
+
*/
|
|
440
|
+
const applyReactProperties = (host) => {
|
|
441
|
+
const props = reactPropsOf(host);
|
|
442
|
+
if (props === undefined) {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
const target = host;
|
|
446
|
+
for (const name of host.constructor.elementProperties.keys()) {
|
|
447
|
+
/* `hasOwn` rather than `in`, because the bag is a plain object and a
|
|
448
|
+
component is free to declare a property called `constructor` or
|
|
449
|
+
`toString`. A symbol can be a declared property name and can never be a
|
|
450
|
+
JSX prop, so it is skipped before the lookup rather than stringified into
|
|
451
|
+
one that cannot match. */
|
|
452
|
+
if (typeof name !== 'string' || !Object.hasOwn(props, name)) {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const value = props[name];
|
|
456
|
+
if (value === null || (typeof value !== 'object' && typeof value !== 'function')) {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (target[name] !== value) {
|
|
460
|
+
target[name] = value;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return true;
|
|
464
|
+
};
|
|
465
|
+
/**
|
|
466
|
+
* @summary Base class every Helix element extends.
|
|
467
|
+
*
|
|
468
|
+
* @description
|
|
469
|
+
* Owns only what is universal: normalizing `box-sizing` inside the shadow root,
|
|
470
|
+
* so component CSS can rely on border-box on both sides of the boundary.
|
|
471
|
+
*
|
|
472
|
+
* There is deliberately no `emit(name, detail)` helper. A dispatch through a
|
|
473
|
+
* generic helper cannot be named by the manifest analyzer, which recorded the
|
|
474
|
+
* parameter identifier and produced a phantom event on every element that
|
|
475
|
+
* inherited it. Components dispatch concrete event classes from `events.ts`,
|
|
476
|
+
* which the analyzer and TypeScript can both see.
|
|
477
|
+
*
|
|
478
|
+
* @since 0.1.0
|
|
479
|
+
* @status experimental
|
|
480
|
+
* @documentation https://helix.fusion.dev/core/helix-element
|
|
481
|
+
*/
|
|
482
|
+
export class HelixElement extends LitElement {
|
|
483
|
+
/**
|
|
484
|
+
* The prefix this tier registers its elements under.
|
|
485
|
+
*
|
|
486
|
+
* **A component that renders another renders its own tier's version of it**,
|
|
487
|
+
* so `helix-headless-pagination-size` composes `helix-headless-select` and
|
|
488
|
+
* `helix-pagination-size` composes `helix-select`. One template says
|
|
489
|
+
* `${this.tag('select')}` and each tier answers with its own name.
|
|
490
|
+
*
|
|
491
|
+
* It was the public tag in both tiers, on the argument that each imports its
|
|
492
|
+
* own element under that name. That works and costs three things: the name
|
|
493
|
+
* means two different things depending on what a consumer imported, the
|
|
494
|
+
* headless tier composes an element it does not ship, and the composition
|
|
495
|
+
* cannot be tested there at all, because there is no `helix-select` on that
|
|
496
|
+
* tier to test it against. Thirty-nine specs had to be moved back to
|
|
497
|
+
* `helix/styled` for exactly that reason.
|
|
498
|
+
*
|
|
499
|
+
* **A prefix rather than an override per composition**, which was the first
|
|
500
|
+
* plan and does not compile. An override re-renders the fragment from the
|
|
501
|
+
* subclass, and the fragment reads members the base keeps private:
|
|
502
|
+
* `PaginationSize` alone would have had to widen `options`, `current` and
|
|
503
|
+
* `handleChange` to `protected` so a subclass could say the same thing again.
|
|
504
|
+
* Sixteen families of that is a lot of encapsulation spent on saying one
|
|
505
|
+
* word differently.
|
|
506
|
+
*/
|
|
507
|
+
static { this.tier = 'helix-headless-'; }
|
|
508
|
+
/**
|
|
509
|
+
* One composed tag, as a static value a template can interpolate.
|
|
510
|
+
*
|
|
511
|
+
* `unsafeStatic` is safe by construction: the name is a literal in our own
|
|
512
|
+
* source and the prefix is one of two strings this class defines. Called per
|
|
513
|
+
* render and not cached, which costs nothing, because `static-html` keys its
|
|
514
|
+
* template cache on the joined string rather than on the object.
|
|
515
|
+
*/
|
|
516
|
+
tag(name) {
|
|
517
|
+
return unsafeStatic(`${this.constructor.tier}${name}`);
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Records a boolean fact about this element as a custom state.
|
|
521
|
+
*
|
|
522
|
+
* **The host belongs to whoever rendered it, and a component's own
|
|
523
|
+
* bookkeeping may not be kept there.** An attribute written on the first
|
|
524
|
+
* update is markup a consumer's server never sent, so a React consumer
|
|
525
|
+
* hydrates a DOM that has gained attributes since and reports a mismatch it
|
|
526
|
+
* will not patch up. A custom state is not in the DOM at all: it carries the
|
|
527
|
+
* fact to a stylesheet through `:state()` and leaves the markup alone, so the
|
|
528
|
+
* server's output and the client's agree by construction.
|
|
529
|
+
*
|
|
530
|
+
* `SETTLED` carries the long version of that argument and was the first to
|
|
531
|
+
* move. `checked`, `open`, `invalid` and `has-label` followed it on 27 Aug
|
|
532
|
+
* 2026, after the same fault was reported once more from the landing page's
|
|
533
|
+
* showcase: a `default-checked` switch, a `default-checked` checkbox and a
|
|
534
|
+
* `default-open` accordion item, each writing its own resolved state onto
|
|
535
|
+
* itself before React reached the node.
|
|
536
|
+
*
|
|
537
|
+
* **The deferral that had been holding it is not a fix and could not be.**
|
|
538
|
+
* The site imports the definitions from an effect so the upgrades fall the
|
|
539
|
+
* other side of hydration, which works only while hydration is one pass:
|
|
540
|
+
* anything that splits it, a streamed segment or a suspended boundary, puts
|
|
541
|
+
* an upgrade back inside the window. Timing cannot be the guarantee when the
|
|
542
|
+
* fact is kept somewhere React reconciles.
|
|
543
|
+
*
|
|
544
|
+
* Feature-tested rather than assumed, the way `hostAria` tests element
|
|
545
|
+
* references. An engine without `CustomStateSet` records nothing and keeps
|
|
546
|
+
* whatever the resting rule draws, which is the safe direction to fail, and
|
|
547
|
+
* Lit's server shim is one of them: a custom state is a fact about a running
|
|
548
|
+
* element, and a server has nothing to say about it.
|
|
549
|
+
*/
|
|
550
|
+
toggleState(name, on) {
|
|
551
|
+
if ('states' in this.internals) {
|
|
552
|
+
this.internals.states[on ? 'add' : 'delete'](name);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
constructor() {
|
|
556
|
+
super();
|
|
557
|
+
/** Resolves `whenSettled`, once, from the frame that records the state. */
|
|
558
|
+
this.resolveSettled = () => { };
|
|
559
|
+
/**
|
|
560
|
+
* Stops an inner control's own announcement at this element's boundary.
|
|
561
|
+
*
|
|
562
|
+
* **A composed control is one control to a consumer, and the natives had no
|
|
563
|
+
* equivalent of the stop the prefixed events already get.** `<helix-select>`
|
|
564
|
+
* renders a `<helix-listbox>`, and both announce: the listbox dispatches
|
|
565
|
+
* `input` and `change` from its own host, `input` is composed by
|
|
566
|
+
* specification, and a consumer listening on the select therefore heard one
|
|
567
|
+
* choice twice. Measured before this existed, on the styled tier, one choice
|
|
568
|
+
* on the inner listbox: select `input` twice and `change` three times,
|
|
569
|
+
* combobox, date input, time input and color input twice each. `Command` was
|
|
570
|
+
* the only one that was right, because it happened to reach for
|
|
571
|
+
* `stopImmediatePropagation` for a different reason.
|
|
572
|
+
*
|
|
573
|
+
* Bound on the child in the parent's template, so it runs while the event is
|
|
574
|
+
* still at the child and before it can cross. The guard is for the case where
|
|
575
|
+
* a component binds this on itself: an element must never stop its own
|
|
576
|
+
* announcement, which is the one it exists to make.
|
|
577
|
+
*
|
|
578
|
+
* This is the same job the `helix-before-*` handlers do with their own
|
|
579
|
+
* `stopPropagation`, and it is separate rather than folded into them because
|
|
580
|
+
* a consumer may legitimately refuse a request without the notification ever
|
|
581
|
+
* happening: the two travel at different times and one can be absent.
|
|
582
|
+
*/
|
|
583
|
+
this.stopInner = (event) => {
|
|
584
|
+
if (event.target !== this) {
|
|
585
|
+
event.stopPropagation();
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
this.internals = this.attachInternals();
|
|
589
|
+
this.whenSettled = new Promise((resolve) => {
|
|
590
|
+
this.resolveSettled = resolve;
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Announces an event, having first bound whatever React is holding for it.
|
|
595
|
+
*
|
|
596
|
+
* Every event this library sends passes through here, the concrete classes in
|
|
597
|
+
* `events.ts` and the relayed native ones alike, and `requestChange`
|
|
598
|
+
* dispatches through the host as well. One override therefore covers all
|
|
599
|
+
* ninety-seven of them, which is the reason it is here rather than at each
|
|
600
|
+
* dispatch. See `bindReactHandler`.
|
|
601
|
+
*
|
|
602
|
+
* @internal
|
|
603
|
+
*/
|
|
604
|
+
dispatchEvent(event) {
|
|
605
|
+
bindReactHandler(this, event);
|
|
606
|
+
return super.dispatchEvent(event);
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Re-emits a native event from the host, under its own name.
|
|
610
|
+
*
|
|
611
|
+
* A native event fired by an inner `<input>` or `<textarea>` is not composed,
|
|
612
|
+
* so it stops at the shadow boundary and a consumer listening on
|
|
613
|
+
* `<helix-input>` hears nothing. Re-dispatching it from the host is what
|
|
614
|
+
* makes the component behave like the control it wraps.
|
|
615
|
+
*
|
|
616
|
+
* The relayed event keeps the native name deliberately. `change` is `change`,
|
|
617
|
+
* which is what a consumer already knows to listen for, and it is what
|
|
618
|
+
* reaches React: its synthetic system claims `onChange` and wires it to that
|
|
619
|
+
* name, so a prefixed one would never arrive.
|
|
620
|
+
*
|
|
621
|
+
* `composed` is not forced. It defaults to the original event's own value, so
|
|
622
|
+
* a relayed `change` stops at the next shadow boundary exactly as a native
|
|
623
|
+
* one would. Pass it explicitly only where escaping is the intent, and say so
|
|
624
|
+
* where you do.
|
|
625
|
+
*/
|
|
626
|
+
relayNativeEvent(event, init) {
|
|
627
|
+
this.dispatchEvent(new Event(event.type, {
|
|
628
|
+
bubbles: event.bubbles,
|
|
629
|
+
cancelable: event.cancelable,
|
|
630
|
+
composed: event.composed,
|
|
631
|
+
...init,
|
|
632
|
+
}));
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Prepends the shadow-root reset to every subclass's styles, so no component
|
|
636
|
+
* has to remember to include it.
|
|
637
|
+
*/
|
|
638
|
+
static finalizeStyles(styles) {
|
|
639
|
+
return [BOX_SIZING, HIDDEN, SCROLLER, SETTLED, ...LitElement.finalizeStyles(styles)];
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Marks the component's first style resolution as done, so `SETTLED` stops
|
|
643
|
+
* suppressing transitions.
|
|
644
|
+
*
|
|
645
|
+
* A frame later, not now: see `SETTLED` for why `firstUpdated` itself is too
|
|
646
|
+
* early. SSR never calls this, which is correct, because the attribute
|
|
647
|
+
* describes something that happened in a browser and the server has no frame
|
|
648
|
+
* to wait for.
|
|
649
|
+
*/
|
|
650
|
+
firstUpdated(changed) {
|
|
651
|
+
super.firstUpdated(changed);
|
|
652
|
+
/* **The first of two looks, and the one that costs nothing.** It succeeds
|
|
653
|
+
whenever the definition landed after React hydrated, which is the ordering
|
|
654
|
+
a deferred or lazily imported element has: the bag is already on the node,
|
|
655
|
+
and the update this assignment schedules is a microtask, so the value is
|
|
656
|
+
rendered in the same task the upgrade happened in. See
|
|
657
|
+
`applyReactProperties`. */
|
|
658
|
+
const applied = applyReactProperties(this);
|
|
659
|
+
requestAnimationFrame(() => {
|
|
660
|
+
/* Through `toggleState`, which carries the feature test. A component that
|
|
661
|
+
cannot record the state keeps its transitions suppressed, which is the
|
|
662
|
+
safe direction to fail: nothing animates that should not. */
|
|
663
|
+
this.toggleState('settled', true);
|
|
664
|
+
this.resolveSettled();
|
|
665
|
+
/* **Audited here as well as at dispatch, and this is the half that catches
|
|
666
|
+
the real mistake.** The audit ran only from inside `dispatchEvent`, so an
|
|
667
|
+
element holding a handler prop and sending nothing was never looked at.
|
|
668
|
+
A prop on the wrong element is exactly the case where that element has no
|
|
669
|
+
reason to dispatch, so the check could not reach the thing it is for.
|
|
670
|
+
|
|
671
|
+
A frame late, alongside the settled state, because React has to have
|
|
672
|
+
committed its props for there to be anything to read. Once per element
|
|
673
|
+
either way: the audit holds its own set. */
|
|
674
|
+
const props = reactPropsOf(this);
|
|
675
|
+
if (props !== undefined) {
|
|
676
|
+
/* **The other ordering, and the second and last look.** A definition
|
|
677
|
+
evaluated with the client bundle upgrades before React hydrates, so
|
|
678
|
+
there was no bag to read above. A frame callback runs in the rendering
|
|
679
|
+
step *before* paint and the Lit update it schedules is a microtask, so
|
|
680
|
+
the value still reaches the screen in this frame rather than after an
|
|
681
|
+
empty one.
|
|
682
|
+
|
|
683
|
+
Two looks and no loop, because the only ordering neither covers is
|
|
684
|
+
React splitting hydration across frames, which nothing here has
|
|
685
|
+
produced. If a component ever shows up empty on a page large enough to
|
|
686
|
+
do that, this is the line to raise, and the measurement is what should
|
|
687
|
+
decide by how much rather than a number chosen now. */
|
|
688
|
+
if (!applied) {
|
|
689
|
+
applyReactProperties(this);
|
|
690
|
+
}
|
|
691
|
+
auditReactProps(this, props);
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
}
|