@fusion.dev/helix-core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/custom-elements.json +4 -4
  2. package/dist/controllers/aria-forward-controller/aria-forward-controller.js +0 -105
  3. package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +0 -71
  4. package/dist/controllers/command-controller/command-controller.js +0 -62
  5. package/dist/controllers/copy-controller/copy-controller.js +0 -63
  6. package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.js +0 -40
  7. package/dist/controllers/dismiss-controller/dismiss-controller.js +0 -124
  8. package/dist/controllers/drilldown-controller/drilldown-controller.js +0 -87
  9. package/dist/controllers/floating-controller/floating-controller.js +0 -152
  10. package/dist/controllers/focus-trap-controller/focus-trap-controller.js +0 -35
  11. package/dist/controllers/indicator-controller/indicator-controller.js +0 -82
  12. package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.js +0 -46
  13. package/dist/controllers/popover-controller/popover-controller.js +0 -72
  14. package/dist/controllers/source-controller/source-controller.js +0 -38
  15. package/dist/controllers/token-color-controller/token-color-controller.js +0 -51
  16. package/dist/controllers/virtualize-controller/virtualize-controller.js +0 -128
  17. package/dist/events/element-events.js +0 -22
  18. package/dist/events/events.js +0 -386
  19. package/dist/helix-element/helix-element.d.ts +10 -17
  20. package/dist/helix-element/helix-element.js +0 -505
  21. package/dist/helix-form-element/helix-form-element.js +0 -140
  22. package/dist/mixins/mixin-chart-source/mixin-chart-source.js +0 -77
  23. package/dist/mixins/mixin-collection/mixin-collection.js +0 -83
  24. package/dist/mixins/mixin-controller-active/mixin-controller-active.js +0 -82
  25. package/dist/mixins/mixin-controller-checked/mixin-controller-checked.js +0 -78
  26. package/dist/mixins/mixin-controller-open/mixin-controller-open.js +0 -93
  27. package/dist/mixins/mixin-controller-value/mixin-controller-value.js +0 -87
  28. package/dist/mixins/mixin-dismissible/mixin-dismissible.js +0 -38
  29. package/dist/mixins/mixin-field/mixin-field.js +0 -277
  30. package/dist/utils/anchor-element/anchor-element.js +0 -23
  31. package/dist/utils/capture-pointer/capture-pointer.js +0 -18
  32. package/dist/utils/closest-instance/closest-instance.js +0 -20
  33. package/dist/utils/create-sheet/create-sheet.js +0 -14
  34. package/dist/utils/custom-element/custom-element.js +0 -83
  35. package/dist/utils/draw-icon/draw-icon.js +0 -28
  36. package/dist/utils/draw-tooltip/draw-tooltip.js +1 -50
  37. package/dist/utils/elapsed-time/elapsed-time.js +0 -51
  38. package/dist/utils/focusable-elements/focusable-elements.js +1 -59
  39. package/dist/utils/format-numbers/format-numbers.js +0 -16
  40. package/dist/utils/host-aria/host-aria.js +0 -26
  41. package/dist/utils/inherit-disabled/inherit-disabled.js +0 -36
  42. package/dist/utils/line-path/line-path.js +0 -36
  43. package/dist/utils/on-unless-false/on-unless-false.js +0 -23
  44. package/dist/utils/read-series/read-series.js +0 -71
  45. package/dist/utils/relative-time/relative-time.js +0 -43
  46. package/dist/utils/resolve-active-item/resolve-active-item.js +0 -20
  47. package/dist/utils/restore-value/restore-value.js +0 -54
  48. package/dist/utils/scale/scale.js +0 -19
  49. package/dist/utils/scale/ticks.js +0 -36
  50. package/dist/utils/scroll-item-into-view/scroll-item-into-view.js +0 -53
  51. package/dist/utils/slot-filled/slot-filled.js +0 -23
  52. package/dist/utils/tell-disclosure/tell-disclosure.js +0 -53
  53. package/dist/utils/ticker/ticker.js +0 -22
  54. package/dist/utils/tone-marks/tone-marks.js +0 -24
  55. package/dist/utils/value-as/value-as.js +0 -56
  56. package/package.json +5 -5
@@ -2924,7 +2924,7 @@
2924
2924
  }
2925
2925
  }
2926
2926
  ],
