@fusion.dev/helix-core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/custom-elements.json +4 -4
  2. package/dist/controllers/aria-forward-controller/aria-forward-controller.js +0 -105
  3. package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +0 -71
  4. package/dist/controllers/command-controller/command-controller.js +0 -62
  5. package/dist/controllers/copy-controller/copy-controller.js +0 -63
  6. package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.js +0 -40
  7. package/dist/controllers/dismiss-controller/dismiss-controller.js +0 -124
  8. package/dist/controllers/drilldown-controller/drilldown-controller.js +0 -87
  9. package/dist/controllers/floating-controller/floating-controller.js +0 -152
  10. package/dist/controllers/focus-trap-controller/focus-trap-controller.js +0 -35
  11. package/dist/controllers/indicator-controller/indicator-controller.js +0 -82
  12. package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.js +0 -46
  13. package/dist/controllers/popover-controller/popover-controller.js +0 -72
  14. package/dist/controllers/source-controller/source-controller.js +0 -38
  15. package/dist/controllers/token-color-controller/token-color-controller.js +0 -51
  16. package/dist/controllers/virtualize-controller/virtualize-controller.js +0 -128
  17. package/dist/events/element-events.js +0 -22
  18. package/dist/events/events.js +0 -386
  19. package/dist/helix-element/helix-element.d.ts +10 -17
  20. package/dist/helix-element/helix-element.js +0 -505
  21. package/dist/helix-form-element/helix-form-element.js +0 -140
  22. package/dist/mixins/mixin-chart-source/mixin-chart-source.js +0 -77
  23. package/dist/mixins/mixin-collection/mixin-collection.js +0 -83
  24. package/dist/mixins/mixin-controller-active/mixin-controller-active.js +0 -82
  25. package/dist/mixins/mixin-controller-checked/mixin-controller-checked.js +0 -78
  26. package/dist/mixins/mixin-controller-open/mixin-controller-open.js +0 -93
  27. package/dist/mixins/mixin-controller-value/mixin-controller-value.js +0 -87
  28. package/dist/mixins/mixin-dismissible/mixin-dismissible.js +0 -38
  29. package/dist/mixins/mixin-field/mixin-field.js +0 -277
  30. package/dist/utils/anchor-element/anchor-element.js +0 -23
  31. package/dist/utils/capture-pointer/capture-pointer.js +0 -18
  32. package/dist/utils/closest-instance/closest-instance.js +0 -20
  33. package/dist/utils/create-sheet/create-sheet.js +0 -14
  34. package/dist/utils/custom-element/custom-element.js +0 -83
  35. package/dist/utils/draw-icon/draw-icon.js +0 -28
  36. package/dist/utils/draw-tooltip/draw-tooltip.js +1 -50
  37. package/dist/utils/elapsed-time/elapsed-time.js +0 -51
  38. package/dist/utils/focusable-elements/focusable-elements.js +1 -59
  39. package/dist/utils/format-numbers/format-numbers.js +0 -16
  40. package/dist/utils/host-aria/host-aria.js +0 -26
  41. package/dist/utils/inherit-disabled/inherit-disabled.js +0 -36
  42. package/dist/utils/line-path/line-path.js +0 -36
  43. package/dist/utils/on-unless-false/on-unless-false.js +0 -23
  44. package/dist/utils/read-series/read-series.js +0 -71
  45. package/dist/utils/relative-time/relative-time.js +0 -43
  46. package/dist/utils/resolve-active-item/resolve-active-item.js +0 -20
  47. package/dist/utils/restore-value/restore-value.js +0 -54
  48. package/dist/utils/scale/scale.js +0 -19
  49. package/dist/utils/scale/ticks.js +0 -36
  50. package/dist/utils/scroll-item-into-view/scroll-item-into-view.js +0 -53
  51. package/dist/utils/slot-filled/slot-filled.js +0 -23
  52. package/dist/utils/tell-disclosure/tell-disclosure.js +0 -53
  53. package/dist/utils/ticker/ticker.js +0 -22
  54. package/dist/utils/tone-marks/tone-marks.js +0 -24
  55. package/dist/utils/value-as/value-as.js +0 -56
  56. package/package.json +5 -5
@@ -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
  }