@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.
- package/LICENSE +21 -0
- package/custom-elements.json +8005 -0
- package/dist/controllers/aria-forward-controller/aria-forward-controller.d.ts +67 -0
- package/dist/controllers/aria-forward-controller/aria-forward-controller.js +146 -0
- package/dist/controllers/chart-pointer-controller/chart-pointer-controller.d.ts +79 -0
- package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +140 -0
- package/dist/controllers/command-controller/command-controller.d.ts +83 -0
- package/dist/controllers/command-controller/command-controller.js +93 -0
- package/dist/controllers/copy-controller/copy-controller.d.ts +58 -0
- package/dist/controllers/copy-controller/copy-controller.js +128 -0
- package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.d.ts +76 -0
- package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.js +67 -0
- package/dist/controllers/dismiss-controller/dismiss-controller.d.ts +58 -0
- package/dist/controllers/dismiss-controller/dismiss-controller.js +165 -0
- package/dist/controllers/drilldown-controller/drilldown-controller.d.ts +127 -0
- package/dist/controllers/drilldown-controller/drilldown-controller.js +171 -0
- package/dist/controllers/floating-controller/floating-controller.d.ts +123 -0
- package/dist/controllers/floating-controller/floating-controller.js +227 -0
- package/dist/controllers/focus-trap-controller/focus-trap-controller.d.ts +26 -0
- package/dist/controllers/focus-trap-controller/focus-trap-controller.js +93 -0
- package/dist/controllers/indicator-controller/indicator-controller.d.ts +97 -0
- package/dist/controllers/indicator-controller/indicator-controller.js +164 -0
- package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.d.ts +113 -0
- package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.js +117 -0
- package/dist/controllers/popover-controller/popover-controller.d.ts +132 -0
- package/dist/controllers/popover-controller/popover-controller.js +150 -0
- package/dist/controllers/source-controller/source-controller.d.ts +55 -0
- package/dist/controllers/source-controller/source-controller.js +94 -0
- package/dist/controllers/token-color-controller/token-color-controller.d.ts +63 -0
- package/dist/controllers/token-color-controller/token-color-controller.js +105 -0
- package/dist/controllers/virtualize-controller/virtualize-controller.d.ts +127 -0
- package/dist/controllers/virtualize-controller/virtualize-controller.js +216 -0
- package/dist/events/element-events.d.ts +4 -0
- package/dist/events/element-events.js +34 -0
- package/dist/events/events.d.ts +743 -0
- package/dist/events/events.js +799 -0
- package/dist/helix-element/helix-element.d.ts +201 -0
- package/dist/helix-element/helix-element.js +695 -0
- package/dist/helix-form-element/helix-form-element.d.ts +152 -0
- package/dist/helix-form-element/helix-form-element.js +194 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +54 -0
- package/dist/mixins/mixin-chart-source/mixin-chart-source.d.ts +137 -0
- package/dist/mixins/mixin-chart-source/mixin-chart-source.js +140 -0
- package/dist/mixins/mixin-collection/index.d.ts +1 -0
- package/dist/mixins/mixin-collection/index.js +1 -0
- package/dist/mixins/mixin-collection/mixin-collection.d.ts +58 -0
- package/dist/mixins/mixin-collection/mixin-collection.js +154 -0
- package/dist/mixins/mixin-controller-active/mixin-controller-active.d.ts +53 -0
- package/dist/mixins/mixin-controller-active/mixin-controller-active.js +155 -0
- package/dist/mixins/mixin-controller-checked/mixin-controller-checked.d.ts +54 -0
- package/dist/mixins/mixin-controller-checked/mixin-controller-checked.js +157 -0
- package/dist/mixins/mixin-controller-open/mixin-controller-open.d.ts +49 -0
- package/dist/mixins/mixin-controller-open/mixin-controller-open.js +176 -0
- package/dist/mixins/mixin-controller-value/mixin-controller-value.d.ts +73 -0
- package/dist/mixins/mixin-controller-value/mixin-controller-value.js +173 -0
- package/dist/mixins/mixin-dismissible/mixin-dismissible.d.ts +41 -0
- package/dist/mixins/mixin-dismissible/mixin-dismissible.js +79 -0
- package/dist/mixins/mixin-field/mixin-field.d.ts +145 -0
- package/dist/mixins/mixin-field/mixin-field.js +423 -0
- package/dist/utils/anchor-element/anchor-element.d.ts +23 -0
- package/dist/utils/anchor-element/anchor-element.js +39 -0
- package/dist/utils/capture-pointer/capture-pointer.d.ts +22 -0
- package/dist/utils/capture-pointer/capture-pointer.js +30 -0
- package/dist/utils/closest-instance/closest-instance.d.ts +25 -0
- package/dist/utils/closest-instance/closest-instance.js +34 -0
- package/dist/utils/create-sheet/create-sheet.d.ts +16 -0
- package/dist/utils/create-sheet/create-sheet.js +16 -0
- package/dist/utils/custom-element/custom-element.d.ts +26 -0
- package/dist/utils/custom-element/custom-element.js +114 -0
- package/dist/utils/draw-icon/draw-icon.d.ts +38 -0
- package/dist/utils/draw-icon/draw-icon.js +39 -0
- package/dist/utils/draw-tooltip/draw-tooltip.d.ts +56 -0
- package/dist/utils/draw-tooltip/draw-tooltip.js +89 -0
- package/dist/utils/elapsed-time/elapsed-time.d.ts +10 -0
- package/dist/utils/elapsed-time/elapsed-time.js +117 -0
- package/dist/utils/focusable-elements/focusable-elements.d.ts +40 -0
- package/dist/utils/focusable-elements/focusable-elements.js +88 -0
- package/dist/utils/format-numbers/format-numbers.d.ts +1 -0
- package/dist/utils/format-numbers/format-numbers.js +29 -0
- package/dist/utils/host-aria/host-aria.d.ts +38 -0
- package/dist/utils/host-aria/host-aria.js +41 -0
- package/dist/utils/inherit-disabled/inherit-disabled.d.ts +33 -0
- package/dist/utils/inherit-disabled/inherit-disabled.js +55 -0
- package/dist/utils/line-path/line-path.d.ts +23 -0
- package/dist/utils/line-path/line-path.js +107 -0
- package/dist/utils/on-unless-false/on-unless-false.d.ts +28 -0
- package/dist/utils/on-unless-false/on-unless-false.js +32 -0
- package/dist/utils/read-series/read-series.d.ts +78 -0
- package/dist/utils/read-series/read-series.js +187 -0
- package/dist/utils/relative-time/relative-time.d.ts +24 -0
- package/dist/utils/relative-time/relative-time.js +84 -0
- package/dist/utils/request-change/request-change.d.ts +48 -0
- package/dist/utils/request-change/request-change.js +7 -0
- package/dist/utils/resolve-active-item/resolve-active-item.d.ts +28 -0
- package/dist/utils/resolve-active-item/resolve-active-item.js +21 -0
- package/dist/utils/restore-value/restore-value.d.ts +32 -0
- package/dist/utils/restore-value/restore-value.js +84 -0
- package/dist/utils/scale/scale.d.ts +23 -0
- package/dist/utils/scale/scale.js +32 -0
- package/dist/utils/scale/ticks.d.ts +27 -0
- package/dist/utils/scale/ticks.js +52 -0
- package/dist/utils/scroll-item-into-view/scroll-item-into-view.d.ts +10 -0
- package/dist/utils/scroll-item-into-view/scroll-item-into-view.js +98 -0
- package/dist/utils/slot-filled/slot-filled.d.ts +26 -0
- package/dist/utils/slot-filled/slot-filled.js +26 -0
- package/dist/utils/tell-disclosure/tell-disclosure.d.ts +51 -0
- package/dist/utils/tell-disclosure/tell-disclosure.js +76 -0
- package/dist/utils/ticker/ticker.d.ts +8 -0
- package/dist/utils/ticker/ticker.js +55 -0
- package/dist/utils/tone-marks/tone-marks.d.ts +52 -0
- package/dist/utils/tone-marks/tone-marks.js +36 -0
- package/dist/utils/value-as/value-as.d.ts +64 -0
- package/dist/utils/value-as/value-as.js +105 -0
- 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;
|