@fusion.dev/helix-core 0.25.1 → 0.30.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 (36) hide show
  1. package/custom-elements.json +833 -114
  2. package/dist/controllers/chart-pointer-controller/chart-pointer-controller.d.ts +23 -1
  3. package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +23 -26
  4. package/dist/controllers/dismiss-controller/dismiss-controller.d.ts +27 -0
  5. package/dist/controllers/dismiss-controller/dismiss-controller.js +17 -2
  6. package/dist/controllers/floating-controller/floating-controller.d.ts +13 -0
  7. package/dist/controllers/floating-controller/floating-controller.js +3 -1
  8. package/dist/controllers/indicator-controller/indicator-controller.d.ts +16 -0
  9. package/dist/controllers/indicator-controller/indicator-controller.js +7 -0
  10. package/dist/controllers/popover-controller/popover-controller.d.ts +22 -0
  11. package/dist/controllers/popover-controller/popover-controller.js +12 -0
  12. package/dist/controllers/source-controller/source-controller.d.ts +45 -3
  13. package/dist/controllers/source-controller/source-controller.js +33 -21
  14. package/dist/helix-element/helix-element.d.ts +19 -0
  15. package/dist/helix-element/helix-element.js +5 -94
  16. package/dist/helix-element/react-bridge.d.ts +104 -0
  17. package/dist/helix-element/react-bridge.js +94 -0
  18. package/dist/helix-form-element/helix-form-element.d.ts +19 -0
  19. package/dist/helix-form-element/helix-form-element.js +6 -0
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.js +2 -1
  22. package/dist/mixins/mixin-chart-source/mixin-chart-source.d.ts +1 -1
  23. package/dist/mixins/mixin-chart-source/mixin-chart-source.js +2 -5
  24. package/dist/mixins/mixin-controller-value/mixin-controller-value.js +2 -2
  25. package/dist/mixins/mixin-field/mixin-field.js +2 -2
  26. package/dist/mixins/mixin-picker-editing/mixin-picker-editing.d.ts +48 -0
  27. package/dist/mixins/mixin-picker-editing/mixin-picker-editing.js +43 -0
  28. package/dist/mixins/mixin-picker-field/mixin-picker-field.d.ts +118 -0
  29. package/dist/mixins/mixin-picker-field/mixin-picker-field.js +79 -0
  30. package/dist/utils/draw-icon/draw-icon.d.ts +1 -1
  31. package/dist/utils/draw-tooltip/draw-tooltip.js +1 -0
  32. package/package.json +4 -4
  33. package/dist/mixins/mixin-collection/index.d.ts +0 -1
  34. package/dist/mixins/mixin-collection/index.js +0 -1
  35. package/dist/utils/close-markdown/close-markdown.d.ts +0 -39
  36. package/dist/utils/close-markdown/close-markdown.js +0 -51
