@fusion.dev/helix-core 0.0.0-dev

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