2927
- "description": "Records a boolean fact about this element as a custom state.\n\n**The host belongs to whoever rendered it, and a component's own\nbookkeeping may not be kept there.** An attribute written on the first\nupdate is markup a consumer's server never sent, so a React consumer\nhydrates a DOM that has gained attributes since and reports a mismatch it\nwill not patch up. A custom state is not in the DOM at all: it carries the\nfact to a stylesheet through `:state()` and leaves the markup alone, so the\nserver's output and the client's agree by construction.\n\n`SETTLED` carries the long version of that argument; `checked`, `open`,\n`invalid` and `has-label` are recorded the same way, each of them a state a\n`default-*` attribute resolves before React reaches the node.\n\n**Deferring the definitions is not a fix and could not be.** Importing them\nfrom an effect so the upgrades fall the other side of hydration works only\nwhile hydration is one pass: anything that splits it, a streamed segment or\na suspended boundary, puts an upgrade back inside the window. Timing cannot\nbe the guarantee when the fact is kept somewhere React reconciles.\n\nFeature-tested rather than assumed, the way `hostAria` tests element\nreferences. An engine without `CustomStateSet` records nothing and keeps\nwhatever the resting rule draws, which is the safe direction to fail.\n\n**Lit's server shim is no longer one of them**, and this said it was.\n`@lit-labs/ssr-dom-shim@1.6.0` sets `this.states = new Set()` in its\n`ElementInternals` constructor, so the guard passes on the server and a\ncustom state is recorded where nothing reads it, which is harmless. The\nengines the test is for are browsers: Safari below 17.4 and Firefox below\n126. There the write is a `TypeError`, so a component that bypasses this\nfails to render rather than rendering without whatever the state draws."
2927
+ "description": "Records a boolean fact about this element as a custom state.\n\n**The host belongs to whoever rendered it, and a component's own\nbookkeeping may not be kept there.** An attribute written on the first\nupdate is markup a consumer's server never sent, so a React consumer\nhydrates a DOM that has gained attributes since and reports a mismatch it\nwill not patch up. A custom state is not in the DOM at all: it carries the\nfact to a stylesheet through `:state()` and leaves the markup alone, so the\nserver's output and the client's agree by construction.\n\n`SETTLED` carries the long version of that argument; `checked`, `open`,\n`invalid` and `has-label` are recorded the same way, each of them a state a\n`default-*` attribute resolves before React reaches the node.\n\n**Deferring the definitions is not a fix and could not be.** Importing them\nfrom an effect so the upgrades fall the other side of hydration works only\nwhile hydration is one pass: anything that splits it, a streamed segment or\na suspended boundary, puts an upgrade back inside the window. Timing cannot\nbe the guarantee when the fact is kept somewhere React reconciles.\n\nFeature-tested rather than assumed, the way `hostAria` tests element\nreferences. An engine without `CustomStateSet` records nothing and keeps\nwhatever the resting rule draws, which is the safe direction to fail.\n\n**The engines the test is for are browsers**: Safari below 17.4 and Firefox\nbelow 126, where the write is a `TypeError`, so a component bypassing this\nfails to render rather than rendering without whatever the state draws.\nLit's server shim sets `states`, so the guard passes there and records a\nstate nothing reads, which is harmless."
2928
2928
  },
2929
2929
  {
2930
2930
  "kind": "field",
@@ -2933,7 +2933,7 @@
2933
2933
  "text": "Promise<void>"
2934
2934
  },
2935
2935
  "readonly": true,
2936
- "description": "Resolves a frame after the first update, when transitions are switched on.\n\n**Named `whenSettled` because `settled` was taken four times over.** Image\nand Splitter each have a private `settle`, and both color controls carry a\n`settled` of their own, all of them older than this and none of them about\ntransitions. A base class takes the name every subclass then cannot use, so\nit takes the one nobody wanted.\n\n**The state it reports cannot be watched, which is why this exists.** A\n`MutationObserver` could see `[data-settled]` arrive and there is no\nequivalent for `ElementInternals.states`: a custom state is deliberately not\nin the DOM, which is the whole reason it replaced the attribute. Anything\nthat used to wait on the attribute waits on this instead, and gets a better\nsignal for it, an awaited promise being what the callers wanted rather than\na mutation they had to interpret.\n\nSeparate from `updateComplete`, which resolves in the microtask checkpoint\nthe suppression is still active in. That is the distinction `readyToMove`\nin `@fusion.dev/helix-testing` is written around.\n\nIt never resolves where `requestAnimationFrame` never fires, which is a\nserver, a hidden tab, and happy-dom. Callers that must not hang race it\nagainst a timeout; `readyToMove` does exactly that.",
2936
+ "description": "Resolves a frame after the first update, when transitions are switched on.\n\n**Named `whenSettled` because `settled` is taken four times over**, by\nImage, Splitter and both color controls, none of them about transitions. A\nbase class takes the name every subclass then cannot use.\n\n**The state it reports cannot be watched, which is why this exists.** A\ncustom state is deliberately not in the DOM, so there is no\n`MutationObserver` equivalent for `ElementInternals.states`. Await this\ninstead.\n\nSeparate from `updateComplete`, which resolves in the microtask checkpoint\nthe suppression is still active in. That is the distinction `readyToMove`\nin `@fusion.dev/helix-testing` is written around.\n\nIt never resolves where `requestAnimationFrame` never fires, which is a\nserver, a hidden tab, and happy-dom. Callers that must not hang race it\nagainst a timeout; `readyToMove` does exactly that.",
2937
2937
  "default": "new Promise<void>((resolve) => { this.resolveSettled = resolve })"