@@ -35,7 +35,9 @@ export interface ChartPointerHost extends ReactiveControllerHost, HTMLElement {
35
35
  * `pointerType` is not fine.
36
36
  */
37
37
  export declare class ChartPointerController implements ReactiveController {
38
- #private;
38
+ private host;
39
+ private moved;
40
+ private plot;
39
41
  /** The reading under the pointer, or -1 when the pointer is elsewhere. */
40
42
  at: number;
41
43
  /**
@@ -73,6 +75,26 @@ export declare class ChartPointerController implements ReactiveController {
73
75
  * position until the render that follows.
74
76
  */
75
77
  hostUpdated(): void;
78
+ /**
79
+ * The box the readings are laid out in, which is the plot and not the host.
80
+ *
81
+ * The host also holds the row of labels, so measuring against it would map
82
+ * the pointer over a box taller than the drawing. That mattered the moment a
83
+ * chart started measuring down the box rather than across it, which is what
84
+ * `axis` is for: on a horizontal bar chart the labels are beside the plot
85
+ * rather than under it, and the host is wider than the drawing by exactly
86
+ * their column.
87
+ *
88
+ * **Held only while it is still in the tree.** Every chart renders a
89
+ * different template for its empty state, so the plot a pointer crossed
90
+ * before the table filled is a node Lit has since replaced: held forever, it
91
+ * measures a detached box and the tooltip never appears. Looked up again
92
+ * whenever the held one has left, which is once per data arrival rather than
93
+ * once per move.
94
+ */
95
+ private box;
76
96
  /** Puts the pointer on a reading, for a chart that resolves it some other way. */
77
97
  set(at: number): void;
98
+ private readonly move;
99
+ private readonly leave;
78
100
  }
@@ -1,17 +1,14 @@
1
1
  export class ChartPointerController {
2
- #host;
3
- #moved;
4
- #plot;
5
2
  constructor(host) {
6
- this.#moved = false;
3
+ this.moved = false;
7
4
  this.at = -1;
8
5
  this.axis = 'inline';
9
- this.#move = (event) => {
6
+ this.move = (event) => {
10
7
  if (this.axis === 'none' || (event.pointerType !== 'mouse' && event.pointerType !== 'pen')) {
11
8
  return;
12
9
  }
13
- const box = this.#box();
14
- const count = this.#host.readings;
10
+ const box = this.box();
11
+ const count = this.host.readings;
15
12
  const along = this.axis === 'block' ? box?.height : box?.width;
16
13
  if (!box || !along || count === 0) {
17
14
  return;
@@ -21,48 +18,48 @@ export class ChartPointerController {
21
18
  const next = Math.min(Math.max(slot, 0), count - 1);
22
19
  if (next !== this.at) {
23
20
  this.at = next;
24
- this.#moved = true;
25
- this.#host.requestUpdate();
21
+ this.moved = true;
22
+ this.host.requestUpdate();
26
23
  }
27
24
  };
28
- this.#leave = () => {
25
+ this.leave = () => {
29
26
  if (this.at !== -1) {
30
27
  this.at = -1;
31
- this.#host.requestUpdate();
28
+ this.host.requestUpdate();
32
29
  }
33
30
  };
34
- this.#host = host;
31
+ this.host = host;
35
32
  host.addController(this);
36
33
  }
37
34
  hostConnected() {
38
- this.#host.addEventListener('pointermove', this.#move);
39
- this.#host.addEventListener('pointerleave', this.#leave);
35
+ this.host.addEventListener('pointermove', this.move);
36
+ this.host.addEventListener('pointerleave', this.leave);
40
37
  }
41
38
  hostDisconnected() {
42
- this.#host.removeEventListener('pointermove', this.#move);
43
- this.#host.removeEventListener('pointerleave', this.#leave);
39
+ this.host.removeEventListener('pointermove', this.move);
40
+ this.host.removeEventListener('pointerleave', this.leave);
44
41
  this.at = -1;
45
42
  }
46
43
  hostUpdated() {
47
- if (!this.#moved) {
44
+ if (!this.moved) {
48
45
  return;
49
46
  }
50
- this.#moved = false;
51
- this.#host.renderRoot
47
+ this.moved = false;
48
+ this.host.renderRoot
52
49
  .querySelector('[part="tooltip-anchor"]')
53
50
  ?.reposition?.();
54
51
  }
55
- #box() {
56
- this.#plot ??= this.#host.renderRoot.querySelector('[part="plot"]') ?? undefined;
57
- return this.#plot?.getBoundingClientRect();
52
+ box() {
53
+ if (!this.plot?.isConnected) {
54
+ this.plot = this.host.renderRoot.querySelector('[part="plot"]') ?? undefined;
55
+ }
56
+ return this.plot?.getBoundingClientRect();
58
57
  }
59
58
  set(at) {
60
59
  if (at !== this.at) {
61
60
  this.at = at;
62
- this.#moved = true;
63
- this.#host.requestUpdate();
61
+ this.moved = true;
62
+ this.host.requestUpdate();
64
63
  }
65
64
  }
66
- #move;
67
- #leave;
68
65
  }
@@ -74,8 +74,35 @@ export declare class DismissController implements ReactiveController {
74
74
  * null in some browsers, so the read is guarded and the frame is simply
75
75
  * skipped. Nothing is lost by skipping it that was not already lost: there is
76
76
  * no listener to add to a document this one may not touch.
77
+ *
78
+ * **And walked only where the page holds a frame at all.** The walk is every
79
+ * element on the page, through every shadow root, and it ran on every
80
+ * `activate`, which is every tooltip hover and every menu opening. Measured
81
+ * in Chromium on 14 Sep 2026, on a page of 10,006 elements across 500 shadow
82
+ * roots, which is the shape of a docs page: 1.36ms per walk, against a
83
+ * length read on `getElementsByTagName('iframe')` too small for
84
+ * `performance.now` to resolve over 20,000 runs. That collection is live and
85
+ * the engine keeps its count, so asking it is a read rather than a search.
86
+ *
87
+ * **What the count cannot see is a frame inside a shadow root**, and that is
88
+ * a trade made knowingly rather than an oversight. A page whose only frame
89
+ * sits in a shadow root reads as having none and skips the walk, so a
90
+ * same-origin frame rendered by a component is not listened to there. The
91
+ * one frame this library renders inside a shadow root is the sandbox's
92
+ * preview, which is sandboxed without `allow-same-origin` and so is a
93
+ * document this one may not read whether or not it is found: the blur
94
+ * signal is what covers it, and the walk never could. A page with a frame in
95
+ * the light DOM still walks, shadow roots included.
96
+ *
97
+ * Held for the rest of the run as well, so overlays that activate together,
98
+ * a dialog opening a select from its own `updated`, walk once between them.
99
+ * A microtask and no longer: what a frame gathered here is good for is the
100
+ * lifetime of one open, and the task after this one is a page that may have
101
+ * gained a frame.
77
102
  */
78
103
  private static frameDocuments;
104
+ /** What the last walk found, for the rest of the run it was made in. */
105
+ private static found;
79
106
  /**
80
107
  * The frame documents being listened to, held so the same ones are released.
81
108
  *
@@ -8,7 +8,12 @@ const deepDepth = (node) => {
8
8
  }
9
9
  return depth;
10
10
  };
11
- const listening = new Set();
11
+ const REGISTRY = Symbol.for('fusion.helix.dismiss.listening');
12
+ const globals = globalThis;
13
+ if (globals[REGISTRY] === undefined) {
14
+ globals[REGISTRY] = new Set();
15
+ }
16
+ const listening = globals[REGISTRY];
12
17
  export class DismissController {
13
18
  constructor(host, { boundary, onDismiss }) {
14
19
  this.active = false;
@@ -54,6 +59,12 @@ export class DismissController {
54
59
  host.addController(this);
55
60
  }
56
61
  static frameDocuments() {
62
+ if (DismissController.found) {
63
+ return DismissController.found;
64
+ }
65
+ if (document.getElementsByTagName('iframe').length === 0) {
66
+ return [];
67
+ }
57
68
  const found = [];
58
69
  const walk = (root) => {
59
70
  for (const element of root.querySelectorAll('*')) {
@@ -73,6 +84,10 @@ export class DismissController {
73
84
  }
74
85
  };
75
86
  walk(document);
87
+ DismissController.found = found;
88
+ queueMicrotask(() => {
89
+ DismissController.found = undefined;
90
+ });
76
91
  return found;
77
92
  }
78
93
  activate() {
@@ -84,7 +99,7 @@ export class DismissController {
84
99
  document.addEventListener('keydown', this.handleKeydown);
85
100
  document.addEventListener('pointerdown', this.handlePointerDown);
86
101
  window.addEventListener('blur', this.handleWindowBlur);
87
- this.frames = DismissController.frameDocuments();
102
+ this.frames = [...DismissController.frameDocuments()];
88
103
  for (const inner of this.frames) {
89
104
  inner.addEventListener('pointerdown', this.handlePointerDown);
90
105
  }
@@ -112,6 +112,19 @@ export declare class FloatingController implements ReactiveController {
112
112
  constructor(host: ReactiveControllerHost & HTMLElement, options: FloatingOptions);
113
113
  /** The placement in use after the last measurement. */
114
114
  placement: Placement;
115
+ /**
116
+ * How far the arrow keeps from the floating element's ends, measured once.
117
+ *
118
+ * `autoUpdate` runs `update` on every scroll frame of every ancestor, and
119
+ * `corner` is a `getComputedStyle` and four parses, which was a style read
120
+ * per frame for a number that does not change while the surface is up. So it
121
+ * is read as placing starts, when the surface has just been shown and has a
122
+ * computed style to read, and forgotten as placing stops, so a surface
123
+ * restyled between opens is measured again. `update` measures for itself
124
+ * where nothing started it, which is a `reposition` on a surface placed by
125
+ * its own stylesheet.
126
+ */
127
+ private padding;
115
128
  /** Starts placing, and keeps placing until stopped. */
116
129
  start(): void;
117
130
  /** Stops placing and releases everything it was tracking. */
@@ -53,7 +53,7 @@ export class FloatingController {
53
53
  }));
54
54
  }
55
55
  if (pointer) {
56
- middleware.push(arrow({ element: pointer, padding: corner(floating) }));
56
+ middleware.push(arrow({ element: pointer, padding: this.padding ?? corner(floating) }));
57
57
  }
58
58
  computePosition(anchor, floating, {
59
59
  middleware,
@@ -92,12 +92,14 @@ export class FloatingController {
92
92
  if (!anchor || !floating || this.cleanup) {
93
93
  return;
94
94
  }
95
+ this.padding = this.options.arrow?.() ? corner(floating) : 0;
95
96
  this.cleanup = autoUpdate(anchor, floating, this.update);
96
97
  }
97
98
  stop() {
98
99
  this.run += 1;
99
100
  this.cleanup?.();
100
101
  this.cleanup = undefined;
102
+ this.padding = undefined;
101
103
  this.options.floating()?.removeAttribute('data-placed');
102
104
  }
103
105
  hostDisconnected() {
@@ -7,6 +7,20 @@ export interface IndicatorOptions {
7
7
  active: () => HTMLElement | null | undefined;
8
8
  /** The traveling mark. Positioned against the surface. */
9
9
  indicator: () => HTMLElement | null | undefined;
10
+ /**
11
+ * Elements whose own size moves the mark, beyond the host.
12
+ *
13
+ * The host is always watched, and it is not enough on its own: an item can
14
+ * change width inside a strip that keeps its size, and the host never fires
15
+ * for that. So a component hands over its items, and they are watched too.
16
+ *
17
+ * Read again on every placement rather than once, because the set is not
18
+ * settled when the controller connects: a tab strip's tabs have not
19
+ * upgraded by its first update, so a list read then is empty, and watching
20
+ * nothing is silent. Observing the same element twice is a no-op, so the
21
+ * re-read costs nothing and needs no bookkeeping about who is watched.
22
+ */
23
+ observe?: () => readonly Element[];
10
24
  /**
11
25
  * The box the mark is positioned against, which is its containing block.
12
26
  *
@@ -61,6 +75,8 @@ export declare class IndicatorController implements ReactiveController {
61
75
  private resize;
62
76
  constructor(host: ReactiveControllerHost & HTMLElement, options: IndicatorOptions);
63
77
  hostConnected(): void;
78
+ /** Watches whatever `observe` names now, which `place` asks for on every placement. */
79
+ private watch;
64
80
  hostDisconnected(): void;
65
81
  /** Called after every update, which is when the marked child may have moved. */
66
82
  hostUpdated(): void;
@@ -7,6 +7,12 @@ export class IndicatorController {
7
7
  hostConnected() {
8
8
  this.resize = new ResizeObserver(() => this.place());
9
9
  this.resize.observe(this.host);
10
+ this.watch();
11
+ }
12
+ watch() {
13
+ for (const element of this.options.observe?.() ?? []) {
14
+ this.resize?.observe(element);
15
+ }
10
16
  }
11
17
  hostDisconnected() {
12
18
  this.resize?.disconnect();
@@ -17,6 +23,7 @@ export class IndicatorController {
17
23
  this.place();
18
24
  }
19
25
  place() {
26
+ this.watch();
20
27
  const indicator = this.options.indicator();
21
28
  const surface = this.options.surface();
22
29
  const active = this.options.active();
@@ -56,6 +56,26 @@ export interface PopoverOptions {
56
56
  * select puts focus back on its trigger, and a tooltip does neither.
57
57
  */
58
58
  onDismiss: () => void;
59
+ /**
60
+ * Whether the host wants the surface up, read after every update.
61
+ *
62
+ * **The open state is a property, and a consumer may set it directly**, so
63
+ * raising the surface from a `show()` method alone leaves a panel opened by
64
+ * declaring `open` visible, out of the top layer and unanchored: every
65
+ * imperative path works and every declarative one is half-open. Ten
66
+ * families wrote the same `if (this.open) show() else hide()` into their
67
+ * `updated()` for that reason, and this is that paragraph once.
68
+ *
69
+ * After the update and never before it, because the render has to have
70
+ * been written: until then the surface is still `display: none` and
71
+ * `showPopover` on it measures zeroes. A controller's `hostUpdated` runs
72
+ * after the commit and before the host's own `updated`, so a host that has
73
+ * to act on a surface that is up, focusing into it or centering a wheel,
74
+ * finds it up.
75
+ *
76
+ * Left unset, the host drives `show()` and `hide()` itself.
77
+ */
78
+ open?: () => boolean;
59
79
  /** Preferred side. It moves if that side does not fit. */
60
80
  placement?: Placement | (() => Placement);
61
81
  /** The panel that floats. */
@@ -99,6 +119,8 @@ export declare class PopoverController implements ReactiveController {
99
119
  constructor(host: ReactiveControllerHost & HTMLElement, options: PopoverOptions);
100
120
  /** Whether the surface is showing. */
101
121
  get open(): boolean;
122
+ /** Puts the surface where the host's `open` says, however it got there. */
123
+ hostUpdated(): void;
102
124
  /** Whether this one is placed by measurement, read fresh so it may change. */
103
125
  private get places();
104
126
  /**
@@ -21,6 +21,18 @@ export class PopoverController {
21
21
  get open() {
22
22
  return this.shown;
23
23
  }
24
+ hostUpdated() {
25
+ const wanted = this.options.open?.();
26
+ if (wanted === undefined) {
27
+ return;
28
+ }
29
+ if (wanted) {
30
+ this.show();
31
+ }
32
+ else {
33
+ this.hide();
34
+ }
35
+ }
24
36
  get places() {
25
37
  const asked = this.options.place;
26
38
  if (asked === undefined) {
@@ -1,5 +1,5 @@
1
1
  import type { ReactiveController, ReactiveControllerHost } from 'lit';
2
- import { type Series } from '../../utils/read-series/read-series.js';
2
+ import { type Row, type Series } from '../../utils/read-series/read-series.js';
3
3
  /** A host that names the element it reads from. */
4
4
  export interface SourceHost extends ReactiveControllerHost, HTMLElement {
5
5
  /**
@@ -28,7 +28,20 @@ export interface SourceHost extends ReactiveControllerHost, HTMLElement {
28
28
  * and nothing in a page has reason to know a chart is reading it.
29
29
  */
30
30
  export declare class SourceController implements ReactiveController {
31
- #private;
31
+ private host;
32
+ private observer;
33
+ /** Whether a hydrating render was told there was nothing, and needs the render after. */
34
+ private deferred;
35
+ /**
36
+ * Which edit of the table the parses below belong to.
37
+ *
38
+ * Moved by the watcher and by `watch()`, which are the two things that can
39
+ * change what a chart reads without the chart doing anything: an edit to the
40
+ * table, and `for` naming another one.
41
+ */
42
+ private version;
43
+ private heldRows;
44
+ private heldSeries;
32
45
  constructor(host: SourceHost);
33
46
  /**
34
47
  * The element `for` names, or null where there is nothing to name it in.
@@ -42,8 +55,37 @@ export declare class SourceController implements ReactiveController {
42
55
  * a template Lit throws on. The update after it, which this asks for, draws.
43
56
  */
44
57
  get element(): Element | null;
45
- /** The series it holds, or none. */
58
+ /**
59
+ * The element `for` names, whatever the host is rendering.
60
+ *
61
+ * Apart from `element` because the watcher needs the table while a render
62
+ * cannot have it: attached on connect, which on a server's page is before
63
+ * the render that hydrates, it resolved nothing there and never watched, so a
64
+ * table a page filled afterwards drew no line.
65
+ */
66
+ private resolve;
67
+ /**
68
+ * The series it holds, or none.
69
+ *
70
+ * **Parsed once per edit, not once per read.** A chart's render asks for
71
+ * these several times over, and the pointer asks for the row count on every
72
+ * move, so parsing the whole table on each read was a table walk per
73
+ * pointermove. The parse is held against the table it came from and the
74
+ * edit it saw, and let go when either moves: `element` is compared by
75
+ * identity, so a table replaced under the same id parses again, and the
76
+ * watcher moves the version on every mutation it delivers.
77
+ *
78
+ * Which is a microtask after the edit, the observer being what it is. A
79
+ * read in the same run as the edit answers the parse before it, and every
80
+ * render that follows, which is what the watcher exists to ask for, answers
81
+ * the one after. The same array comes back until then, so a consumer may
82
+ * compare by identity.
83
+ */
46
84
  get series(): Series[];
85
+ /** Every row it holds, text and number alike, or none. Held the way `series` is. */
86
+ get rows(): Row[];
87
+ /** A held parse where it still describes the table, or a fresh one. */
88
+ private parsed;
47
89
  hostConnected(): void;
48
90
  hostUpdated(): void;
49
91
  hostDisconnected(): void;
@@ -1,21 +1,20 @@
1
- import { readSeries } from '../../utils/read-series/read-series.js';
1
+ import { readRows, readSeries } from '../../utils/read-series/read-series.js';
2
2
  export class SourceController {
3
- #host;
4
- #observer;
5
- #deferred = false;
6
3
  constructor(host) {
7
- this.#host = host;
4
+ this.deferred = false;
5
+ this.version = 0;
6
+ this.host = host;
8
7
  host.addController(this);
9
8
  }
10
9
  get element() {
11
- if (this.#host.hydrating === true) {
12
- this.#deferred = this.#host.for !== '';
10
+ if (this.host.hydrating === true) {
11
+ this.deferred = this.host.for !== '';
13
12
  return null;
14
13
  }
15
- return this.#resolve();
14
+ return this.resolve();
16
15
  }
17
- #resolve() {
18
- const host = this.#host;
16
+ resolve() {
17
+ const host = this.host;
19
18
  if (host.for === '') {
20
19
  return null;
21
20
  }
@@ -33,32 +32,45 @@ export class SourceController {
33
32
  : null;
34
33
  }
35
34
  get series() {
35
+ this.heldSeries = this.parsed(this.heldSeries, (table) => (table ? readSeries(table) : []));
36
+ return this.heldSeries.value;
37
+ }
38
+ get rows() {
39
+ this.heldRows = this.parsed(this.heldRows, (table) => (table ? readRows(table) : []));
40
+ return this.heldRows.value;
41
+ }
42
+ parsed(held, read) {
36
43
  const element = this.element;
37
- return element ? readSeries(element) : [];
44
+ if (held && held.element === element && held.version === this.version) {
45
+ return held;
46
+ }
47
+ return { element, value: read(element), version: this.version };
38
48
  }
39
49
  hostConnected() {
40
50
  this.watch();
41
51
  }
42
52
  hostUpdated() {
43
- if (this.#deferred) {
44
- this.#deferred = false;
45
- this.#host.requestUpdate();
53
+ if (this.deferred) {
54
+ this.deferred = false;
55
+ this.host.requestUpdate();
46
56
  }
47
57
  }
48
58
  hostDisconnected() {
49
- this.#observer?.disconnect();
50
- this.#observer = undefined;
59
+ this.observer?.disconnect();
60
+ this.observer = undefined;
51
61
  }
52
62
  watch() {
53
- this.#observer?.disconnect();
54
- const element = this.#resolve();
63
+ this.observer?.disconnect();
64
+ this.version += 1;
65
+ const element = this.resolve();
55
66
  if (!element || typeof MutationObserver === 'undefined') {
56
67
  return;
57
68
  }
58
- this.#observer ??= new MutationObserver(() => {
59
- this.#host.requestUpdate();
69
+ this.observer ??= new MutationObserver(() => {
70
+ this.version += 1;
71
+ this.host.requestUpdate();
60
72
  });
61
- this.#observer.observe(element, {
73
+ this.observer.observe(element, {
62
74
  characterData: true,
63
75
  childList: true,
64
76
  subtree: true,
@@ -50,6 +50,25 @@ export declare class HelixElement extends LitElement {
50
50
  * template cache on the joined string rather than on the object.
51
51
  */
52
52
  protected tag(name: string): StaticValue;
53
+ /**
54
+ * One of this element's own parts, by name, or null where it is not rendered.
55
+ *
56
+ * Not `part`, which every element already has: `Element.part` is the
57
+ * `DOMTokenList` of the names the element itself carries, and a method under
58
+ * that name is a type error on every subclass.
59
+ *
60
+ * **One guard for every lookup.** Lit's server shim has no `querySelector`
61
+ * on the render root, so a lookup reached during a server render, from a
62
+ * getter a template reads or from `willUpdate`, throws there and nowhere
63
+ * else. Four spellings of the same guard had grown across the library, and
64
+ * a fifth site with none. Null on the server, which is what the rest of the
65
+ * code already treats a part that has not rendered as.
66
+ *
67
+ * `helix component`'s `selectors` reads these calls the way it reads a
68
+ * `querySelector('[part="x"]')`, so a name nothing renders is a finding
69
+ * rather than a null an optional chain swallows.
70
+ */
71
+ protected partNamed<T extends Element = HTMLElement>(name: string): T | null;
53
72
  /**
54
73
  * The element's semantics, set without touching the DOM.
55
74
  *
@@ -1,7 +1,6 @@
1
1
  import { css, LitElement } from 'lit';
2
2
  import { unsafeStatic } from 'lit/static-html.js';
3
- import { eventsForTag } from '../events/element-events.js';
4
- import { helixEventNames } from '../events/events.js';
3
+ import { applyReactProperties, auditReactProps, bindReactHandler, reactPropsOf, } from './react-bridge.js';
5
4
  const BOX_SIZING = css `
6
5
  *,
7
6
  ::before,
@@ -25,103 +24,15 @@ const SETTLED = css `
25
24
  transition: none !important;
26
25
  }
27
26
  `;
28
- const REACT_PROPS = '__reactProps$';
29
- let reactPropsKey;
30
- const reactPropsOf = (node) => {
31
- const stash = node;
32
- if (reactPropsKey !== undefined && stash[reactPropsKey] !== undefined) {
33
- return stash[reactPropsKey];
34
- }
35
- const found = Object.keys(stash).find((key) => key.startsWith(REACT_PROPS));
36
- if (found === undefined) {
37
- return undefined;
38
- }
39
- reactPropsKey = found;
40
- return stash[found];
41
- };
42
- const audited = new WeakSet();
43
- const REMOVED = new Set(['onActiveChange', 'onCheckedChange', 'onOpenChange', 'onValueChange']);
44
- const auditReactProps = (host, props) => {
45
- if (audited.has(host)) {
46
- return;
47
- }
48
- audited.add(host);
49
- const tag = host instanceof Element ? `<${host.localName}>` : 'A Helix element';
50
- for (const key of Object.keys(props)) {
51
- if (key.startsWith('on:')) {
52
- console.warn(`${tag} was given \`${key}\`, which is Solid's syntax. React binds the name after \`on\` ` +
53
- `verbatim, so this listens for an event called \`${key.slice(2)}\`. Write \`on${key.slice(3)}\`.`);
54
- continue;
55
- }
56
- if (REMOVED.has(key)) {
57
- console.warn(`${tag} was given \`${key}\`, which was removed. It never worked from JSX, because React ` +
58
- `binds any \`on\` prop as an event of that name. Listen for the element's change event instead.`);
59
- continue;
60
- }
61
- if (!key.startsWith('on') || !key.includes('-')) {
62
- continue;
63
- }
64
- const name = key.slice(2);
65
- if (!helixEventNames.has(name)) {
66
- console.warn(`${tag} was given \`${key}\`, and nothing dispatches \`${name}\`. TypeScript cannot ` +
67
- `check a hyphenated JSX prop, so this is the only place it shows up.`);
68
- continue;
69
- }
70
- const mine = host instanceof Element ? eventsForTag(host.localName) : undefined;
71
- if (mine && !mine.has(name)) {
72
- console.warn(`${tag} was given \`${key}\`, but it dispatches ${[...mine].map((one) => `\`${one}\``).join(', ')}. ` +
73
- `\`${name}\` is sent by a different element, and React only wires an \`on\` prop on the ` +
74
- 'element that dispatches, so this handler will never run. Put it on that element.');
75
- }
76
- }
77
- };
78
- const REACT_WIRES = new Set(['scroll', 'scrollend']);
79
- const reactPropFor = (event) => {
80
- if (event.type.includes('-')) {
81
- return `on${event.type}`;
82
- }
83
- if (event.bubbles || REACT_WIRES.has(event.type)) {
84
- return undefined;
85
- }
86
- return `on${event.type.charAt(0).toUpperCase()}${event.type.slice(1)}`;
87
- };
88
- const bindReactHandler = (host, event) => {
89
- const props = reactPropsOf(host);
90
- if (props === undefined) {
91
- return;
92
- }
93
- auditReactProps(host, props);
94
- const key = reactPropFor(event);
95
- const handler = key === undefined ? undefined : props[key];
96
- if (typeof handler === 'function') {
97
- host.addEventListener(event.type, handler);
98
- }
99
- };
100
- const applyReactProperties = (host) => {
101
- const props = reactPropsOf(host);
102
- if (props === undefined) {
103
- return false;
104
- }
105
- const target = host;
106
- for (const name of host.constructor.elementProperties.keys()) {
107
- if (typeof name !== 'string' || !Object.hasOwn(props, name)) {
108
- continue;
109
- }
110
- const value = props[name];
111
- if (value === null || (typeof value !== 'object' && typeof value !== 'function')) {
112
- continue;
113
- }
114
- if (target[name] !== value) {
115
- target[name] = value;
116
- }
117
- }
118
- return true;
119
- };
120
27
  export class HelixElement extends LitElement {
121
28
  static { this.tier = 'helix-headless-'; }
122
29
  tag(name) {
123
30
  return unsafeStatic(`${this.constructor.tier}${name}`);
124
31
  }
32
+ partNamed(name) {
33
+ const root = this.renderRoot;
34
+ return typeof root?.querySelector === 'function' ? root.querySelector(`[part="${name}"]`) : null;
35
+ }
125
36
  toggleState(name, on) {
126
37
  if ('states' in this.internals) {
127
38
  this.internals.states[on ? 'add' : 'delete'](name);