@fusion.dev/helix-core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -1,41 +1,3 @@
1
- /**
2
- * Measures the marked child and writes the geometry onto a traveling mark.
3
- *
4
- * Serves `Navigation`, `Tabs`, `Toggle.Group` and `Outline`. The two things
5
- * hand-written copies of this get wrong:
6
- *
7
- * **The inline axis is signed.** In a right-to-left document the distance is
8
- * measured from the surface's right edge and applied as a negative translation,
9
- * because the mark travels from the inline start and the inline start is on the
10
- * right. Measuring from the physical left and applying an unsigned offset from
11
- * the inline-start edge sends the mark the wrong way in RTL.
12
- *
13
- * **The border has to come off both axes.** `getBoundingClientRect` gives the
14
- * border box and the containing block is the padding box, so a surface with a
15
- * border of its own puts the mark one border-width out. `clientLeft` and
16
- * `clientTop` are the border widths, already resolved, which is why they are
17
- * used here rather than parsing `borderLeftWidth`.
18
- *
19
- * **Nothing is written until there is something to measure.** A zero-width box
20
- * is a component whose children have not upgraded yet, and writing zeroes would
21
- * park the mark at the origin and leave it there. The `data-placed` attribute is
22
- * what a stylesheet waits for, so a mark fades in where it belongs rather than
23
- * sliding from the corner on first paint.
24
- *
25
- * The four custom properties are the contract. What a component does with them
26
- * is its own business, which is how one controller serves a bar under a tab, a
27
- * pill filling it, and a rule down the side of a contents page.
28
- *
29
- * **Declared here because this is what writes them.** `core` ships no CSS, so
30
- * every reader is outside this package and none of these is private.
31
- * the `hooks` rule pairs a documented hook with a rule that reads one, in both
32
- * directions.
33
- *
34
- * @cssproperty --indicator-block-size - How tall the active item is, in pixels.
35
- * @cssproperty --indicator-inline-size - How wide the active item is, in pixels.
36
- * @cssproperty --indicator-x - Where the active item starts, along the inline axis.
37
- * @cssproperty --indicator-y - Where the active item starts, along the block axis.
38
- */
39
1
  export class IndicatorController {
40
2
  constructor(host, options) {
41
3
  this.host = host;
@@ -43,32 +5,17 @@ export class IndicatorController {
43
5
  host.addController(this);
44
6
  }
45
7
  hostConnected() {
46
- /* The host's own size is not enough: a strip that keeps its width while its
47
- children rewrap moves every one of them, and a mark measured against the
48
- old layout is left behind. Observing the host catches both, because a
49
- rewrap changes its height. */
50
8
  this.resize = new ResizeObserver(() => this.place());
51
9
  this.resize.observe(this.host);
52
10
  }
53
11
  hostDisconnected() {
54
12
  this.resize?.disconnect();
55
13
  this.resize = undefined;
56
- /* The host going away is the third way a move can end, and the one nothing
57
- was watching. A timer holding a closure over a detached element keeps it
58
- alive for the second it has left to run. */
59
14
  this.stopWaiting();
60
15
  }
61
- /** Called after every update, which is when the marked child may have moved. */
62
16
  hostUpdated() {
63
17
  this.place();
64
18
  }
65
- /*
66
- * Measures and writes, or gives up without writing anything.
67
- *
68
- * Public because `slotchange` is not an update: children arriving is exactly
69
- * when the first measurement becomes possible, and a component that only
70
- * placed on its own updates left the mark unplaced for the life of the page.
71
- */
72
19
  place() {
73
20
  const indicator = this.options.indicator();
74
21
  const surface = this.options.surface();
@@ -77,8 +24,6 @@ export class IndicatorController {
77
24
  return;
78
25
  }
79
26
  if (!active) {
80
- // Nothing is marked, so there is nothing to point at. The attribute goes
81
- // and the stylesheet takes the mark away with it.
82
27
  indicator.removeAttribute('data-placed');
83
28
  return;
84
29
  }
@@ -87,32 +32,12 @@ export class IndicatorController {
87
32
  if (box.width === 0 && box.height === 0) {
88
33
  return;
89
34
  }
90
- /* `clientLeft` and `clientTop` are the resolved border widths, which is the
91
- difference between the border box the measurement gives and the padding
92
- box the mark is placed against. */
93
35
  const rtl = getComputedStyle(surface).direction === 'rtl';
94
36
  const inline = rtl
95
37
  ? own.right - box.right - surface.clientLeft
96
38
  : box.left - own.left - surface.clientLeft;
97
39
  const x = `${rtl ? -inline : inline}px`;
98
40
  const y = `${box.top - own.top - surface.clientTop}px`;
99
- /* `data-moving` while it is on its way, so a stylesheet can say something
100
- * about the travel itself.
101
- *
102
- * There is no CSS state for "a transition is running", and the effect this
103
- * exists for wants one: a mark that swells as it leaves and settles back
104
- * when it lands reads as a thing being carried rather than a rectangle
105
- * being repositioned. The controller is the only thing that knows a move
106
- * has been asked for, so it says so.
107
- *
108
- * Only on a *change*, and never on the first placement: the mark arrives by
109
- * fading in, and swelling on arrival would be an animation about nothing.
110
- *
111
- * Cleared on `transitionend` with a timeout behind it. The event is the
112
- * honest signal and it does not always come: a move under reduced motion
113
- * has no transition to end, and neither does one whose computed position
114
- * turns out identical after a resize. A marker that can stick is worse than
115
- * one that clears a little early. */
116
41
  const moved = this.last !== undefined && (this.last.x !== x || this.last.y !== y);
117
42
  this.last = { x, y };
118
43
  indicator.style.setProperty('--indicator-x', x);
@@ -125,12 +50,6 @@ export class IndicatorController {
125
50
  }
126
51
  indicator.toggleAttribute('data-moving', true);
127
52
  this.stopWaiting();
128
- /* `translate` only. `transitionend` fires once per property, and a
129
- stylesheet reacting to `data-moving` is very likely to transition
130
- something shorter than the travel: the outline's mark widens over
131
- `duration-fast` while it travels over `duration-normal`, so an unfiltered
132
- listener had the width's own transition end the move that caused it, a
133
- third of the way through. */
134
53
  const settle = (event) => {
135
54
  if (event && event.propertyName !== 'translate') {
136
55
  return;
@@ -142,7 +61,6 @@ export class IndicatorController {
142
61
  this.waiting = { on: indicator, settle };
143
62
  this.settling = setTimeout(settle, 1000);
144
63
  }
145
- /** Takes the pending listener and its guard off, wherever they came from. */
146
64
  stopWaiting() {
147
65
  clearTimeout(this.settling);
148
66
  this.settling = undefined;
@@ -1,27 +1,4 @@
1
- /** Every key that moves within a set, which is the whole of what this handles. */
2
1
  const KEYS = new Set(['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'End', 'Home']);
3
- /**
4
- * Moves through a set of items with the arrow keys, and chooses as it goes.
5
- *
6
- * **Moving and choosing are one act here, not two.** That is the radio group
7
- * pattern and it is the difference from a listbox: moving focus without
8
- * choosing leaves a keyboard reader sitting on an item they have not selected,
9
- * with nothing on screen saying which of the two is true. A pointer user
10
- * chooses by arriving, so a keyboard user does too.
11
- *
12
- * **Both axes, always.** A vertical set still answers left and right, and a
13
- * horizontal one still answers up and down. Binding only the axis the items
14
- * happen to be laid out on means a reader who guesses wrong gets nothing, and
15
- * `orientation` is presentation: the same set of choices reads the same way
16
- * whichever direction it runs.
17
- *
18
- * Disabled items are skipped rather than stopped at, so a set with a gap in it
19
- * still moves in one press.
20
- *
21
- * A set that starts from focus rather than from the selection, or that moves
22
- * without choosing, says so through `origin` and `selects`. Neither is a reason
23
- * to keep a hand-written copy, and neither exempts a set from both axes.
24
- */
25
2
  export class KeyboardNavigationController {
26
3
  constructor(host, { items, origin = 'selection', reserved = () => [], selects = () => true, wrap = true, }) {
27
4
  this.handleKeydown = (event) => {
@@ -32,18 +9,6 @@ export class KeyboardNavigationController {
32
9
  if (items.length === 0) {
33
10
  return;
34
11
  }
35
- /* `-1` means two different things and only one of them is a starting point.
36
- *
37
- Starting from the selection, `-1` is "nothing is chosen", and the
38
- arithmetic below relies on it: a forward step lands on the first item and
39
- a backward step on the last, both of which are where a reader entering an
40
- unanswered set expects to arrive.
41
-
42
- Starting from focus, `-1` is "the press did not come from the set", and
43
- there is nothing to move. The listener is on the host, not on the items,
44
- and a host can hold more than the set: a tab panel is `tabindex="0"`, so
45
- an arrow pressed while reading a panel used to move the tabs out from
46
- under the reader. */
47
12
  const current = this.origin === 'focus'
48
13
  ? items.findIndex((item) => item.contains(event.target))
49
14
  : items.findIndex((item) => item.selected);
@@ -56,17 +21,6 @@ export class KeyboardNavigationController {
56
21
  next = this.wrap ? (current + 1) % items.length : Math.min(current + 1, last);
57
22
  }
58
23
  else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
59
- /* From nothing, backwards is the last, which the arithmetic did not give.
60
- *
61
- * `(-1 - 1 + length) % length` is `length - 2`: the *second* to last, so
62
- * a reader arrowing backwards into an unanswered group lands one short of
63
- * the end. The wrapping form is right for every real index and wrong for
64
- * the one that is not an index.
65
- *
66
- * And the clamp is `Math.max` against the forward branch's `Math.min`.
67
- * Unclamped this is `-1` on the first item, and `items[-1]` is undefined,
68
- * so nothing is focused and nothing chosen on a press that
69
- * `preventDefault` has already swallowed. */
70
24
  next =
71
25
  current < 0
72
26
  ? last
@@ -1,37 +1,7 @@
1
1
  import { DismissController } from '../dismiss-controller/dismiss-controller.js';
2
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
- * Bundles the three things that always travel together: the popover API, a
7
- * `FloatingController` and a `DismissController`.
8
- *
9
- * **The top layer is the whole point.** A floating panel has to escape every
10
- * clipping ancestor between it and the page, and `position: fixed` does not do
11
- * that: it is clipped by `overflow` on any ancestor and re-anchored entirely by
12
- * an ancestor carrying `transform`, `filter` or `contain`. The popover API
13
- * lifts the element out of the tree's paint order for real, and it is the only
14
- * thing that does.
15
- *
16
- * `manual` rather than `auto`, because the host owns dismissal. `auto`
17
- * popovers light-dismiss themselves and close each other, which is close to
18
- * what is wanted and not the same: a select that closed itself before its own
19
- * change event landed, or a submenu that closed its parent, are both things the
20
- * platform would do here and neither is right.
21
- *
22
- * **The order matters on the way up.** `showPopover` first, then position:
23
- * measuring an element that is still `display: none` gives zeroes, so a surface
24
- * positioned before it is shown lands at the origin and slides into place on
25
- * the next frame. On the way down it is the reverse, and stopping the observer
26
- * before hiding avoids a final measurement of an element on its way out.
27
- *
28
- * What this does not own is *when*. Opening is the host's, because a menu opens
29
- * on click, a tooltip on hover and a select on either, and none of that is
30
- * about surfaces.
31
- */
32
3
  export class PopoverController {
33
4
  constructor(host, options) {
34
- /** Whether the surface is currently shown, so the calls can be idempotent. */
35
5
  this.shown = false;
36
6
  this.options = options;
37
7
  this.floating = new FloatingController(host, {
@@ -48,11 +18,9 @@ export class PopoverController {
48
18
  });
49
19
  host.addController(this);
50
20
  }
51
- /** Whether the surface is showing. */
52
21
  get open() {
53
22
  return this.shown;
54
23
  }
55
- /** Whether this one is placed by measurement, read fresh so it may change. */
56
24
  get places() {
57
25
  const asked = this.options.place;
58
26
  if (asked === undefined) {
@@ -60,29 +28,13 @@ export class PopoverController {
60
28
  }
61
29
  return typeof asked === 'function' ? asked() : asked;
62
30
  }
63
- /**
64
- * Shows the surface, positions it, and starts listening for a way out.
65
- *
66
- * Safe to call when already open, so a host may call it from whatever
67
- * combination of a property, an event and a command it likes without keeping
68
- * track of which one got there first.
69
- */
70
31
  show() {
71
32
  const surface = this.options.surface();
72
33
  if (this.shown || !surface) {
73
34
  return;
74
35
  }
75
36
  this.shown = true;
76
- /* Feature-detected rather than assumed. The popover API is recent enough
77
- that a browser without it should still get a positioned panel, which is
78
- what every one of these was before the API existed: worse, because it can
79
- be clipped, and not broken. */
80
37
  if (typeof surface.showPopover === 'function') {
81
- /* And said plainly where the surface is not one. `showPopover` on an
82
- element with no `popover` attribute throws `NotSupportedError`, from
83
- inside the promise the host called this in, so it surfaces as an
84
- unhandled rejection naming a method rather than as the thing that is
85
- actually wrong. */
86
38
  if (!surface.hasAttribute('popover')) {
87
39
  throw new Error('PopoverController: the surface needs a `popover` attribute to reach the top layer');
88
40
  }
@@ -93,7 +45,6 @@ export class PopoverController {
93
45
  }
94
46
  this.dismisser.activate();
95
47
  }
96
- /** Hides the surface and stops everything watching it. */
97
48
  hide() {
98
49
  const surface = this.options.surface();
99
50
  if (!this.shown) {
@@ -104,7 +55,6 @@ export class PopoverController {
104
55
  this.floating.stop();
105
56
  surface?.hidePopover?.();
106
57
  }
107
- /** Whichever of the two the surface is not already doing. */
108
58
  toggle() {
109
59
  if (this.shown) {
110
60
  this.hide();
@@ -113,31 +63,9 @@ export class PopoverController {
113
63
  this.show();
114
64
  }
115
65
  }
116
- /**
117
- * Places the surface again, for an anchor that moved without the page moving.
118
- *
119
- * `autoUpdate` watches scrolling, resizing and layout shift, which covers
120
- * everything that moves an anchor *with* its surroundings. It does not cover
121
- * an absolutely positioned anchor being moved by an inline style, and that is
122
- * exactly what a chart does: the mark walks from reading to reading while the
123
- * page holds still, so the bubble was placed once and stayed there.
124
- */
125
66
  reposition() {
126
67
  this.floating.update();
127
68
  }
128
- /*
129
- * Forgets that it was showing, and nothing more, which is the whole job.
130
- *
131
- * An element removed while its popover is showing is taken out of the top
132
- * layer by the platform. The two controllers watching it stop themselves:
133
- * both were constructed against this same host and registered with it, so Lit
134
- * calls each one's own `hostDisconnected`, and `autoUpdate` in particular
135
- * releases the listeners it holds on every scrolling ancestor there.
136
- *
137
- * Stopping them from here as well would be the same call twice. What is left
138
- * is the flag, which nothing else owns, and which has to be cleared or a host
139
- * reconnected to the page believes it is still open and refuses to show.
140
- */
141
69
  hostDisconnected() {
142
70
  this.shown = false;
143
71
  }
@@ -1,24 +1,4 @@
1
1
  import { readSeries } from '../../utils/read-series/read-series.js';
2
- /**
3
- * Resolves the element a chart reads from, and follows it.
4
- *
5
- * Every chart that takes a `for` wants exactly this, and wants it to behave
6
- * identically, so it is a controller rather than a paragraph in each of them.
7
- *
8
- * A controller and not a pure function, by the test the rules set: it reacts to
9
- * the host's lifecycle. It has to start watching when the host connects, stop
10
- * when it leaves, and re-resolve when `for` changes.
11
- *
12
- * **What it refuses is as much the point as what it accepts.** A `<table>` or
13
- * anything built from grid roles, including this library's own `helix-table`,
14
- * whose roles live in `ElementInternals` and therefore write no attribute at
15
- * all. Anything else resolves to null, so a `for` pointed at a paragraph draws
16
- * nothing rather than something arbitrary.
17
- *
18
- * The element is watched for `childList` and `characterData` alike, because
19
- * renaming a series edits a text node in place rather than replacing a cell,
20
- * and nothing in a page has reason to know a chart is reading it.
21
- */
22
2
  export class SourceController {
23
3
  #host;
24
4
  #observer;
@@ -26,13 +6,6 @@ export class SourceController {
26
6
  this.#host = host;
27
7
  host.addController(this);
28
8
  }
29
- /**
30
- * The element `for` names, or null where there is nothing to name it in.
31
- *
32
- * Null on the server as well, since resolving an id wants a document. Every
33
- * chart inherits that: the table is served whole and the drawing arrives on
34
- * hydration.
35
- */
36
9
  get element() {
37
10
  const host = this.#host;
38
11
  if (host.for === '') {
@@ -43,10 +16,6 @@ export class SourceController {
43
16
  if (!found) {
44
17
  return null;
45
18
  }
46
- /* `tagName` rather than `instanceof HTMLTableElement`, which is not a guard
47
- on the server: the constructor is not defined there, so naming it throws
48
- before the comparison can answer false. It is also the check that
49
- survives an element from another document. */
50
19
  const role = found.getAttribute('role');
51
20
  return found.tagName === 'TABLE' ||
52
21
  role === 'table' ||
@@ -55,7 +24,6 @@ export class SourceController {
55
24
  ? found
56
25
  : null;
57
26
  }
58
- /** The series it holds, or none. */
59
27
  get series() {
60
28
  const element = this.element;
61
29
  return element ? readSeries(element) : [];
@@ -67,12 +35,6 @@ export class SourceController {
67
35
  this.#observer?.disconnect();
68
36
  this.#observer = undefined;
69
37
  }
70
- /**
71
- * Points the watcher at whatever `for` names now.
72
- *
73
- * Called on connect and by the host when `for` changes. Safe to call when
74
- * there is no `MutationObserver`, which is the server.
75
- */
76
38
  watch() {
77
39
  this.#observer?.disconnect();
78
40
  const element = this.element;
@@ -1,44 +1,7 @@
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
- * A screenshot cannot catch that: a warm cast on a saturated gradient is also
13
- * what chroma subsampling produces, so the picture agrees with the bug.
14
- *
15
- * The conversion is therefore the browser's own: fill a pixel and read it back.
16
- * That holds for `lab()`, `oklch()`, `color-mix()` and whatever a token is
17
- * written in next, which is the property worth having in a design system whose
18
- * palette is derived rather than listed.
19
- *
20
- * **A controller and not a mixin**, by this repository's own line: it adds no
21
- * property or method a consumer sets. It reacts to lifecycle, holding a probe
22
- * that has to be created after the host connects and removed when it leaves,
23
- * and it watches for the color mode changing so the host can repaint.
24
- *
25
- * **Client only, and that is the contract rather than a limitation.** There is
26
- * no computed style on a server and no canvas to convert with, so `read`
27
- * answers the fallback there. Every caller is drawing into a canvas, which does
28
- * not exist on a server either.
29
- */
30
1
  export class TokenColorController {
31
- /**
32
- * @param host The element to update when the palette changes.
33
- * @param onChange Called when the mode moves, after the host has updated. A
34
- * host that paints outside Lit's cycle needs to know, because a canvas
35
- * holds its last frame and no re-render repaints it.
36
- */
37
2
  constructor(host, onChange) {
38
3
  this.host = host;
39
4
  this.onChange = onChange;
40
- /** Where a resolved color is converted, one pixel at a time. */
41
- // 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.
42
5
  this.context = null;
43
6
  this.announce = () => {
44
7
  this.host.requestUpdate();
@@ -47,8 +10,6 @@ export class TokenColorController {
47
10
  host.addController(this);
48
11
  }
49
12
  hostConnected() {
50
- /* Guarded on `document` rather than on `window`, which the server shim does
51
- provide. There is nothing to append a probe to without one. */
52
13
  if (typeof document === 'undefined') {
53
14
  return;
54
15
  }
@@ -59,10 +20,6 @@ export class TokenColorController {
59
20
  swatch.width = 1;
60
21
  swatch.height = 1;
61
22
  this.context = swatch.getContext('2d', { willReadFrequently: true });
62
- /* The color mode is an attribute, and the system's answer is a media query.
63
- Watching only the first leaves a reader on system light with the dark
64
- palette, which is the half that is easy to miss because a developer
65
- toggling the site's own control never sees it. */
66
23
  this.modes = new MutationObserver(() => this.announce());
67
24
  this.modes.observe(document.documentElement, {
68
25
  attributeFilter: ['data-color-mode'],
@@ -80,20 +37,12 @@ export class TokenColorController {
80
37
  this.probe = undefined;
81
38
  this.context = null;
82
39
  }
83
- /**
84
- * The token's current value, or the fallback where it cannot be resolved.
85
- *
86
- * @param token The custom property's name, including its leading dashes.
87
- * @param fallback What to answer on a server, or where the token is unset.
88
- */
89
40
  read(token, fallback) {
90
41
  const { context, probe } = this;
91
42
  if (!context || !probe) {
92
43
  return [...fallback];
93
44
  }
94
45
  probe.style.color = `var(${token})`;
95
- /* Reset first. An unparseable value leaves `fillStyle` where it was, which
96
- would silently hand back the previous token's color. */
97
46
  context.fillStyle = '#000000';
98
47
  context.fillStyle = getComputedStyle(probe).color;
99
48
  context.fillRect(0, 0, 1, 1);
@@ -1,97 +1,11 @@
1
- /**
2
- * How many times an update this asked for may change the answer again.
3
- *
4
- * **A measurement that its own render moves has no fixed point, so something
5
- * has to stop looking for one.** `clientHeight` is meant to be a fact about the
6
- * box, true of any real scroller, and false of a box with no height: there it
7
- * is a function of the rows just drawn, so `measure` renders, the render
8
- * changes the height, and the next measurement disagrees.
9
- *
10
- * It does not even diverge, which is what made it hard to see. Measured on the
11
- * headless tier with 500 rows and no height on the viewport, it converged
12
- * toward a non-integer and then oscillated between 378 and 379 rows forever,
13
- * 2505 passes in eight seconds. The loop runs on microtasks, so timers never
14
- * fire, and every deadline written to catch this, the harness's 2000ms race and
15
- * the spec's own bounded `settle`, is a `setTimeout` that cannot run. The page
16
- * simply stops.
17
- *
18
- * Ten, because a legitimate settling is two or three: nothing, then the real
19
- * height, then agreement.
20
- */
21
1
  const UNSTABLE = 10;
22
- /**
23
- * Works out which slice of a long set is worth rendering.
24
- *
25
- * **A controller and not an element, which is the whole reason it is here.**
26
- * Table, Listbox, Combobox and Tree each need windowing *inside* themselves,
27
- * and an element would make each of them nest one in its own shadow root: a
28
- * listbox whose options are inside a `<helix-virtual-list>` is a listbox whose
29
- * options are no longer its children, so its own keyboard, its own selection
30
- * and its own ARIA all have a stranger in the middle of them.
31
- *
32
- * **A windowed set still reports its true length, and each row its true
33
- * position.** This is the part virtualization usually gets wrong and the reason
34
- * the arithmetic lives somewhere shared. A reader on row 4,000 of 10,000 must
35
- * hear "4,000 of 10,000", so `aria-setsize` is the count and `aria-posinset` is
36
- * the real index, never the position within the rendered slice. The window is a
37
- * rendering decision and the set is a fact, and a component that lets the first
38
- * overwrite the second has told a screen reader the list is twelve long.
39
- *
40
- * **The spacers are what make the scrollbar honest.** Rendering twelve rows in
41
- * a box that should scroll for ten thousand gives a thumb the size of the
42
- * track, so the room the absent rows would have taken is left as padding before
43
- * and after. A reader dragging that thumb is dragging against the real length.
44
- *
45
- * **What it does not do, and the reason is narrower than it first looks.** True
46
- * virtualization means never creating the rows that are not on screen, and that
47
- * needs the set as *data*. Two channels are shut: a consumer's items arrive as
48
- * light-DOM children, which exist by the time this could decide not to create
49
- * them, and an `items` property cannot survive React hydration, which skips
50
- * every property it would have to apply imperatively (see `bindReactHandler`,
51
- * and the note in the rules that objects and functions have that bug and are
52
- * not fixed).
53
- *
54
- * **A third channel is open.** Data as *text*
55
- * in the markup, a `<script type="application/json">` child or an attribute, is
56
- * neither a property nor a node per row: React's hydration bug cannot touch text
57
- * it renders as a child, a server emits it, and the node count is one. The
58
- * charts already read data out of the document this way, `for="<id>"` at a real
59
- * `<table>`, so the pattern is in the library rather than being invented for
60
- * this.
61
- *
62
- * So the thing actually blocked is the *row template*. Turning an item into
63
- * markup wants either a render function, which is a function property and dies
64
- * on the same bug, or a `<template>` with named holes, which survives because it
65
- * is markup and which no component here asks anyone to write. That is a decision
66
- * about what this library is willing to look like, not a platform limit, and it
67
- * is open at #71.
68
- *
69
- * So what this buys is the expensive half rather than all of it: the rows
70
- * outside the window are not laid out and not painted, which is where the time
71
- * goes in a long list, and the node count stays. That is worth having and it is
72
- * not worth calling something it is not. An element that windows children it
73
- * was handed is not a virtual list, whatever it is named.
74
- *
75
- * **One estimate, not per-row measurement.** Measuring every row to place it is
76
- * how a virtual list ends up reading the layout it is trying to avoid, and it
77
- * is what makes them jump: a row taller than its estimate shifts everything
78
- * after it at the moment it renders. A fixed estimate is a straight line
79
- * between scroll offset and index, which never disagrees with itself. Rows of
80
- * genuinely different heights are a different component and a harder one.
81
- */
82
2
  export class VirtualizeController {
83
3
  constructor(host, options) {
84
4
  this.watching = false;
85
- /** Consecutive measurements that moved the window with nothing outside asking. */
86
5
  this.settling = 0;
87
- /** Whether the consumer has been told their box cannot be measured. */
88
6
  this.warned = false;
89
- /** The slice to render, recomputed on scroll and on resize. */
90
7
  this.window = { after: 0, before: 0, first: 0, last: 0 };
91
- /* `passive`, because this never calls `preventDefault` and saying so lets the
92
- browser scroll without waiting to find out. */
93
8
  this.handleScroll = () => {
94
- // A reader moved, which is the one input this exists to answer.
95
9
  this.settling = 0;
96
10
  this.measure();
97
11
  };
@@ -100,22 +14,6 @@ export class VirtualizeController {
100
14
  host.addController(this);
101
15
  }
102
16
  hostConnected() {
103
- /* Nothing is observed here, and that is deliberate. `hostConnected` runs
104
- before the first render, so the container query returns null and an
105
- observer attached to nothing observes nothing for the life of the
106
- element. It is an invisible failure: the first measurement is right, so
107
- the only symptom is a window that never moves again. */
108
- /* **The count is deliberately not cleared here**, which is the obvious
109
- thing to do and does not work. A resize looks like an outside cause and
110
- is not one when the box has no height of its own: the render this
111
- controller asked for is what changed the size, so the observer fires on
112
- every pass and resets the very counter meant to notice that. Measured:
113
- the guard made no difference at all, 7776 passes in twenty-five seconds,
114
- identical to having none.
115
-
116
- Nothing is lost by leaving it. A genuine resize changes the window once
117
- and the next measurement agrees, which clears the count through `same`. A
118
- runaway never agrees, which is the whole distinction. */
119
17
  this.observer = new ResizeObserver(() => {
120
18
  this.measure();
121
19
  });
@@ -126,7 +24,6 @@ export class VirtualizeController {
126
24
  this.watching = false;
127
25
  this.options.container()?.removeEventListener('scroll', this.handleScroll);
128
26
  }
129
- /** Attaches to the box the first time there is one to attach to. */
130
27
  watch() {
131
28
  if (this.watching) {
132
29
  return;
@@ -139,13 +36,6 @@ export class VirtualizeController {
139
36
  box.addEventListener('scroll', this.handleScroll, { passive: true });
140
37
  this.watching = true;
141
38
  }
142
- /**
143
- * Reads the scroll offset and settles the window.
144
- *
145
- * Asks for an update only when the slice actually moved. A scroll fires many
146
- * events per frame and most of them land inside the row already at the top,
147
- * so requesting one per event would re-render the list for every pixel.
148
- */
149
39
  measure() {
150
40
  this.watch();
151
41
  const box = this.options.container();
@@ -164,8 +54,6 @@ export class VirtualizeController {
164
54
  first,
165
55
  last,
166
56
  };
167
- /* The host's own numbers changing is an outside cause, the same as a scroll
168
- or a resize: a set that grew is not this controller chasing itself. */
169
57
  if (this.last?.count !== total || this.last.estimate !== size) {
170
58
  this.last = { count: total, estimate: size };
171
59
  this.settling = 0;
@@ -175,15 +63,10 @@ export class VirtualizeController {
175
63
  next.before === this.window.before &&
176
64
  next.after === this.window.after;
177
65
  if (same) {
178
- // It settled, so nothing that came before it was a runaway either.
179
66
  this.settling = 0;
180
67
  return;
181
68
  }
182
69
  if (this.settling >= UNSTABLE) {
183
- /* Hold what is *rendered*, which is `this.window`, rather than adopting a
184
- reading nothing will draw. Refusing the update and keeping `next` would
185
- leave the two disagreeing with no way back: the next measurement would
186
- find them the same and do nothing. */
187
70
  this.report();
188
71
  return;
189
72
  }
@@ -191,17 +74,6 @@ export class VirtualizeController {
191
74
  this.window = next;
192
75
  this.host.requestUpdate();
193
76
  }
194
- /**
195
- * Says once that the box cannot be measured, and why.
196
- *
197
- * A warning rather than a throw, because the list still works: it holds a
198
- * window, it draws rows, and a reader can scroll. What it cannot do is choose
199
- * the window well, and that is the consumer's stylesheet to fix, not
200
- * something this can decide on their behalf.
201
- *
202
- * Once per controller, because a warning per frame is one people filter out
203
- * and this one has to be believed the day it is right.
204
- */
205
77
  report() {
206
78
  if (this.warned) {
207
79
  return;