2938
2938
  },
2939
2939
  {
@@ -3204,7 +3204,7 @@
3204
3204
  }
3205
3205
  }
3206
3206
  ],
3207
- "description": "Records a boolean fact about this element as a custom state.\n\n**The host belongs to whoever rendered it, and a component's own\nbookkeeping may not be kept there.** An attribute written on the first\nupdate is markup a consumer's server never sent, so a React consumer\nhydrates a DOM that has gained attributes since and reports a mismatch it\nwill not patch up. A custom state is not in the DOM at all: it carries the\nfact to a stylesheet through `:state()` and leaves the markup alone, so the\nserver's output and the client's agree by construction.\n\n`SETTLED` carries the long version of that argument; `checked`, `open`,\n`invalid` and `has-label` are recorded the same way, each of them a state a\n`default-*` attribute resolves before React reaches the node.\n\n**Deferring the definitions is not a fix and could not be.** Importing them\nfrom an effect so the upgrades fall the other side of hydration works only\nwhile hydration is one pass: anything that splits it, a streamed segment or\na suspended boundary, puts an upgrade back inside the window. Timing cannot\nbe the guarantee when the fact is kept somewhere React reconciles.\n\nFeature-tested rather than assumed, the way `hostAria` tests element\nreferences. An engine without `CustomStateSet` records nothing and keeps\nwhatever the resting rule draws, which is the safe direction to fail.\n\n**Lit's server shim is no longer one of them**, and this said it was.\n`@lit-labs/ssr-dom-shim@1.6.0` sets `this.states = new Set()` in its\n`ElementInternals` constructor, so the guard passes on the server and a\ncustom state is recorded where nothing reads it, which is harmless. The\nengines the test is for are browsers: Safari below 17.4 and Firefox below\n126. There the write is a `TypeError`, so a component that bypasses this\nfails to render rather than rendering without whatever the state draws.",
3207
+ "description": "Records a boolean fact about this element as a custom state.\n\n**The host belongs to whoever rendered it, and a component's own\nbookkeeping may not be kept there.** An attribute written on the first\nupdate is markup a consumer's server never sent, so a React consumer\nhydrates a DOM that has gained attributes since and reports a mismatch it\nwill not patch up. A custom state is not in the DOM at all: it carries the\nfact to a stylesheet through `:state()` and leaves the markup alone, so the\nserver's output and the client's agree by construction.\n\n`SETTLED` carries the long version of that argument; `checked`, `open`,\n`invalid` and `has-label` are recorded the same way, each of them a state a\n`default-*` attribute resolves before React reaches the node.\n\n**Deferring the definitions is not a fix and could not be.** Importing them\nfrom an effect so the upgrades fall the other side of hydration works only\nwhile hydration is one pass: anything that splits it, a streamed segment or\na suspended boundary, puts an upgrade back inside the window. Timing cannot\nbe the guarantee when the fact is kept somewhere React reconciles.\n\nFeature-tested rather than assumed, the way `hostAria` tests element\nreferences. An engine without `CustomStateSet` records nothing and keeps\nwhatever the resting rule draws, which is the safe direction to fail.\n\n**The engines the test is for are browsers**: Safari below 17.4 and Firefox\nbelow 126, where the write is a `TypeError`, so a component bypassing this\nfails to render rather than rendering without whatever the state draws.\nLit's server shim sets `states`, so the guard passes there and records a\nstate nothing reads, which is harmless.",
3208
3208
  "inheritedFrom": {
3209
3209
  "name": "HelixElement",
3210
3210
  "module": "src/helix-element/helix-element.ts"
@@ -3217,7 +3217,7 @@
3217
3217
  "text": "Promise<void>"
3218
3218
  },
3219
3219
  "readonly": true,
3220
- "description": "Resolves a frame after the first update, when transitions are switched on.\n\n**Named `whenSettled` because `settled` was taken four times over.** Image\nand Splitter each have a private `settle`, and both color controls carry a\n`settled` of their own, all of them older than this and none of them about\ntransitions. A base class takes the name every subclass then cannot use, so\nit takes the one nobody wanted.\n\n**The state it reports cannot be watched, which is why this exists.** A\n`MutationObserver` could see `[data-settled]` arrive and there is no\nequivalent for `ElementInternals.states`: a custom state is deliberately not\nin the DOM, which is the whole reason it replaced the attribute. Anything\nthat used to wait on the attribute waits on this instead, and gets a better\nsignal for it, an awaited promise being what the callers wanted rather than\na mutation they had to interpret.\n\nSeparate from `updateComplete`, which resolves in the microtask checkpoint\nthe suppression is still active in. That is the distinction `readyToMove`\nin `@fusion.dev/helix-testing` is written around.\n\nIt never resolves where `requestAnimationFrame` never fires, which is a\nserver, a hidden tab, and happy-dom. Callers that must not hang race it\nagainst a timeout; `readyToMove` does exactly that.",
3220
+ "description": "Resolves a frame after the first update, when transitions are switched on.\n\n**Named `whenSettled` because `settled` is taken four times over**, by\nImage, Splitter and both color controls, none of them about transitions. A\nbase class takes the name every subclass then cannot use.\n\n**The state it reports cannot be watched, which is why this exists.** A\ncustom state is deliberately not in the DOM, so there is no\n`MutationObserver` equivalent for `ElementInternals.states`. Await this\ninstead.\n\nSeparate from `updateComplete`, which resolves in the microtask checkpoint\nthe suppression is still active in. That is the distinction `readyToMove`\nin `@fusion.dev/helix-testing` is written around.\n\nIt never resolves where `requestAnimationFrame` never fires, which is a\nserver, a hidden tab, and happy-dom. Callers that must not hang race it\nagainst a timeout; `readyToMove` does exactly that.",
3221
3221
  "default": "new Promise<void>((resolve) => { this.resolveSettled = resolve })",
