@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,27 @@
1
+ /**
2
+ * The round numbers a reader would infer inside a domain.
3
+ *
4
+ * **A gridline at an arbitrary number is worse than no gridline.** The point of
5
+ * a rule across a chart is that a reader can name the level it sits at without
6
+ * being told, so it has to land on a number a person would have picked: 0, 250,
7
+ * 500, not 187.4. Dividing the domain into equal parts gives the second, and
8
+ * that is what makes this arithmetic rather than a loop.
9
+ *
10
+ * The step is the power of ten below the rough interval, multiplied by whichever
11
+ * of 1, 2, 5 or 10 lands nearest the count asked for. Those four because they
12
+ * are the multipliers that keep every tick short in decimal, which is the whole
13
+ * property being bought: 2.5 divides as evenly as 2 and reads as a worse number.
14
+ *
15
+ * **The count is a target and not a promise**, which is the usual surprise.
16
+ * Asking for five over a domain of 0 to 1000 gives six (0, 200, ... 1000);
17
+ * asking for five over 0 to 12 gives seven at a step of 2. Forcing the count
18
+ * would mean forcing the step, which is the thing worth keeping round. A caller
19
+ * that needs an exact number of rules wants equal division and not this.
20
+ *
21
+ * Ticks outside the domain are dropped rather than clamped, so nothing is drawn
22
+ * off the end of the box and no two rules land on the same line.
23
+ *
24
+ * A pure function, for the reason `linear` gives beside it: it holds nothing,
25
+ * subscribes to nothing, and can be called from `render`.
26
+ */
27
+ export declare const ticks: (domain: readonly [number, number], count?: number) => number[];
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The round numbers a reader would infer inside a domain.
3
+ *
4
+ * **A gridline at an arbitrary number is worse than no gridline.** The point of
5
+ * a rule across a chart is that a reader can name the level it sits at without
6
+ * being told, so it has to land on a number a person would have picked: 0, 250,
7
+ * 500, not 187.4. Dividing the domain into equal parts gives the second, and
8
+ * that is what makes this arithmetic rather than a loop.
9
+ *
10
+ * The step is the power of ten below the rough interval, multiplied by whichever
11
+ * of 1, 2, 5 or 10 lands nearest the count asked for. Those four because they
12
+ * are the multipliers that keep every tick short in decimal, which is the whole
13
+ * property being bought: 2.5 divides as evenly as 2 and reads as a worse number.
14
+ *
15
+ * **The count is a target and not a promise**, which is the usual surprise.
16
+ * Asking for five over a domain of 0 to 1000 gives six (0, 200, ... 1000);
17
+ * asking for five over 0 to 12 gives seven at a step of 2. Forcing the count
18
+ * would mean forcing the step, which is the thing worth keeping round. A caller
19
+ * that needs an exact number of rules wants equal division and not this.
20
+ *
21
+ * Ticks outside the domain are dropped rather than clamped, so nothing is drawn
22
+ * off the end of the box and no two rules land on the same line.
23
+ *
24
+ * A pure function, for the reason `linear` gives beside it: it holds nothing,
25
+ * subscribes to nothing, and can be called from `render`.
26
+ */
27
+ export const ticks = (domain, count = 5) => {
28
+ const [low, high] = domain;
29
+ /* A flat domain has no levels to name. Zero rules rather than one, because a
30
+ single rule lying exactly under a flat line is a rule nobody can see and a
31
+ line a reader may take for the grid. */
32
+ if (!Number.isFinite(low) || !Number.isFinite(high) || low === high || count < 1) {
33
+ return [];
34
+ }
35
+ const rough = (high - low) / count;
36
+ const magnitude = 10 ** Math.floor(Math.log10(rough));
37
+ const eased = rough / magnitude;
38
+ const step = magnitude * (eased >= 7.5 ? 10 : eased >= 3 ? 5 : eased >= 1.5 ? 2 : 1);
39
+ const found = [];
40
+ /* From the first multiple of the step at or above the floor, so the run is
41
+ anchored to the number line rather than to wherever the data happens to
42
+ start. Two charts of the same shape over different ranges then draw rules
43
+ at the same levels, which is what makes them comparable at a glance. */
44
+ for (let at = Math.ceil(low / step) * step; at <= high; at += step) {
45
+ /* Floating point makes 0.30000000000000004 out of three tenths, and a
46
+ gridline is a number a reader sees. Rounded to the step's own precision,
47
+ which is as fine as any tick can legitimately be. */
48
+ const places = Math.max(0, -Math.floor(Math.log10(step)));
49
+ found.push(Number(at.toFixed(places)));
50
+ }
51
+ return found;
52
+ };
@@ -0,0 +1,10 @@
1
+ /** What the util needs of a scroller and the thing it is bringing into it. */
2
+ export interface ScrollItemOptions {
3
+ /** Space left between the item and the edge it came in at. */
4
+ padding?: number;
5
+ /** The scrolling box. Defaults to the nearest ancestor that scrolls. */
6
+ scroller?: HTMLElement;
7
+ /** Whether to animate. Ignored where the reader asked for less motion. */
8
+ smooth?: boolean;
9
+ }
10
+ export declare const scrollItemIntoView: (item: HTMLElement, options?: ScrollItemOptions) => void;
@@ -0,0 +1,98 @@
1
+ /*
2
+ * Brings an item into view inside its own scroller, and moves nothing else.
3
+ *
4
+ * **Not `Element.scrollIntoView`, and the difference is the whole reason this
5
+ * exists.** That method walks every scrollable ancestor and scrolls all of
6
+ * them, so bringing the current item into a tree also scrolls the page the tree
7
+ * sits on, and on a phone it takes the address bar with it. A reader who
8
+ * pressed Down in a sidebar did not ask the document to move.
9
+ *
10
+ * It also does nothing when the item is already visible, where `scrollIntoView`
11
+ * with `block: 'nearest'` still nudges. Nudging on every keypress is what makes
12
+ * a long list feel like it is fighting the reader.
13
+ *
14
+ * Both axes, because the components that need this do not agree on one: a
15
+ * carousel scrolls sideways and a tree scrolls down, and a menu that has run
16
+ * out of room does both.
17
+ *
18
+ * `smooth` is a request rather than an instruction. Animating a scroll the
19
+ * reader triggered by pressing a key means the list is still moving when the
20
+ * next press arrives, so this follows `prefers-reduced-motion` and is off by
21
+ * default for exactly that case.
22
+ */
23
+ /**
24
+ * The nearest ancestor that actually scrolls, crossing shadow boundaries.
25
+ *
26
+ * This was `item.offsetParent`, which is the nearest *positioned* ancestor and
27
+ * has nothing whatever to do with overflow. The two coincide often enough to
28
+ * look right, because a scroller frequently is positioned, and they come apart
29
+ * exactly where it matters: put anything `position: relative` between the item
30
+ * and the box that scrolls, and the util scrolled the thing in between, which
31
+ * does not scroll, and reported nothing. `Tree` passes no scroller at all, so
32
+ * it had the default on every keypress.
33
+ *
34
+ * The walk crosses shadow boundaries through the host, because the item is
35
+ * almost always in one and `parentElement` is null at the root. Without that
36
+ * step, a component whose scroller is its own `[part]` would find nothing.
37
+ *
38
+ * It stops before the document. Scrolling the page is the one thing this whole
39
+ * util exists not to do, so finding nothing inside the component and returning
40
+ * null is the honest answer: the caller no-ops rather than taking the address
41
+ * bar with it.
42
+ */
43
+ const scrollingAncestor = (item) => {
44
+ const stop = item.ownerDocument.body;
45
+ let node = climb(item);
46
+ while (node && node !== stop) {
47
+ const style = getComputedStyle(node);
48
+ /* Both axes, since the caller may need either, and `overlay` because a
49
+ scroller styled with it scrolls exactly as `auto` does. `clip` and
50
+ `hidden` are deliberately not here: they cut the content off and are not
51
+ something a reader can move. */
52
+ if (/\b(auto|scroll|overlay)\b/.test(`${style.overflowX} ${style.overflowY}`)) {
53
+ return node;
54
+ }
55
+ node = climb(node);
56
+ }
57
+ return null;
58
+ };
59
+ /** Up one, through a shadow root where that is what is above. */
60
+ const climb = (node) => {
61
+ if (node.parentElement) {
62
+ return node.parentElement;
63
+ }
64
+ const root = node.getRootNode();
65
+ return root instanceof ShadowRoot ? root.host : null;
66
+ };
67
+ export const scrollItemIntoView = (item, options = {}) => {
68
+ const { padding = 0, smooth = false } = options;
69
+ const scroller = options.scroller ?? scrollingAncestor(item);
70
+ if (!scroller) {
71
+ return;
72
+ }
73
+ /* Measured from the boxes rather than from `offsetLeft`, which is relative to
74
+ the offset parent and is only the scroller when nothing between them is
75
+ positioned. The rectangles are relative to the viewport, so the difference
76
+ between them is the distance regardless of what sits in between. */
77
+ const box = item.getBoundingClientRect();
78
+ const view = scroller.getBoundingClientRect();
79
+ const before = { block: view.top - box.top + padding, inline: view.left - box.left + padding };
80
+ const after = {
81
+ block: box.bottom - view.bottom + padding,
82
+ inline: box.right - view.right + padding,
83
+ };
84
+ /* Whichever edge it is past, and nothing when it is past neither. A scroll of
85
+ zero still fires a `scroll` event and still cancels a smooth one already
86
+ running, so the early return is behavior rather than an optimisation. */
87
+ const inline = before.inline > 0 ? -before.inline : after.inline > 0 ? after.inline : 0;
88
+ const block = before.block > 0 ? -before.block : after.block > 0 ? after.block : 0;
89
+ if (inline === 0 && block === 0) {
90
+ return;
91
+ }
92
+ const still = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
93
+ scroller.scrollBy({
94
+ behavior: smooth && !still ? 'smooth' : 'instant',
95
+ left: inline,
96
+ top: block,
97
+ });
98
+ };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Whether a consumer put anything in a slot.
3
+ *
4
+ * **There is no selector for this**, which is why it is script. `slot:empty`
5
+ * looks like the answer and is not: a slot's children are its *fallback*, and
6
+ * assigned nodes are not children, so `:empty` is true whenever the fallback is
7
+ * absent whether or not the consumer slotted anything. `:host(:has(...))` is
8
+ * invalid CSS, and the server has no children to inspect.
9
+ *
10
+ * The answer matters because an empty wrapper is not free. A region that
11
+ * generates a box is a flex item or a grid item whether or not it holds
12
+ * anything, so it takes the container's gap: an announcement with no actions
13
+ * paid the row gap twice, a switch with no label took one for a label that was
14
+ * not there, and a blockquote with no attribution reserved a full space below
15
+ * every quote. `display: none` is the only fix, because only a box that is not
16
+ * generated has no gap beside it.
17
+ *
18
+ * Text or an element, because neither alone is the test. An element on its own
19
+ * counts a whitespace-only span as content; text on its own counts an icon as
20
+ * nothing.
21
+ *
22
+ * `flatten` is deliberate here, unlike the tooltip's emptiness test: this asks
23
+ * "will anything render", where that one asks "did the consumer provide
24
+ * anything", and a slot showing its fallback does render.
25
+ */
26
+ export declare const slotFilled: (slot: HTMLSlotElement | null | undefined) => boolean;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Whether a consumer put anything in a slot.
3
+ *
4
+ * **There is no selector for this**, which is why it is script. `slot:empty`
5
+ * looks like the answer and is not: a slot's children are its *fallback*, and
6
+ * assigned nodes are not children, so `:empty` is true whenever the fallback is
7
+ * absent whether or not the consumer slotted anything. `:host(:has(...))` is
8
+ * invalid CSS, and the server has no children to inspect.
9
+ *
10
+ * The answer matters because an empty wrapper is not free. A region that
11
+ * generates a box is a flex item or a grid item whether or not it holds
12
+ * anything, so it takes the container's gap: an announcement with no actions
13
+ * paid the row gap twice, a switch with no label took one for a label that was
14
+ * not there, and a blockquote with no attribution reserved a full space below
15
+ * every quote. `display: none` is the only fix, because only a box that is not
16
+ * generated has no gap beside it.
17
+ *
18
+ * Text or an element, because neither alone is the test. An element on its own
19
+ * counts a whitespace-only span as content; text on its own counts an icon as
20
+ * nothing.
21
+ *
22
+ * `flatten` is deliberate here, unlike the tooltip's emptiness test: this asks
23
+ * "will anything render", where that one asks "did the consumer provide
24
+ * anything", and a slot showing its fallback does render.
25
+ */
26
+ export const slotFilled = (slot) => (slot?.assignedNodes({ flatten: true }) ?? []).some((node) => node.nodeType === Node.ELEMENT_NODE || node.textContent?.trim() !== '');
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Tells a slotted trigger that it opens something, and whether it is open.
3
+ *
4
+ * **Two components answered this by writing `aria-expanded` onto the consumer's
5
+ * element, and that is a hydration mismatch.** `Megamenu` and `SpeedDial` both
6
+ * did, each with a note saying why: a consumer who has to mirror `open` back
7
+ * onto their own trigger is a consumer who will forget, and the failure is
8
+ * silent to everyone who can see the screen. Both notes are right about the
9
+ * gap. `Popover` refused to write anything, from the day it was written, on the
10
+ * grounds that a component decorating a consumer's markup after upgrading is
11
+ * one whose output a React consumer's hydration disagrees with.
12
+ *
13
+ * **Popover was right, measured 28 Aug 2026 against React 19.2.8.** Four
14
+ * families were server-rendered as React emits them and hydrated with the
15
+ * element definitions loaded either side of `hydrateRoot`:
16
+ *
17
+ * ```
18
+ * case writes to trigger hydration mismatch
19
+ * megamenu aria-expanded yes, both load orders
20
+ * speed dial aria-expanded yes, both load orders
21
+ * popover nothing no
22
+ * menu nothing no
23
+ * ```
24
+ *
25
+ * React reports "some attributes of the server rendered HTML didn't match the
26
+ * client properties. This won't be patched up", and the cost is not the console
27
+ * line: it answers a mismatch by discarding the server's HTML for that subtree
28
+ * and rebuilding it, which is what the carousel's own note records blinding an
29
+ * `<helix-aura>` two sections away.
30
+ *
31
+ * **A property crosses the gap that an attribute cannot, and this library
32
+ * already knew it.** `Menu` tells its items by property "rather than an
33
+ * attribute for that reason", in those words. React diffs the attributes it
34
+ * rendered and never looks at a property, so the state reaches the trigger,
35
+ * lands as `aria-expanded` inside *that element's* shadow root, and the
36
+ * consumer's markup is exactly what they served. Both positions get what they
37
+ * were protecting: nothing is written onto the consumer's element, and the
38
+ * reader is still told what the control does.
39
+ *
40
+ * **Set before upgrade is safe**, which is what lets this run without waiting
41
+ * for a definition: `ReactiveElement` captures an instance property assigned
42
+ * before it upgrades and re-applies it, so a trigger whose module has not
43
+ * loaded yet still ends up with the value.
44
+ *
45
+ * **A plain element is warned about rather than written to.** A
46
+ * `<button slot="trigger">` has no shadow root to keep the state out of the
47
+ * markup and no property to take it, and a consumer cannot mirror it themselves
48
+ * because the open state belongs to the component. Saying so once is the same
49
+ * answer `Tooltip` gives about the link it will not write for them. See #322.
50
+ */
51
+ export declare const tellDisclosure: (trigger: Element | null | undefined, open: boolean) => void;
@@ -0,0 +1,76 @@
1
+ /** Triggers already told they cannot carry the state, so it is said once. */
2
+ const warned = new WeakSet();
3
+ /**
4
+ * Tells a slotted trigger that it opens something, and whether it is open.
5
+ *
6
+ * **Two components answered this by writing `aria-expanded` onto the consumer's
7
+ * element, and that is a hydration mismatch.** `Megamenu` and `SpeedDial` both
8
+ * did, each with a note saying why: a consumer who has to mirror `open` back
9
+ * onto their own trigger is a consumer who will forget, and the failure is
10
+ * silent to everyone who can see the screen. Both notes are right about the
11
+ * gap. `Popover` refused to write anything, from the day it was written, on the
12
+ * grounds that a component decorating a consumer's markup after upgrading is
13
+ * one whose output a React consumer's hydration disagrees with.
14
+ *
15
+ * **Popover was right, measured 28 Aug 2026 against React 19.2.8.** Four
16
+ * families were server-rendered as React emits them and hydrated with the
17
+ * element definitions loaded either side of `hydrateRoot`:
18
+ *
19
+ * ```
20
+ * case writes to trigger hydration mismatch
21
+ * megamenu aria-expanded yes, both load orders
22
+ * speed dial aria-expanded yes, both load orders
23
+ * popover nothing no
24
+ * menu nothing no
25
+ * ```
26
+ *
27
+ * React reports "some attributes of the server rendered HTML didn't match the
28
+ * client properties. This won't be patched up", and the cost is not the console
29
+ * line: it answers a mismatch by discarding the server's HTML for that subtree
30
+ * and rebuilding it, which is what the carousel's own note records blinding an
31
+ * `<helix-aura>` two sections away.
32
+ *
33
+ * **A property crosses the gap that an attribute cannot, and this library
34
+ * already knew it.** `Menu` tells its items by property "rather than an
35
+ * attribute for that reason", in those words. React diffs the attributes it
36
+ * rendered and never looks at a property, so the state reaches the trigger,
37
+ * lands as `aria-expanded` inside *that element's* shadow root, and the
38
+ * consumer's markup is exactly what they served. Both positions get what they
39
+ * were protecting: nothing is written onto the consumer's element, and the
40
+ * reader is still told what the control does.
41
+ *
42
+ * **Set before upgrade is safe**, which is what lets this run without waiting
43
+ * for a definition: `ReactiveElement` captures an instance property assigned
44
+ * before it upgrades and re-applies it, so a trigger whose module has not
45
+ * loaded yet still ends up with the value.
46
+ *
47
+ * **A plain element is warned about rather than written to.** A
48
+ * `<button slot="trigger">` has no shadow root to keep the state out of the
49
+ * markup and no property to take it, and a consumer cannot mirror it themselves
50
+ * because the open state belongs to the component. Saying so once is the same
51
+ * answer `Tooltip` gives about the link it will not write for them. See #322.
52
+ */
53
+ export const tellDisclosure = (trigger, open) => {
54
+ if (!trigger) {
55
+ return;
56
+ }
57
+ /* A custom element, which is the only kind that can hold this without putting
58
+ it in the markup. The tag is the test rather than `'expanded' in trigger`,
59
+ because the trigger may not have upgraded yet and the property lives on the
60
+ prototype: asking too early would fall through to the warning for an
61
+ element that is about to be perfectly capable. */
62
+ if (trigger.localName.includes('-')) {
63
+ ;
64
+ trigger.expanded = open;
65
+ return;
66
+ }
67
+ if (warned.has(trigger)) {
68
+ return;
69
+ }
70
+ warned.add(trigger);
71
+ console.warn(`<${trigger.localName}> is slotted as a trigger and cannot be told that it opens something. ` +
72
+ 'The state would have to be written onto your markup as `aria-expanded`, which a React ' +
73
+ "consumer's hydration reports and does not patch up, so it is not written. Slot a " +
74
+ '`helix-button` or `helix-icon-button`, which carry it inside their own shadow root, or ' +
75
+ 'add `aria-haspopup` and your own `aria-expanded` to this element.');
76
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Asks to be run in `delay` milliseconds, and returns the way to stop asking.
3
+ *
4
+ * The returned function is the whole of the contract: an element that forgets to
5
+ * call it on disconnect leaks a closure over itself for the life of the page,
6
+ * and a panel that opens and closes leaks one per open.
7
+ */
8
+ export declare const subscribe: (delay: number, run: () => void) => (() => void);
@@ -0,0 +1,55 @@
1
+ const KEY = Symbol.for('helix.ticker');
2
+ const ticker = () => {
3
+ const global = globalThis;
4
+ let found = global[KEY];
5
+ if (!found) {
6
+ found = { timer: undefined, waiting: new Set() };
7
+ global[KEY] = found;
8
+ }
9
+ return found;
10
+ };
11
+ /** Sets the one timeout, for the soonest thing anybody is waiting on. */
12
+ const schedule = () => {
13
+ const held = ticker();
14
+ if (held.timer !== undefined) {
15
+ clearTimeout(held.timer);
16
+ held.timer = undefined;
17
+ }
18
+ if (held.waiting.size === 0) {
19
+ return;
20
+ }
21
+ const soonest = Math.min(...[...held.waiting].map((one) => one.at));
22
+ /* Floored at a second: a subscriber handed a zero would be re-armed inside the
23
+ callback that fired it, which is a busy loop wearing a timer's clothes. */
24
+ const wait = Math.max(1000, soonest - Date.now());
25
+ held.timer = setTimeout(() => {
26
+ held.timer = undefined;
27
+ const now = Date.now();
28
+ /* Copied before iterating, because every callback re-subscribes and would
29
+ otherwise be walked again inside the same pass. */
30
+ for (const one of [...held.waiting]) {
31
+ if (one.at <= now) {
32
+ held.waiting.delete(one);
33
+ one.run();
34
+ }
35
+ }
36
+ schedule();
37
+ }, wait);
38
+ };
39
+ /**
40
+ * Asks to be run in `delay` milliseconds, and returns the way to stop asking.
41
+ *
42
+ * The returned function is the whole of the contract: an element that forgets to
43
+ * call it on disconnect leaks a closure over itself for the life of the page,
44
+ * and a panel that opens and closes leaks one per open.
45
+ */
46
+ export const subscribe = (delay, run) => {
47
+ const held = ticker();
48
+ const entry = { at: Date.now() + delay, run };
49
+ held.waiting.add(entry);
50
+ schedule();
51
+ return () => {
52
+ held.waiting.delete(entry);
53
+ schedule();
54
+ };
55
+ };
@@ -0,0 +1,52 @@
1
+ import { type HelixIcon } from '@fusion.dev/helix-icons';
2
+ /**
3
+ * The four status tones, as a type anything branching on them can take.
4
+ *
5
+ * **A subset of `tone` rather than an axis of its own, which is what the rename
6
+ * records.** This was `HelixSeverity`, and `severity` turned out to be a sixth
7
+ * spelling of the axis for what a thing *means*: `Callout` and `Toast.Item`
8
+ * report a status and never a brand role, so they take four of the seven values
9
+ * and not four values of their own. The components moved with #158; the
10
+ * vocabulary underneath them was still saying the old word.
11
+ *
12
+ * `Status` stays in the name because that is the half of `tone` these are: there
13
+ * is no mark for `primary` or `accent`, a brand role having nothing to announce.
14
+ */
15
+ export type HelixStatusTone = 'danger' | 'info' | 'success' | 'warning';
16
+ /** What a tone is drawn as, and what it is called. */
17
+ export interface ToneMark {
18
+ /** The mark drawn beside the message. Never the only carrier of the meaning. */
19
+ icon: HelixIcon;
20
+ /** What a screen reader is told, since the color says nothing to them. */
21
+ label: string;
22
+ }
23
+ /**
24
+ * The one status vocabulary.
25
+ *
26
+ * It was two, defined identically in `Callout` and in `Toast.Item`, and only one
27
+ * of the copies carried the comment saying the two must not drift. A reader
28
+ * meets these marks in a passage and in a message and has to read them as the
29
+ * same four things, so there is one place they come from.
30
+ *
31
+ * **Filled at every tone**, which is what it already was at one of them.
32
+ * `danger` took `xCircleFill` and the other three took outlines, so a set meant
33
+ * to be read as one vocabulary had one member drawn a different way. Filled
34
+ * because a tone mark is a status indicator and not an action: it wants to
35
+ * read at a glance and at a small size, where an outline at this weight loses
36
+ * its interior.
37
+ *
38
+ * **The label is not the tone name.** `danger` is announced as "Error",
39
+ * because that is the word a reader understands and `danger` is the word the API
40
+ * uses. Both matter and they are not the same string.
41
+ */
42
+ export declare const TONE_MARKS: Record<HelixStatusTone, ToneMark>;
43
+ /**
44
+ * The mark for a tone, or `undefined` where the name is not one of the four.
45
+ *
46
+ * A lookup rather than direct indexing, because `tone` reaches a component as an
47
+ * attribute and an attribute can say anything, including the three tones that
48
+ * have no mark. Callers decide what an unknown one means: `Callout` falls back
49
+ * to `info`, since a callout is always *about* something, and `Toast.Item` draws
50
+ * nothing, since a message is just the next thing that happened.
51
+ */
52
+ export declare const toneMark: (tone: string | undefined) => ToneMark | undefined;
@@ -0,0 +1,36 @@
1
+ import { checkCircleFill, infoFill, warningFill, xCircleFill, } from '@fusion.dev/helix-icons';
2
+ /**
3
+ * The one status vocabulary.
4
+ *
5
+ * It was two, defined identically in `Callout` and in `Toast.Item`, and only one
6
+ * of the copies carried the comment saying the two must not drift. A reader
7
+ * meets these marks in a passage and in a message and has to read them as the
8
+ * same four things, so there is one place they come from.
9
+ *
10
+ * **Filled at every tone**, which is what it already was at one of them.
11
+ * `danger` took `xCircleFill` and the other three took outlines, so a set meant
12
+ * to be read as one vocabulary had one member drawn a different way. Filled
13
+ * because a tone mark is a status indicator and not an action: it wants to
14
+ * read at a glance and at a small size, where an outline at this weight loses
15
+ * its interior.
16
+ *
17
+ * **The label is not the tone name.** `danger` is announced as "Error",
18
+ * because that is the word a reader understands and `danger` is the word the API
19
+ * uses. Both matter and they are not the same string.
20
+ */
21
+ export const TONE_MARKS = {
22
+ danger: { icon: xCircleFill, label: 'Error' },
23
+ info: { icon: infoFill, label: 'Information' },
24
+ success: { icon: checkCircleFill, label: 'Success' },
25
+ warning: { icon: warningFill, label: 'Warning' },
26
+ };
27
+ /**
28
+ * The mark for a tone, or `undefined` where the name is not one of the four.
29
+ *
30
+ * A lookup rather than direct indexing, because `tone` reaches a component as an
31
+ * attribute and an attribute can say anything, including the three tones that
32
+ * have no mark. Callers decide what an unknown one means: `Callout` falls back
33
+ * to `info`, since a callout is always *about* something, and `Toast.Item` draws
34
+ * nothing, since a message is just the next thing that happened.
35
+ */
36
+ export const toneMark = (tone) => tone ? TONE_MARKS[tone] : undefined;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A date or time control's string value, read as the platform reads it.
3
+ *
4
+ * **Six families needed this and none of them could have it alone.** `Date
5
+ * Input` and `Date Picker` both hold `YYYY-MM-DD`, `Time Input` and `Time
6
+ * Picker` both hold `HH:mm`, and `Number Input` and `Rating` both hold a
7
+ * number, so `valueAsDate` and `valueAsNumber` would have been four
8
+ * implementations of two conversions with four chances to disagree about what
9
+ * an invalid value returns. Here rather than in a shared folder under
10
+ * `helix/headless`, because there is no such folder and this is a pure function
11
+ * of a string: it sits beside `formatNumbers` and `relativeTime`, which are the
12
+ * same kind of thing.
13
+ *
14
+ * **UTC throughout, which is the platform's own choice and looks wrong until it
15
+ * is explained.** `<input type="date">.valueAsDate` returns midnight UTC rather
16
+ * than midnight local, so a date that is the same date everywhere does not
17
+ * become a different one by crossing a timezone. Constructing through
18
+ * `new Date('2026-08-26')` happens to do this and `new Date(2026, 7, 26)` does
19
+ * not, and the difference is a whole day for anybody west of Greenwich: the
20
+ * local constructor gives a `Date` whose UTC date is the 25th. `Date.UTC` says
21
+ * which one is meant rather than relying on a parse rule.
22
+ *
23
+ * **Invalid is `null` and `NaN`, never a thrown error and never a guess.** That
24
+ * is what the platform answers for a half-typed date, which is most of the
25
+ * keystrokes a reader makes in one: a control that threw on the way to a valid
26
+ * value would be unusable, and one that guessed would submit a date nobody
27
+ * chose.
28
+ */
29
+ /**
30
+ * The date a `YYYY-MM-DD` value names, at midnight UTC, or `null`.
31
+ *
32
+ * The range check is not decoration. `2026-02-31` matches the pattern and
33
+ * `Date.UTC` rolls it forward to 3 March, so without reading the parts back a
34
+ * control would report a date the reader never typed and never saw.
35
+ */
36
+ export declare const valueAsDate: (value: string) => Date | null;
37
+ /** The `YYYY-MM-DD` value for a date, or the empty string, which is how a control says nothing. */
38
+ export declare const dateAsValue: (date: Date | null) => string;
39
+ /**
40
+ * The time an `HH:mm` value names, on 1 January 1970 UTC, or `null`.
41
+ *
42
+ * The platform's own answer for `<input type="time">`, and the epoch day is
43
+ * what makes `valueAsNumber` come out as milliseconds since midnight rather
44
+ * than as an instant nobody meant.
45
+ */
46
+ export declare const valueAsTime: (value: string) => Date | null;
47
+ /**
48
+ * The `HH:mm` value for a time, or the empty string.
49
+ *
50
+ * Seconds are written only where there are any, because `HH:mm` is what this
51
+ * library's time controls hold and a trailing `:00` on every value would be a
52
+ * format change dressed as a conversion.
53
+ */
54
+ export declare const timeAsValue: (date: Date | null) => string;
55
+ /**
56
+ * The number a value holds, or `NaN`.
57
+ *
58
+ * `Number('')` is zero, which is the trap: an empty control would report a
59
+ * number it does not hold and a form would submit it. The platform answers
60
+ * `NaN` for an empty `<input type="number">` and so does this.
61
+ */
62
+ export declare const valueAsNumber: (value: string) => number;
63
+ /** The value for a number, or the empty string, which is how a control says nothing. */
64
+ export declare const numberAsValue: (count: number) => string;