@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,150 @@
1
+ import { DismissController } from '../dismiss-controller/dismiss-controller.js';
2
+ import { FloatingController } from '../floating-controller/floating-controller.js';
3
+ /**
4
+ * A surface that floats against something, in the top layer, until dismissed.
5
+ *
6
+ * Three things always travel together and were written out longhand three
7
+ * times: the popover API, a `FloatingController` and a `DismissController`.
8
+ * Individually each is small, which is exactly why the copies drifted rather
9
+ * than being noticed. **`Select` never called `showPopover` at all**, so its
10
+ * list was an ordinary positioned panel and any ancestor with `overflow:
11
+ * hidden` clipped it, while a menu two folders away in the same position did
12
+ * not. That is not a difference anyone chose.
13
+ *
14
+ * **The top layer is the whole point.** A floating panel has to escape every
15
+ * clipping ancestor between it and the page, and `position: fixed` does not do
16
+ * that: it is clipped by `overflow` on any ancestor and re-anchored entirely by
17
+ * an ancestor carrying `transform`, `filter` or `contain`. The popover API
18
+ * lifts the element out of the tree's paint order for real, and it is the only
19
+ * thing that does.
20
+ *
21
+ * `manual` rather than `auto`, because the host owns dismissal. `auto`
22
+ * popovers light-dismiss themselves and close each other, which is close to
23
+ * what is wanted and not the same: a select that closed itself before its own
24
+ * change event landed, or a submenu that closed its parent, are both things the
25
+ * platform would do here and neither is right.
26
+ *
27
+ * **The order matters on the way up.** `showPopover` first, then position:
28
+ * measuring an element that is still `display: none` gives zeroes, so a surface
29
+ * positioned before it is shown lands at the origin and slides into place on
30
+ * the next frame. On the way down it is the reverse, and stopping the observer
31
+ * before hiding avoids a final measurement of an element on its way out.
32
+ *
33
+ * What this does not own is *when*. Opening is the host's, because a menu opens
34
+ * on click, a tooltip on hover and a select on either, and none of that is
35
+ * about surfaces.
36
+ */
37
+ export class PopoverController {
38
+ constructor(host, options) {
39
+ /** Whether the surface is currently shown, so the calls can be idempotent. */
40
+ this.shown = false;
41
+ this.options = options;
42
+ this.floating = new FloatingController(host, {
43
+ anchor: options.anchor,
44
+ ...(options.arrow === undefined ? {} : { arrow: options.arrow }),
45
+ floating: options.surface,
46
+ ...(options.offset === undefined ? {} : { offset: options.offset }),
47
+ ...(options.placement === undefined ? {} : { placement: options.placement }),
48
+ ...(options.within === undefined ? {} : { within: options.within }),
49
+ });
50
+ this.dismisser = new DismissController(host, {
51
+ boundary: options.boundary ?? options.surface,
52
+ onDismiss: options.onDismiss,
53
+ });
54
+ host.addController(this);
55
+ }
56
+ /** Whether the surface is showing. */
57
+ get open() {
58
+ return this.shown;
59
+ }
60
+ /** Whether this one is placed by measurement, read fresh so it may change. */
61
+ get places() {
62
+ const asked = this.options.place;
63
+ if (asked === undefined) {
64
+ return true;
65
+ }
66
+ return typeof asked === 'function' ? asked() : asked;
67
+ }
68
+ /**
69
+ * Shows the surface, positions it, and starts listening for a way out.
70
+ *
71
+ * Safe to call when already open, so a host may call it from whatever
72
+ * combination of a property, an event and a command it likes without keeping
73
+ * track of which one got there first.
74
+ */
75
+ show() {
76
+ const surface = this.options.surface();
77
+ if (this.shown || !surface) {
78
+ return;
79
+ }
80
+ this.shown = true;
81
+ /* Feature-detected rather than assumed. The popover API is recent enough
82
+ that a browser without it should still get a positioned panel, which is
83
+ what every one of these was before the API existed: worse, because it can
84
+ be clipped, and not broken. */
85
+ if (typeof surface.showPopover === 'function') {
86
+ /* And said plainly where the surface is not one. `showPopover` on an
87
+ element with no `popover` attribute throws `NotSupportedError`, from
88
+ inside the promise the host called this in, so it surfaces as an
89
+ unhandled rejection naming a method rather than as the thing that is
90
+ actually wrong. Which is how `Select` looked for as long as it was
91
+ broken: no error at all, just a panel that could be clipped. */
92
+ if (!surface.hasAttribute('popover')) {
93
+ throw new Error('PopoverController: the surface needs a `popover` attribute to reach the top layer');
94
+ }
95
+ surface.showPopover();
96
+ }
97
+ if (this.places) {
98
+ this.floating.start();
99
+ }
100
+ this.dismisser.activate();
101
+ }
102
+ /** Hides the surface and stops everything watching it. */
103
+ hide() {
104
+ const surface = this.options.surface();
105
+ if (!this.shown) {
106
+ return;
107
+ }
108
+ this.shown = false;
109
+ this.dismisser.deactivate();
110
+ this.floating.stop();
111
+ surface?.hidePopover?.();
112
+ }
113
+ /** Whichever of the two the surface is not already doing. */
114
+ toggle() {
115
+ if (this.shown) {
116
+ this.hide();
117
+ }
118
+ else {
119
+ this.show();
120
+ }
121
+ }
122
+ /**
123
+ * Places the surface again, for an anchor that moved without the page moving.
124
+ *
125
+ * `autoUpdate` watches scrolling, resizing and layout shift, which covers
126
+ * everything that moves an anchor *with* its surroundings. It does not cover
127
+ * an absolutely positioned anchor being moved by an inline style, and that is
128
+ * exactly what a chart does: the mark walks from reading to reading while the
129
+ * page holds still, so the bubble was placed once and stayed there.
130
+ */
131
+ reposition() {
132
+ this.floating.update();
133
+ }
134
+ /*
135
+ * Forgets that it was showing, and nothing more, which is the whole job.
136
+ *
137
+ * An element removed while its popover is showing is taken out of the top
138
+ * layer by the platform. The two controllers watching it stop themselves:
139
+ * both were constructed against this same host and registered with it, so Lit
140
+ * calls each one's own `hostDisconnected`, and `autoUpdate` in particular
141
+ * releases the listeners it holds on every scrolling ancestor there.
142
+ *
143
+ * Stopping them from here as well would be the same call twice. What is left
144
+ * is the flag, which nothing else owns, and which has to be cleared or a host
145
+ * reconnected to the page believes it is still open and refuses to show.
146
+ */
147
+ hostDisconnected() {
148
+ this.shown = false;
149
+ }
150
+ }
@@ -0,0 +1,55 @@
1
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
2
+ import { type Series } from '../../utils/read-series/read-series.js';
3
+ /** A host that names the element it reads from. */
4
+ export interface SourceHost extends ReactiveControllerHost, HTMLElement {
5
+ /**
6
+ * The id of the table or grid to read.
7
+ */
8
+ for: string;
9
+ }
10
+ /**
11
+ * Resolves the element a chart reads from, and follows it.
12
+ *
13
+ * **Three charts had written this out separately before it was one thing**, and
14
+ * the drift had already started: two of them called the lookup `#table` and one
15
+ * called it `#source`, for the same method with the same body. Every chart that
16
+ * takes a `for` wants exactly this, and wants it to behave identically, so it is
17
+ * a controller rather than a paragraph in each of them.
18
+ *
19
+ * A controller and not a pure function, by the test the rules set: it reacts to
20
+ * the host's lifecycle. It has to start watching when the host connects, stop
21
+ * when it leaves, and re-resolve when `for` changes.
22
+ *
23
+ * **What it refuses is as much the point as what it accepts.** A `<table>` or
24
+ * anything built from grid roles, including this library's own `helix-table`,
25
+ * whose roles live in `ElementInternals` and therefore write no attribute at
26
+ * all. Anything else resolves to null, so a `for` pointed at a paragraph draws
27
+ * nothing rather than something arbitrary.
28
+ *
29
+ * The element is watched for `childList` and `characterData` alike, because
30
+ * renaming a series edits a text node in place rather than replacing a cell,
31
+ * and nothing in a page has reason to know a chart is reading it.
32
+ */
33
+ export declare class SourceController implements ReactiveController {
34
+ #private;
35
+ constructor(host: SourceHost);
36
+ /**
37
+ * The element `for` names, or null where there is nothing to name it in.
38
+ *
39
+ * Null on the server as well, since resolving an id wants a document. Every
40
+ * chart inherits that: the table is served whole and the drawing arrives on
41
+ * hydration.
42
+ */
43
+ get element(): Element | null;
44
+ /** The series it holds, or none. */
45
+ get series(): Series[];
46
+ hostConnected(): void;
47
+ hostDisconnected(): void;
48
+ /**
49
+ * Points the watcher at whatever `for` names now.
50
+ *
51
+ * Called on connect and by the host when `for` changes. Safe to call when
52
+ * there is no `MutationObserver`, which is the server.
53
+ */
54
+ watch(): void;
55
+ }
@@ -0,0 +1,94 @@
1
+ import { readSeries } from '../../utils/read-series/read-series.js';
2
+ /**
3
+ * Resolves the element a chart reads from, and follows it.
4
+ *
5
+ * **Three charts had written this out separately before it was one thing**, and
6
+ * the drift had already started: two of them called the lookup `#table` and one
7
+ * called it `#source`, for the same method with the same body. Every chart that
8
+ * takes a `for` wants exactly this, and wants it to behave identically, so it is
9
+ * a controller rather than a paragraph in each of them.
10
+ *
11
+ * A controller and not a pure function, by the test the rules set: it reacts to
12
+ * the host's lifecycle. It has to start watching when the host connects, stop
13
+ * when it leaves, and re-resolve when `for` changes.
14
+ *
15
+ * **What it refuses is as much the point as what it accepts.** A `<table>` or
16
+ * anything built from grid roles, including this library's own `helix-table`,
17
+ * whose roles live in `ElementInternals` and therefore write no attribute at
18
+ * all. Anything else resolves to null, so a `for` pointed at a paragraph draws
19
+ * nothing rather than something arbitrary.
20
+ *
21
+ * The element is watched for `childList` and `characterData` alike, because
22
+ * renaming a series edits a text node in place rather than replacing a cell,
23
+ * and nothing in a page has reason to know a chart is reading it.
24
+ */
25
+ export class SourceController {
26
+ #host;
27
+ #observer;
28
+ constructor(host) {
29
+ this.#host = host;
30
+ host.addController(this);
31
+ }
32
+ /**
33
+ * The element `for` names, or null where there is nothing to name it in.
34
+ *
35
+ * Null on the server as well, since resolving an id wants a document. Every
36
+ * chart inherits that: the table is served whole and the drawing arrives on
37
+ * hydration.
38
+ */
39
+ get element() {
40
+ const host = this.#host;
41
+ if (host.for === '') {
42
+ return null;
43
+ }
44
+ const root = host.getRootNode();
45
+ const found = root.getElementById?.(host.for);
46
+ if (!found) {
47
+ return null;
48
+ }
49
+ /* `tagName` rather than `instanceof HTMLTableElement`, which is not a guard
50
+ on the server: the constructor is not defined there, so naming it throws
51
+ before the comparison can answer false. It is also the check that
52
+ survives an element from another document. */
53
+ const role = found.getAttribute('role');
54
+ return found.tagName === 'TABLE' ||
55
+ role === 'table' ||
56
+ role === 'grid' ||
57
+ /^helix-(headless-)?table$/i.test(found.tagName)
58
+ ? found
59
+ : null;
60
+ }
61
+ /** The series it holds, or none. */
62
+ get series() {
63
+ const element = this.element;
64
+ return element ? readSeries(element) : [];
65
+ }
66
+ hostConnected() {
67
+ this.watch();
68
+ }
69
+ hostDisconnected() {
70
+ this.#observer?.disconnect();
71
+ this.#observer = undefined;
72
+ }
73
+ /**
74
+ * Points the watcher at whatever `for` names now.
75
+ *
76
+ * Called on connect and by the host when `for` changes. Safe to call when
77
+ * there is no `MutationObserver`, which is the server.
78
+ */
79
+ watch() {
80
+ this.#observer?.disconnect();
81
+ const element = this.element;
82
+ if (!element || typeof MutationObserver === 'undefined') {
83
+ return;
84
+ }
85
+ this.#observer ??= new MutationObserver(() => {
86
+ this.#host.requestUpdate();
87
+ });
88
+ this.#observer.observe(element, {
89
+ characterData: true,
90
+ childList: true,
91
+ subtree: true,
92
+ });
93
+ }
94
+ }
@@ -0,0 +1,63 @@
1
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
2
+ /** A color as the three channels in `0..1` that a shader or a canvas takes. */
3
+ export type TokenColor = [number, number, number];
4
+ /**
5
+ * Reads a design token as the sRGB triple a shader or a 2D canvas can take, and
6
+ * tells the host when the palette underneath it moves.
7
+ *
8
+ * **Two hops, and both are load-bearing.** A custom property's own value is the
9
+ * unevaluated function text, so `getComputedStyle` on a probe is the only way
10
+ * to see what it resolves to. And what it resolves to is not `rgb()`: the
11
+ * palette derives from an `oklch()` seed, and Chrome answers `lab(100 0 0)` for
12
+ * white. Parsing that with a regular expression for numbers reads the three
13
+ * channels as 100, 0 and 0, which divided by 255 is a dark red.
14
+ *
15
+ * That is not hypothetical. It shipped, and `--helix-color-foreground` arrived
16
+ * as a dark red that painted a warm streak down a shader for an afternoon. It
17
+ * survived review because a warm cast on a saturated gradient is also what a
18
+ * screenshot's chroma subsampling produces, so the picture agreed with the bug.
19
+ *
20
+ * The conversion is therefore the browser's own: fill a pixel and read it back.
21
+ * That holds for `lab()`, `oklch()`, `color-mix()` and whatever a token is
22
+ * written in next, which is the property worth having in a design system whose
23
+ * palette is derived rather than listed.
24
+ *
25
+ * **A controller and not a mixin**, by this repository's own line: it adds no
26
+ * property or method a consumer sets. It reacts to lifecycle, holding a probe
27
+ * that has to be created after the host connects and removed when it leaves,
28
+ * and it watches for the color mode changing so the host can repaint.
29
+ *
30
+ * **Client only, and that is the contract rather than a limitation.** There is
31
+ * no computed style on a server and no canvas to convert with, so `read`
32
+ * answers the fallback there. Every caller is drawing into a canvas, which does
33
+ * not exist on a server either.
34
+ */
35
+ export declare class TokenColorController implements ReactiveController {
36
+ private readonly host;
37
+ private readonly onChange?;
38
+ /** Where a resolved color is converted, one pixel at a time. */
39
+ private context;
40
+ /** Watches the document for `data-color-mode`. */
41
+ private modes;
42
+ /** The hidden element a token is resolved against. */
43
+ private probe;
44
+ /** Watches the operating system's answer, where nothing overrides it. */
45
+ private scheme;
46
+ /**
47
+ * @param host The element to update when the palette changes.
48
+ * @param onChange Called when the mode moves, after the host has updated. A
49
+ * host that paints outside Lit's cycle needs to know, because a canvas
50
+ * holds its last frame and no re-render repaints it.
51
+ */
52
+ constructor(host: ReactiveControllerHost, onChange?: (() => void) | undefined);
53
+ hostConnected(): void;
54
+ hostDisconnected(): void;
55
+ /**
56
+ * The token's current value, or the fallback where it cannot be resolved.
57
+ *
58
+ * @param token The custom property's name, including its leading dashes.
59
+ * @param fallback What to answer on a server, or where the token is unset.
60
+ */
61
+ read(token: string, fallback: TokenColor): TokenColor;
62
+ private readonly announce;
63
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Reads a design token as the sRGB triple a shader or a 2D canvas can take, and
3
+ * tells the host when the palette underneath it moves.
4
+ *
5
+ * **Two hops, and both are load-bearing.** A custom property's own value is the
6
+ * unevaluated function text, so `getComputedStyle` on a probe is the only way
7
+ * to see what it resolves to. And what it resolves to is not `rgb()`: the
8
+ * palette derives from an `oklch()` seed, and Chrome answers `lab(100 0 0)` for
9
+ * white. Parsing that with a regular expression for numbers reads the three
10
+ * channels as 100, 0 and 0, which divided by 255 is a dark red.
11
+ *
12
+ * That is not hypothetical. It shipped, and `--helix-color-foreground` arrived
13
+ * as a dark red that painted a warm streak down a shader for an afternoon. It
14
+ * survived review because a warm cast on a saturated gradient is also what a
15
+ * screenshot's chroma subsampling produces, so the picture agreed with the bug.
16
+ *
17
+ * The conversion is therefore the browser's own: fill a pixel and read it back.
18
+ * That holds for `lab()`, `oklch()`, `color-mix()` and whatever a token is
19
+ * written in next, which is the property worth having in a design system whose
20
+ * palette is derived rather than listed.
21
+ *
22
+ * **A controller and not a mixin**, by this repository's own line: it adds no
23
+ * property or method a consumer sets. It reacts to lifecycle, holding a probe
24
+ * that has to be created after the host connects and removed when it leaves,
25
+ * and it watches for the color mode changing so the host can repaint.
26
+ *
27
+ * **Client only, and that is the contract rather than a limitation.** There is
28
+ * no computed style on a server and no canvas to convert with, so `read`
29
+ * answers the fallback there. Every caller is drawing into a canvas, which does
30
+ * not exist on a server either.
31
+ */
32
+ export class TokenColorController {
33
+ /**
34
+ * @param host The element to update when the palette changes.
35
+ * @param onChange Called when the mode moves, after the host has updated. A
36
+ * host that paints outside Lit's cycle needs to know, because a canvas
37
+ * holds its last frame and no re-render repaints it.
38
+ */
39
+ constructor(host, onChange) {
40
+ this.host = host;
41
+ this.onChange = onChange;
42
+ /** Where a resolved color is converted, one pixel at a time. */
43
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: read in three places below, and this rule's own automatic fix removed it, which failed `typecheck` immediately. The suppression has to sit under the doc comment rather than over it, being directional.
44
+ this.context = null;
45
+ this.announce = () => {
46
+ this.host.requestUpdate();
47
+ this.onChange?.();
48
+ };
49
+ host.addController(this);
50
+ }
51
+ hostConnected() {
52
+ /* Guarded on `document` rather than on `window`, which the server shim does
53
+ provide. There is nothing to append a probe to without one. */
54
+ if (typeof document === 'undefined') {
55
+ return;
56
+ }
57
+ this.probe = document.createElement('span');
58
+ this.probe.style.display = 'none';
59
+ document.body.appendChild(this.probe);
60
+ const swatch = document.createElement('canvas');
61
+ swatch.width = 1;
62
+ swatch.height = 1;
63
+ this.context = swatch.getContext('2d', { willReadFrequently: true });
64
+ /* The color mode is an attribute, and the system's answer is a media query.
65
+ Watching only the first leaves a reader on system light with the dark
66
+ palette, which is the half that is easy to miss because a developer
67
+ toggling the site's own control never sees it. */
68
+ this.modes = new MutationObserver(() => this.announce());
69
+ this.modes.observe(document.documentElement, {
70
+ attributeFilter: ['data-color-mode'],
71
+ attributes: true,
72
+ });
73
+ this.scheme = window.matchMedia('(prefers-color-scheme: light)');
74
+ this.scheme.addEventListener('change', this.announce);
75
+ }
76
+ hostDisconnected() {
77
+ this.modes?.disconnect();
78
+ this.modes = undefined;
79
+ this.scheme?.removeEventListener('change', this.announce);
80
+ this.scheme = undefined;
81
+ this.probe?.remove();
82
+ this.probe = undefined;
83
+ this.context = null;
84
+ }
85
+ /**
86
+ * The token's current value, or the fallback where it cannot be resolved.
87
+ *
88
+ * @param token The custom property's name, including its leading dashes.
89
+ * @param fallback What to answer on a server, or where the token is unset.
90
+ */
91
+ read(token, fallback) {
92
+ const { context, probe } = this;
93
+ if (!context || !probe) {
94
+ return [...fallback];
95
+ }
96
+ probe.style.color = `var(${token})`;
97
+ /* Reset first. An unparseable value leaves `fillStyle` where it was, which
98
+ would silently hand back the previous token's color. */
99
+ context.fillStyle = '#000000';
100
+ context.fillStyle = getComputedStyle(probe).color;
101
+ context.fillRect(0, 0, 1, 1);
102
+ const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
103
+ return [(r ?? 0) / 255, (g ?? 0) / 255, (b ?? 0) / 255];
104
+ }
105
+ }
@@ -0,0 +1,127 @@
1
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
2
+ /** What the controller needs of a set and the box it is shown in. */
3
+ export interface VirtualizeOptions {
4
+ /** The scrolling box, or null before it renders. */
5
+ container: () => HTMLElement | null | undefined;
6
+ /** How many items there are in total, shown or not. */
7
+ count: () => number;
8
+ /** How tall one item is along the scroll axis, in pixels. */
9
+ estimate: () => number;
10
+ /**
11
+ * Rows kept either side of what fits, so a scroll reveals something drawn
12
+ * rather than something blank. Four by default, which is roughly a frame of
13
+ * fast scrolling at a 32 pixel row.
14
+ */
15
+ overscan?: number;
16
+ }
17
+ /** Which slice is rendered, and how much empty space stands in for the rest. */
18
+ export interface Window {
19
+ /** How much room the items before `first` would have taken, in pixels. */
20
+ before: number;
21
+ /** How much room the items after `last` would take, in pixels. */
22
+ after: number;
23
+ /** Index of the first item rendered. */
24
+ first: number;
25
+ /** Index one past the last item rendered. */
26
+ last: number;
27
+ }
28
+ /**
29
+ * Works out which slice of a long set is worth rendering.
30
+ *
31
+ * **A controller and not an element, which is the whole reason it is here.**
32
+ * Table, Listbox, Combobox and Tree each need windowing *inside* themselves,
33
+ * and an element would make each of them nest one in its own shadow root: a
34
+ * listbox whose options are inside a `<helix-virtual-list>` is a listbox whose
35
+ * options are no longer its children, so its own keyboard, its own selection
36
+ * and its own ARIA all have a stranger in the middle of them.
37
+ *
38
+ * **A windowed set still reports its true length, and each row its true
39
+ * position.** This is the part virtualization usually gets wrong and the reason
40
+ * the arithmetic lives somewhere shared. A reader on row 4,000 of 10,000 must
41
+ * hear "4,000 of 10,000", so `aria-setsize` is the count and `aria-posinset` is
42
+ * the real index, never the position within the rendered slice. The window is a
43
+ * rendering decision and the set is a fact, and a component that lets the first
44
+ * overwrite the second has told a screen reader the list is twelve long.
45
+ *
46
+ * **The spacers are what make the scrollbar honest.** Rendering twelve rows in
47
+ * a box that should scroll for ten thousand gives a thumb the size of the
48
+ * track, so the room the absent rows would have taken is left as padding before
49
+ * and after. A reader dragging that thumb is dragging against the real length.
50
+ *
51
+ * **What it does not do, and the reason is narrower than it first looks.** True
52
+ * virtualization means never creating the rows that are not on screen, and that
53
+ * needs the set as *data*. Two channels are shut: a consumer's items arrive as
54
+ * light-DOM children, which exist by the time this could decide not to create
55
+ * them, and an `items` property cannot survive React hydration, which skips
56
+ * every property it would have to apply imperatively (see `bindReactHandler`,
57
+ * and the note in the rules that objects and functions have that bug and are
58
+ * not fixed).
59
+ *
60
+ * **A third is open, and this said otherwise until 21 Aug 2026.** Data as *text*
61
+ * in the markup, a `<script type="application/json">` child or an attribute, is
62
+ * neither a property nor a node per row: React's hydration bug cannot touch text
63
+ * it renders as a child, a server emits it, and the node count is one. The
64
+ * charts already read data out of the document this way, `for="<id>"` at a real
65
+ * `<table>`, so the pattern is in the library rather than being invented for
66
+ * this.
67
+ *
68
+ * So the thing actually blocked is the *row template*. Turning an item into
69
+ * markup wants either a render function, which is a function property and dies
70
+ * on the same bug, or a `<template>` with named holes, which survives because it
71
+ * is markup and which no component here asks anyone to write. That is a decision
72
+ * about what this library is willing to look like, not a platform limit, and it
73
+ * is open at #71.
74
+ *
75
+ * So what this buys is the expensive half rather than all of it: the rows
76
+ * outside the window are not laid out and not painted, which is where the time
77
+ * goes in a long list, and the node count stays. That is worth having and it is
78
+ * not worth calling something it is not. An element that windows children it
79
+ * was handed is not a virtual list, whatever it is named.
80
+ *
81
+ * **One estimate, not per-row measurement.** Measuring every row to place it is
82
+ * how a virtual list ends up reading the layout it is trying to avoid, and it
83
+ * is what makes them jump: a row taller than its estimate shifts everything
84
+ * after it at the moment it renders. A fixed estimate is a straight line
85
+ * between scroll offset and index, which never disagrees with itself. Rows of
86
+ * genuinely different heights are a different component and a harder one.
87
+ */
88
+ export declare class VirtualizeController implements ReactiveController {
89
+ private readonly host;
90
+ private readonly options;
91
+ private observer;
92
+ private watching;
93
+ /** Consecutive measurements that moved the window with nothing outside asking. */
94
+ private settling;
95
+ /** Whether the consumer has been told their box cannot be measured. */
96
+ private warned;
97
+ /** The host's own inputs, so a change in them is not read as a runaway. */
98
+ private last;
99
+ /** The slice to render, recomputed on scroll and on resize. */
100
+ window: Window;
101
+ constructor(host: ReactiveControllerHost, options: VirtualizeOptions);
102
+ hostConnected(): void;
103
+ hostDisconnected(): void;
104
+ /** Attaches to the box the first time there is one to attach to. */
105
+ private watch;
106
+ private readonly handleScroll;
107
+ /**
108
+ * Reads the scroll offset and settles the window.
109
+ *
110
+ * Asks for an update only when the slice actually moved. A scroll fires many
111
+ * events per frame and most of them land inside the row already at the top,
112
+ * so requesting one per event would re-render the list for every pixel.
113
+ */
114
+ measure(): void;
115
+ /**
116
+ * Says once that the box cannot be measured, and why.
117
+ *
118
+ * A warning rather than a throw, because the list still works: it holds a
119
+ * window, it draws rows, and a reader can scroll. What it cannot do is choose
120
+ * the window well, and that is the consumer's stylesheet to fix, not
121
+ * something this can decide on their behalf.
122
+ *
123
+ * Once per controller, because a warning per frame is one people filter out
124
+ * and this one has to be believed the day it is right.
125
+ */
126
+ private report;
127
+ }