3222
3222
  "inheritedFrom": {
3223
3223
  "name": "HelixElement",
@@ -1,110 +1,20 @@
1
- /** The two relationships a consumer writes on a host and means for the control. */
2
1
  const RELATIONSHIPS = [
3
2
  ['aria-describedby', 'ariaDescribedByElements'],
4
3
  ['aria-labelledby', 'ariaLabelledByElements'],
5
4
  ];
6
- /**
7
- * The state attributes a host may carry and mean for the control inside it.
8
- *
9
- * **Copied rather than resolved, which is what makes them a separate list.** A
10
- * relationship above names ids in the consumer's tree and can only travel as an
11
- * element reference; these are plain strings and the value means the same thing
12
- * wherever it is written, so the whole of the work is putting it on the node the
13
- * accessibility tree actually exposes.
14
- *
15
- * **A state written on a host reaches nobody.** `Megamenu` and `SpeedDial` write
16
- * `aria-expanded` onto whatever a consumer slots, and where that is a
17
- * `<helix-button>` it lands on a host the tree reports as `generic`: measured
18
- * through Chrome's own tree, a button named "Mega" with no expanded state at
19
- * all. A plain `<button slot="trigger">` never had the problem, because there
20
- * the host *is* the focusable node.
21
- *
22
- * **Only what this controller put there is ever taken away.** Removing any of
23
- * these from the control whenever the host lacks it reads as the obvious way to
24
- * keep the two in step and quietly deletes what the component renders for
25
- * itself: `Combobox` writes `aria-controls="listbox"` onto its own input and
26
- * `Select` writes `aria-expanded` onto its own trigger, and a controller
27
- * carrying nothing would wipe both on every update.
28
- *
29
- * So the record below holds what was forwarded last time, and a host that stops
30
- * carrying one removes exactly that and nothing else. A control's own attribute
31
- * is untouched either way, which is the only rule that can be right: this
32
- * controller knows what the host said and cannot know what the template meant.
33
- */
34
5
  const STATES = ['aria-expanded', 'aria-haspopup', 'aria-controls'];
35
- /**
36
- * Carries a host's ARIA references down to the control inside it.
37
- *
38
- * **The gap this closes, measured.** `<helix-button aria-describedby="tip">` puts
39
- * the description on the host, and the host is not what a reader is on: the
40
- * accessibility tree exposes the `<button>` in the shadow root, and the host
41
- * beside it is a generic with a description nobody hears. Read from Chrome's own
42
- * tree:
43
- *
44
- * ```
45
- * host role=generic name="" desc="Helix description"
46
- * inner button role=button name="Helix" desc=""
47
- * ```
48
- *
49
- * A plain `<button>` with the identical attribute reads `desc="Helix
50
- * description"`, so the reference itself is fine and only its destination is
51
- * wrong. This moves it.
52
- *
53
- * **An IDREF cannot make that trip and an element reference can.** The ids are
54
- * written in the consumer's tree and the control is in a shadow root, so
55
- * `aria-describedby` copied onto the inner element resolves to nothing.
56
- * `ariaDescribedByElements` takes the elements themselves, and a reference is
57
- * valid into the same tree *or a shadow-including ancestor* of it: from inside
58
- * the shadow root looking out, the document is exactly that. Measured, the
59
- * assignment reads back with the node intact, where the inward direction, from a
60
- * light-DOM trigger to a node in a shadow root, reads back empty. This is the
61
- * one direction that works, which is why `Tooltip` needs `for` and this does
62
- * not.
63
- *
64
- * **The attributes stay on the host.** Nothing is removed and nothing is added:
65
- * the consumer's markup is exactly what they wrote, which is the same reason
66
- * `hostAria` exists. What changes is only that the control now also holds the
67
- * reference, so a screen reader on the focused element hears it.
68
- *
69
- * Watched with a `MutationObserver`, because a reference can arrive at any time:
70
- * a framework rendering a tooltip after this element upgrades, or a consumer
71
- * pointing `aria-describedby` at a different id, changes an attribute and
72
- * nothing schedules a Lit update.
73
- */
74
6
  export class AriaForwardController {
75
7
  constructor(host, options) {
76
- /** Which state attributes this controller wrote, so only those are removed. */
77
8
  this.forwarded = new Set();
78
9
  this.host = host;
79
10
  this.target = options.target;
80
11
  host.addController(this);
81
12
  }
82
- /**
83
- * Resolves the host's references and hands them to the control.
84
- *
85
- * Resolved through the host's own root rather than `document`, so a button
86
- * inside another component's shadow root finds the ids written beside it
87
- * there. Ids that resolve to nothing are dropped rather than passed on: an
88
- * `aria-describedby` naming a heading that has been removed is a stale
89
- * reference, and an empty array clears the property rather than leaving the
90
- * last good one in place.
91
- */
92
13
  forward() {
93
14
  const target = this.target();
94
15
  if (!target) {
95
16
  return;
96
17
  }
97
- /* **The record belongs to the element it was written about.** `forwarded`
98
- says what this controller put on the control. Kept across a replacement,
99
- it claims a value the new element never had, and the clearing branches
100
- below then take off what the component wrote itself: the fault the record
101
- exists to prevent, one element later.
102
-
103
- **No component in this library replaces its control**, which is worth
104
- saying because the comment here used to name one. All fifteen consumers
105
- resolve to a single fixed part rendered once. `target` is a callback and
106
- its contract permits a swap, so a controller in `core` is held to the
107
- contract rather than to the fifteen callers it happens to have today. */
108
18
  if (target !== this.recorded) {
109
19
  this.forwarded.clear();
110
20
  this.recorded = target;
@@ -112,9 +22,6 @@ export class AriaForwardController {
112
22
  const root = this.host.getRootNode();
113
23
  for (const [attribute, property] of RELATIONSHIPS) {
114
24
  if (!(property in target)) {
115
- // An engine without element reflection. Nothing to do, and nothing that
116
- // silently half works: assigning to a property it does not implement is
117
- // a no-op rather than an error, which is what the guard makes visible.
118
25
  continue;
119
26
  }
120
27
  const ids = (this.host.getAttribute(attribute) ?? '').split(/\s+/).filter(Boolean);
@@ -127,14 +34,6 @@ export class AriaForwardController {
127
34
  this.forwarded.add(attribute);
128
35
  continue;
129
36
  }
130
- /* Only if this controller is what put it there, which is the same rule
131
- the states below keep and for a worse reason. `MixinField` writes
132
- `aria-describedby` on the control for its own hint, and assigning null
133
- here removes the content attribute, so forwarding nothing wiped the
134
- component's own description on all eighteen form controls. Invisible to
135
- every DOM spec: happy-dom implements no ARIA element reflection, so the
136
- `property in target` guard above skips this whole branch and the suite
137
- is green against code that clears a description in every browser. */
138
37
  if (this.forwarded.has(attribute)) {
139
38
  reflect[property] = null;
140
39
  this.forwarded.delete(attribute);
@@ -143,7 +42,6 @@ export class AriaForwardController {
143
42
  for (const attribute of STATES) {
144
43
  const value = this.host.getAttribute(attribute);
145
44
  if (value === null) {
146
- // Only if this controller is what put it there. See the note above.
147
45
  if (this.forwarded.has(attribute)) {
148
46
  target.removeAttribute(attribute);
149
47
  this.forwarded.delete(attribute);
@@ -164,9 +62,6 @@ export class AriaForwardController {
164
62
  this.observer?.disconnect();
165
63
  this.observer = undefined;
166
64
  }
167
- /* After the render that created the control, because there is nothing to
168
- forward to before it exists. Every update, not only the first: a component
169
- that re-renders its control produces a new element with none of this. */
170
65
  hostUpdated() {
171
66
  this.forward();
172
67
  }
@@ -1,51 +1,10 @@
1
- /**
2
- * Which reading a pointer is nearest, for a chart that draws no markers.
3
- *
4
- * **The anchor is a position and not an element, and that is the whole reason
5
- * this exists.** A bar and a donut slice are elements a tooltip could be hung
6
- * on; a line is one `<path>` for a whole series, because
7
- * `preserveAspectRatio="none"` turns a circle into an ellipse and there is no
8
- * vector-effect that answers for a shape, so `LineChart` deliberately draws no
9
- * per-reading marker. Anchoring per datum works for two charts of four and
10
- * cannot be extended to the third, so the resolution is arithmetic instead.
11
- *
12
- * **It maps along one axis only.** The nearest reading to a pointer is decided
13
- * by x alone in a vertical chart, whatever y is doing, because a reader moving
14
- * across a chart is asking about a column of it rather than about a point. A
15
- * true nearest-point search would make a two-series chart answer differently
16
- * depending on which line the pointer drifted toward, which is a tooltip that
17
- * changes its mind while the reader holds still.
18
- *
19
- * **Coarse pointers get nothing.** A touch has no hover, so a tooltip driven by
20
- * one appears where a finger already covers it and stays after the finger
21
- * lifts. The table underneath is the readable version and is what a phone
22
- * should be given, so this listens for `pointermove` and ignores anything whose
23
- * `pointerType` is not fine.
24
- */
25
1
  export class ChartPointerController {
26
2
  #host;
27
3
  #moved;
28
4
  #plot;
29
5
  constructor(host) {
30
6
  this.#moved = false;
31
- /** The reading under the pointer, or -1 when the pointer is elsewhere. */
32
7
  this.at = -1;
33
- /**
34
- * Which axis the readings are laid out along.
35
- *
36
- * **An axis and not a boolean, because two of the four charts do not use x.**
37
- * A horizontal bar chart runs its categories *down* the box, so resolving
38
- * every reading from the pointer's x leaves a move from the first row to the
39
- * last never changing the answer: measured on four rows, all four reported
40
- * the third, which is the slot the middle of the box falls in. It looks like
41
- * a tooltip that will not follow and is a tooltip reading the wrong axis.
42
- *
43
- * `none` is the ring. Its slices are real elements at every angle, so the
44
- * reading under the pointer is the one the pointer is over, and arithmetic on
45
- * either axis would answer with whichever slice shared a coordinate: at three
46
- * o'clock and at nine o'clock, x alone gives the same slice for opposite
47
- * sides. The slices call `set` and this only holds the answer.
48
- */
49
8
  this.axis = 'inline';
50
9
  this.#move = (event) => {
51
10
  if (this.axis === 'none' || (event.pointerType !== 'mouse' && event.pointerType !== 'pen')) {
@@ -57,11 +16,6 @@ export class ChartPointerController {
57
16
  if (!box || !along || count === 0) {
58
17
  return;
59
18
  }
60
- /* The same slot arithmetic the charts draw with: reading `i` sits in the
61
- middle of the `i`th of `count` equal slots, so the nearest is the slot
62
- the pointer is in. Rounding a proportion of `count - 1` instead puts the
63
- boundaries half a slot off and the first and last reading get half the
64
- catchment of the others. */
65
19
  const from = this.axis === 'block' ? event.clientY - box.top : event.clientX - box.left;
66
20
  const slot = Math.floor((from / along) * count);
67
21
  const next = Math.min(Math.max(slot, 0), count - 1);
@@ -89,20 +43,6 @@ export class ChartPointerController {
89
43
  this.#host.removeEventListener('pointerleave', this.#leave);
90
44
  this.at = -1;
91
45
  }
92
- /**
93
- * Tells the tooltip its anchor moved, which nothing else would.
94
- *
95
- * The mark the tooltip is hung on is one element the chart walks from reading
96
- * to reading with an inline style, and Floating UI's `autoUpdate` watches
97
- * scrolling, resizing and layout shift. Moving an absolutely positioned
98
- * element by writing `inset` is none of the three, so the bubble is placed
99
- * once on open and then stays where the first reading was while the mark
100
- * travels out from under it.
101
- *
102
- * Here rather than in the chart because the reading is this controller's, and
103
- * after the update rather than in `set` because the mark is still at the old
104
- * position until the render that follows.
105
- */
106
46
  hostUpdated() {
107
47
  if (!this.#moved) {
108
48
  return;
@@ -112,21 +52,10 @@ export class ChartPointerController {
112
52
  .querySelector('[part="tooltip-anchor"]')
113
53
  ?.reposition?.();
114
54
  }
115
- /**
116
- * The box the readings are laid out in, which is the plot and not the host.
117
- *
118
- * The host also holds the row of labels, so measuring against it would map
119
- * the pointer over a box taller than the drawing. That mattered the moment a
120
- * chart started measuring down the box rather than across it, which is what
121
- * `axis` is for: on a horizontal bar chart the labels are beside the plot
122
- * rather than under it, and the host is wider than the drawing by exactly
123
- * their column.
124
- */
125
55
  #box() {
126
56
  this.#plot ??= this.#host.renderRoot.querySelector('[part="plot"]') ?? undefined;
127
57
  return this.#plot?.getBoundingClientRect();
128
58
  }
129
- /** Puts the pointer on a reading, for a chart that resolves it some other way. */
130
59
  set(at) {
131
60
  if (at !== this.at) {
132
61
  this.at = at;
@@ -1,73 +1,11 @@
1
- /**
2
- * Whether the browser implements invoker commands.
3
- *
4
- * Feature-detected on the prototype rather than on an instance, so it can be
5
- * answered before anything has rendered, and guarded for a server, which has no
6
- * `HTMLButtonElement` at all.
7
- */
8
1
  export const supportsInvokers = () => typeof HTMLButtonElement !== 'undefined' && 'commandForElement' in HTMLButtonElement.prototype;
9
- /**
10
- * The `command` event, for a browser that does not send one.
11
- *
12
- * Invoker commands are recent: Chrome 135, Firefox 137, Safari 18.4. Before
13
- * this, a button in an older browser set `commandForElement` on nothing, the
14
- * wiring silently did not happen, and the control did nothing at all when
15
- * clicked. No error, no warning, and every `command` in the library dead at
16
- * once, which is the worst way for a feature to be missing: the markup is
17
- * right, the component is right, and the button is simply inert.
18
- *
19
- * So the button sends this instead where the platform will not. It carries the
20
- * two members `CommandController` reads and dispatches at the target, which is
21
- * where the platform dispatches too, so nothing downstream can tell the
22
- * difference.
23
- *
24
- * **The built-in commands are not reproduced.** `show-modal`, `close` and
25
- * `toggle-popover` are defined against native elements and applied by the
26
- * browser itself, and writing our own would mean reimplementing part of the
27
- * platform against elements we do not own. Nothing in this library uses them:
28
- * a custom element only ever answers a `--` command, which is the platform's
29
- * marker for "the target decides", and the target is ours.
30
- */
31
2
  export class HelixFallbackCommandEvent extends Event {
32
3
  constructor(command, source) {
33
- // Matching the platform: fired at the invokee, cancelable, and not
34
- // bubbling. `CommandController` listens on the target itself, so there is
35
- // nothing above it that a bubble would reach anyway.
36
4
  super('command', { cancelable: true });
37
5
  this.command = command;
38
6
  this.source = source;
39
7
  }
40
8
  }
41
- /**
42
- * Lets a control elsewhere on the page operate this one, declaratively.
43
- *
44
- * ```html
45
- * <helix-button commandfor="confirm" command="--show">Delete…</helix-button>
46
- * <helix-dialog id="confirm">…</helix-dialog>
47
- * ```
48
- *
49
- * **The platform's own invoker, not a convention of ours.** `commandfor` and
50
- * `command` are attributes the browser understands: it resolves the id, wires
51
- * the two together, and dispatches a `command` event at the target when the
52
- * trigger is activated. That is a real event with a real source, so it works
53
- * for a click, for Enter, for Space, and for anything else the platform decides
54
- * counts as activation later.
55
- *
56
- * A `data-` recipe of our own would mean a delegated document listener a
57
- * consumer cannot see, an id lookup and activation semantics we would write
58
- * ourselves, and a convention that is ours to document and theirs to learn.
59
- *
60
- * **Only custom commands reach a custom element.** The built-in ones
61
- * (`show-modal`, `close`, `toggle-popover`) are defined against native elements
62
- * and the browser applies them itself; pointed at a custom element they do
63
- * nothing. So the names here start with `--`, which is the platform's marker
64
- * for "the target decides what this means", and the target deciding is exactly
65
- * what this is.
66
- *
67
- * Unrecognized commands are left alone rather than swallowed, so a host that
68
- * answers `--show` does not quietly absorb a `--print` meant for something
69
- * else.
70
- */
71
9
  export class CommandController {
72
10
  constructor(host, options) {
73
11
  this.handle = (event) => {
@@ -1,29 +1,10 @@
1
1
  import { HelixCopyEvent } from '../../events/events.js';
2
- /**
3
- * Writes to the clipboard, by whichever of the two routes this context allows.
4
- *
5
- * The async API first, because it is the one with a future and the only one that
6
- * reports failure. It is gated in more places than it looks: an insecure origin,
7
- * a document that is not focused, and a Permissions Policy that defaults to
8
- * `self`, which a sandboxed iframe without `allow-same-origin` is not.
9
- *
10
- * `execCommand` second, deprecated and still the more widely permitted of the
11
- * two: it is synchronous and rides the user gesture that called it rather than
12
- * asking for a permission, so it works in a sandboxed frame where the modern one
13
- * is refused. A textarea rather than the selection, because the text being
14
- * copied is not on the page: it is a value.
15
- *
16
- * `fixed` and `opacity: 0` rather than off-screen, because scrolling to a
17
- * focused element is what a negative offset causes, and a copy that jumps the
18
- * page is worse than one that does not happen.
19
- */
20
2
  const write = async (text) => {
21
3
  try {
22
4
  await navigator.clipboard.writeText(text);
23
5
  return true;
24
6
  }
25
7
  catch {
26
- /* Fall through. There is nothing to report yet. */
27
8
  }
28
9
  const carrier = document.createElement('textarea');
29
10
  carrier.value = text;
@@ -31,9 +12,6 @@ const write = async (text) => {
31
12
  carrier.setAttribute('aria-hidden', 'true');
32
13
  carrier.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
33
14
  document.body.append(carrier);
34
- /* Focused as well as selected. `select()` focuses in a browser and does not
35
- everywhere, and `execCommand('copy')` copies the *document's* selection, so
36
- an unfocused textarea copies whatever was selected on the page instead. */
37
15
  carrier.focus();
38
16
  carrier.select();
39
17
  let wrote = false;
@@ -46,47 +24,13 @@ const write = async (text) => {
46
24
  carrier.remove();
47
25
  return wrote;
48
26
  };
49
- /**
50
- * Puts text on the clipboard and holds a confirmation for a moment.
51
- *
52
- * A controller rather than a component, because copying is a behavior and not a
53
- * shape. Color's chip is a swatch that copies, and a copy button is a button
54
- * that copies, and those two have nothing in common to look at: what they share
55
- * is the write, the confirmation that follows it, the timer that ends it and the
56
- * event that announces it. Written twice, the second one would get the failure
57
- * case wrong.
58
- *
59
- * **There are two routes and both are tried**, because the modern one is gated
60
- * in more places than it looks and a control that does nothing is
61
- * indistinguishable from a broken one. See `write` above.
62
- *
63
- * **Where neither route is allowed, the failure is silence.** The confirmation
64
- * does not appear, which is the honest report: a message about a clipboard is
65
- * worse than a chip that did not flash, and there is nothing a reader could do
66
- * about a permission policy anyway.
67
- *
68
- * **The event is announced after the write, not before it.** It is not
69
- * cancelable for the same reason: by the time anything could be said, the text
70
- * is already on the clipboard, and an event that cannot prevent what it
71
- * describes should not pretend otherwise.
72
- *
73
- * The timer is cleared when the host leaves the document, or a component removed
74
- * mid-confirmation asks a detached element to re-render.
75
- */
76
27
  export class CopyController {
77
28
  constructor(host, options = {}) {
78
29
  this.host = host;
79
30
  this.options = options;
80
- /** Whether the last write succeeded and is still being confirmed. */
81
31
  this.copied = false;
82
32
  host.addController(this);
83
33
  }
84
- /**
85
- * Writes to the clipboard, and confirms it if the platform allowed it.
86
- *
87
- * Empty text is not copied. An empty clipboard is not a thing a reader ever
88
- * asks for, and confirming one is worse than doing nothing.
89
- */
90
34
  async copy(text) {
91
35
  if (text === '') {
92
36
  return;
@@ -94,13 +38,6 @@ export class CopyController {
94
38
  if (!(await write(text))) {
95
39
  return;
96
40
  }
97
- /* **The host may have gone while the clipboard was being written.** Writing
98
- is asynchronous, `hostDisconnected` runs the moment the element leaves,
99
- and it clears a timer that does not exist yet, so without this the
100
- confirmation starts a fresh timer on a detached host and calls
101
- `requestUpdate` on it a second later. Checked here rather than in
102
- `reset`, because the thing to skip is the whole confirmation and not only
103
- its clock. */
104
41
  if (this.host instanceof HTMLElement && !this.host.isConnected) {
105
42
  return;
106
43
  }
@@ -1,44 +1,4 @@
1
1
  import { inheritDisabled, } from '../../utils/inherit-disabled/inherit-disabled.js';
2
- /**
3
- * Pushes a group's `disabled` onto its members, on every update, unasked.
4
- *
5
- * **The cascade itself is `inheritDisabled`, and this is what makes it
6
- * automatic.** Called by hand it runs only when whichever method holds the call
7
- * runs, so a group whose items changed by some other path leaves them holding
8
- * the last answer, and a group that forgets the call gets no cascade at all
9
- * with nothing to say so: the property exists, the attribute reflects, and the
10
- * members simply ignore it. That is invisible in review and shows up only when
11
- * someone disables a set.
12
- *
13
- * It adds no API. A group already has `disabled`, from `HelixFormElement` or
14
- * from the consumer; nothing here introduces a property or a method for anyone
15
- * to call. By this repo's own rule that makes it a controller and not a mixin:
16
- * it reacts to a lifecycle and nothing else.
17
- *
18
- * So a group states its members once, at construction, and never thinks about
19
- * it again:
20
- *
21
- * new DisabledCascadeController(this, { items: () => this.items })
22
- *
23
- * **Runs after every render, and on every `slotchange`.** The second half is
24
- * not an extra: a member arriving in the light DOM changes no property, so Lit
25
- * schedules nothing, and a render-only cascade leaves a member slotted into an
26
- * already-disabled group enabled. Measured before it was added, on a radio
27
- * group holding two disabled members with a third inserted: `110`, and the
28
- * third was live in a set nobody could use.
29
- *
30
- * The listener is on the render root rather than on the host, because
31
- * `slotchange` bubbles but is not composed: it reaches the shadow root that
32
- * owns the slot and stops there. On the host it would never fire at all.
33
- *
34
- * After render rather than before, because the items are read from the DOM and
35
- * a newly slotted member is not there until the render that assigns it.
36
- *
37
- * Idempotent, so running on every update costs a comparison per member and
38
- * nothing else: `inheritDisabled` writes only where the answer changed and
39
- * remembers what each member said for itself, which is what lets a group be
40
- * re-enabled without freeing a member that was disabled on its own.
41
- */
42
2
  export class DisabledCascadeController {
43
3
  constructor(host, { items }) {
44
4
  this.cascade = () => {