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