@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.
- package/custom-elements.json +4 -4
- package/dist/controllers/aria-forward-controller/aria-forward-controller.js +0 -105
- package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +0 -71
- package/dist/controllers/command-controller/command-controller.js +0 -62
- package/dist/controllers/copy-controller/copy-controller.js +0 -63
- package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.js +0 -40
- package/dist/controllers/dismiss-controller/dismiss-controller.js +0 -124
- package/dist/controllers/drilldown-controller/drilldown-controller.js +0 -87
- package/dist/controllers/floating-controller/floating-controller.js +0 -152
- package/dist/controllers/focus-trap-controller/focus-trap-controller.js +0 -35
- package/dist/controllers/indicator-controller/indicator-controller.js +0 -82
- package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.js +0 -46
- package/dist/controllers/popover-controller/popover-controller.js +0 -72
- package/dist/controllers/source-controller/source-controller.js +0 -38
- package/dist/controllers/token-color-controller/token-color-controller.js +0 -51
- package/dist/controllers/virtualize-controller/virtualize-controller.js +0 -128
- package/dist/events/element-events.js +0 -22
- package/dist/events/events.js +0 -386
- package/dist/helix-element/helix-element.d.ts +10 -17
- package/dist/helix-element/helix-element.js +0 -505
- package/dist/helix-form-element/helix-form-element.js +0 -140
- package/dist/mixins/mixin-chart-source/mixin-chart-source.js +0 -77
- package/dist/mixins/mixin-collection/mixin-collection.js +0 -83
- package/dist/mixins/mixin-controller-active/mixin-controller-active.js +0 -82
- package/dist/mixins/mixin-controller-checked/mixin-controller-checked.js +0 -78
- package/dist/mixins/mixin-controller-open/mixin-controller-open.js +0 -93
- package/dist/mixins/mixin-controller-value/mixin-controller-value.js +0 -87
- package/dist/mixins/mixin-dismissible/mixin-dismissible.js +0 -38
- package/dist/mixins/mixin-field/mixin-field.js +0 -277
- package/dist/utils/anchor-element/anchor-element.js +0 -23
- package/dist/utils/capture-pointer/capture-pointer.js +0 -18
- package/dist/utils/closest-instance/closest-instance.js +0 -20
- package/dist/utils/create-sheet/create-sheet.js +0 -14
- package/dist/utils/custom-element/custom-element.js +0 -83
- package/dist/utils/draw-icon/draw-icon.js +0 -28
- package/dist/utils/draw-tooltip/draw-tooltip.js +1 -50
- package/dist/utils/elapsed-time/elapsed-time.js +0 -51
- package/dist/utils/focusable-elements/focusable-elements.js +1 -59
- package/dist/utils/format-numbers/format-numbers.js +0 -16
- package/dist/utils/host-aria/host-aria.js +0 -26
- package/dist/utils/inherit-disabled/inherit-disabled.js +0 -36
- package/dist/utils/line-path/line-path.js +0 -36
- package/dist/utils/on-unless-false/on-unless-false.js +0 -23
- package/dist/utils/read-series/read-series.js +0 -71
- package/dist/utils/relative-time/relative-time.js +0 -43
- package/dist/utils/resolve-active-item/resolve-active-item.js +0 -20
- package/dist/utils/restore-value/restore-value.js +0 -54
- package/dist/utils/scale/scale.js +0 -19
- package/dist/utils/scale/ticks.js +0 -36
- package/dist/utils/scroll-item-into-view/scroll-item-into-view.js +0 -53
- package/dist/utils/slot-filled/slot-filled.js +0 -23
- package/dist/utils/tell-disclosure/tell-disclosure.js +0 -53
- package/dist/utils/ticker/ticker.js +0 -22
- package/dist/utils/tone-marks/tone-marks.js +0 -24
- package/dist/utils/value-as/value-as.js +0 -56
- package/package.json +5 -5
|
@@ -1,56 +1,7 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Puts a value back into a text control without moving the reader's caret.
|
|
3
|
-
*
|
|
4
|
-
* A controlled input has to restore what the element holds whenever the native
|
|
5
|
-
* control has moved and the answer has not followed, which is a refusal or a
|
|
6
|
-
* consumer who owns the value and has not answered yet. Assigning `value` is
|
|
7
|
-
* how that is done, and per spec assigning a *different* string moves the text
|
|
8
|
-
* entry cursor to the end. Measured: caret at index three in `abcdef`, assign
|
|
9
|
-
* `abcXdef`, caret at seven. Assigning the same string leaves it alone, which
|
|
10
|
-
* is why this only shows against a consumer that is slow to answer.
|
|
11
|
-
*
|
|
12
|
-
* The offset is adjusted by the length difference rather than clamped, because
|
|
13
|
-
* the common case is exactly one character wide: the reader typed, the element
|
|
14
|
-
* restored the shorter previous string, and the caret belongs one place back.
|
|
15
|
-
* Clamping alone would leave it one to the right of where the reader was, which
|
|
16
|
-
* is the same bug at a smaller size.
|
|
17
|
-
*
|
|
18
|
-
* Nothing is restored when the value already matches, since the assignment
|
|
19
|
-
* itself is what moves the caret and there is nothing to assign.
|
|
20
|
-
*/
|
|
21
|
-
/**
|
|
22
|
-
* Holds the caret across a write the element does not make itself.
|
|
23
|
-
*
|
|
24
|
-
* **Lit's `.value` binding moves the caret to the end, and only a controlled
|
|
25
|
-
* field notices.** Measured in Chrome with real typing: place the caret after
|
|
26
|
-
* `Ada` in `Ada Lovelace` and press one key, and an uncontrolled field reports
|
|
27
|
-
* `AdaX Lovelace` with the caret at 4, while a controlled one reports the same
|
|
28
|
-
* text with the caret at 13. Editing the middle of a controlled field was
|
|
29
|
-
* impossible: every keystroke threw the caret to the end.
|
|
30
|
-
*
|
|
31
|
-
* The asymmetry is the interesting part. Uncontrolled, `handleInput` runs
|
|
32
|
-
* `restoreValue` after the state has moved, so the control already holds the
|
|
33
|
-
* new text and the call returns early; when Lit's binding then assigns the same
|
|
34
|
-
* string, Chrome leaves the selection alone. Controlled, the control has been
|
|
35
|
-
* put *back* to the old text while the consumer decides, so Lit assigns a
|
|
36
|
-
* genuinely different string and the platform resets the selection, as it does
|
|
37
|
-
* for any assignment to `value`.
|
|
38
|
-
*
|
|
39
|
-
* So the selection is taken before the update and put back after it, mapped by
|
|
40
|
-
* however much the text grew or shrank, which is the same arithmetic
|
|
41
|
-
* `restoreValue` does in one step. Returned as a closure rather than a pair of
|
|
42
|
-
* fields so the two halves cannot be called out of order.
|
|
43
|
-
*
|
|
44
|
-
* The binding itself has to stay: it is what Lit SSR serializes, and a field
|
|
45
|
-
* that filled in at hydration would flash empty for every reader on a slow
|
|
46
|
-
* connection.
|
|
47
|
-
*/
|
|
48
1
|
export const holdCaret = (control) => {
|
|
49
2
|
const { selectionDirection, selectionEnd, selectionStart } = control;
|
|
50
3
|
const held = control.value.length;
|
|
51
4
|
return () => {
|
|
52
|
-
/* Null on a control with no selection, `<input type="range">` among them,
|
|
53
|
-
where `setSelectionRange` throws rather than declining. */
|
|
54
5
|
if (selectionStart === null || selectionEnd === null) {
|
|
55
6
|
return;
|
|
56
7
|
}
|
|
@@ -66,11 +17,6 @@ export const restoreValue = (control, next) => {
|
|
|
66
17
|
const { selectionDirection, selectionEnd, selectionStart } = control;
|
|
67
18
|
const delta = next.length - control.value.length;
|
|
68
19
|
control.value = next;
|
|
69
|
-
/* A control with no selection is done here, and this is a guard rather than a
|
|
70
|
-
tidiness: `<input type="range">` reports `selectionStart` as null and
|
|
71
|
-
*throws* `InvalidStateError` from `setSelectionRange`. Measured. The slider
|
|
72
|
-
restores through this same util, which is what makes the check load-bearing
|
|
73
|
-
rather than defensive. */
|
|
74
20
|
if (selectionStart === null || selectionEnd === null) {
|
|
75
21
|
return;
|
|
76
22
|
}
|
|
@@ -1,26 +1,7 @@
|
|
|
1
|
-
/** The smallest and largest of a set of numbers, as a scale's domain. */
|
|
2
1
|
export const extent = (values) => [
|
|
3
2
|
Math.min(...values),
|
|
4
3
|
Math.max(...values),
|
|
5
4
|
];
|
|
6
|
-
/**
|
|
7
|
-
* Maps a domain onto a range, linearly.
|
|
8
|
-
*
|
|
9
|
-
* A pure function rather than a controller, which is the distinction the rules
|
|
10
|
-
* draw: a controller reacts to a host's lifecycle, and this is arithmetic on
|
|
11
|
-
* four numbers. It holds nothing, subscribes to nothing, and can be called from
|
|
12
|
-
* `render` without anything being wired up first.
|
|
13
|
-
*
|
|
14
|
-
* The range is given rather than assumed, so an inverted one is ordinary: SVG
|
|
15
|
-
* counts down from the top, so a chart passes `[height, 0]` and gets a larger
|
|
16
|
-
* value drawn higher without anything here knowing why.
|
|
17
|
-
*
|
|
18
|
-
* **A flat domain maps to the middle of the range, and that is the case worth
|
|
19
|
-
* naming.** Every value being equal makes the domain zero wide, and the obvious
|
|
20
|
-
* arithmetic divides by it: seven identical readings drew a line at `NaN`, which
|
|
21
|
-
* an `<svg>` renders as no line at all rather than as an error. A flat series is
|
|
22
|
-
* a real thing to plot and it should come out as a flat line halfway up.
|
|
23
|
-
*/
|
|
24
5
|
export const linear = (domain, range) => {
|
|
25
6
|
const [from, to] = domain;
|
|
26
7
|
const [start, end] = range;
|
|
@@ -1,34 +1,5 @@
|
|
|
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
1
|
export const ticks = (domain, count = 5) => {
|
|
28
2
|
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
3
|
if (!Number.isFinite(low) || !Number.isFinite(high) || low === high || count < 1) {
|
|
33
4
|
return [];
|
|
34
5
|
}
|
|
@@ -37,14 +8,7 @@ export const ticks = (domain, count = 5) => {
|
|
|
37
8
|
const eased = rough / magnitude;
|
|
38
9
|
const step = magnitude * (eased >= 7.5 ? 10 : eased >= 3 ? 5 : eased >= 1.5 ? 2 : 1);
|
|
39
10
|
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
11
|
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
12
|
const places = Math.max(0, -Math.floor(Math.log10(step)));
|
|
49
13
|
found.push(Number(at.toFixed(places)));
|
|
50
14
|
}
|
|
@@ -1,53 +1,8 @@
|
|
|
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
|
-
* **Not `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 scrolls the thing in between, which
|
|
31
|
-
* does not scroll, and reports nothing.
|
|
32
|
-
*
|
|
33
|
-
* The walk crosses shadow boundaries through the host, because the item is
|
|
34
|
-
* almost always in one and `parentElement` is null at the root. Without that
|
|
35
|
-
* step, a component whose scroller is its own `[part]` would find nothing.
|
|
36
|
-
*
|
|
37
|
-
* It stops before the document. Scrolling the page is the one thing this whole
|
|
38
|
-
* util exists not to do, so finding nothing inside the component and returning
|
|
39
|
-
* null is the honest answer: the caller no-ops rather than taking the address
|
|
40
|
-
* bar with it.
|
|
41
|
-
*/
|
|
42
1
|
const scrollingAncestor = (item) => {
|
|
43
2
|
const stop = item.ownerDocument.body;
|
|
44
3
|
let node = climb(item);
|
|
45
4
|
while (node && node !== stop) {
|
|
46
5
|
const style = getComputedStyle(node);
|
|
47
|
-
/* Both axes, since the caller may need either, and `overlay` because a
|
|
48
|
-
scroller styled with it scrolls exactly as `auto` does. `clip` and
|
|
49
|
-
`hidden` are deliberately not here: they cut the content off and are not
|
|
50
|
-
something a reader can move. */
|
|
51
6
|
if (/\b(auto|scroll|overlay)\b/.test(`${style.overflowX} ${style.overflowY}`)) {
|
|
52
7
|
return node;
|
|
53
8
|
}
|
|
@@ -55,7 +10,6 @@ const scrollingAncestor = (item) => {
|
|
|
55
10
|
}
|
|
56
11
|
return null;
|
|
57
12
|
};
|
|
58
|
-
/** Up one, through a shadow root where that is what is above. */
|
|
59
13
|
const climb = (node) => {
|
|
60
14
|
if (node.parentElement) {
|
|
61
15
|
return node.parentElement;
|
|
@@ -69,10 +23,6 @@ export const scrollItemIntoView = (item, options = {}) => {
|
|
|
69
23
|
if (!scroller) {
|
|
70
24
|
return;
|
|
71
25
|
}
|
|
72
|
-
/* Measured from the boxes rather than from `offsetLeft`, which is relative to
|
|
73
|
-
the offset parent and is only the scroller when nothing between them is
|
|
74
|
-
positioned. The rectangles are relative to the viewport, so the difference
|
|
75
|
-
between them is the distance regardless of what sits in between. */
|
|
76
26
|
const box = item.getBoundingClientRect();
|
|
77
27
|
const view = scroller.getBoundingClientRect();
|
|
78
28
|
const before = { block: view.top - box.top + padding, inline: view.left - box.left + padding };
|
|
@@ -80,9 +30,6 @@ export const scrollItemIntoView = (item, options = {}) => {
|
|
|
80
30
|
block: box.bottom - view.bottom + padding,
|
|
81
31
|
inline: box.right - view.right + padding,
|
|
82
32
|
};
|
|
83
|
-
/* Whichever edge it is past, and nothing when it is past neither. A scroll of
|
|
84
|
-
zero still fires a `scroll` event and still cancels a smooth one already
|
|
85
|
-
running, so the early return is behavior rather than an optimization. */
|
|
86
33
|
const inline = before.inline > 0 ? -before.inline : after.inline > 0 ? after.inline : 0;
|
|
87
34
|
const block = before.block > 0 ? -before.block : after.block > 0 ? after.block : 0;
|
|
88
35
|
if (inline === 0 && block === 0) {
|
|
@@ -1,24 +1 @@
|
|
|
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
|
-
* pays the row gap twice. `display: none` is the only fix, because only a box
|
|
14
|
-
* that is not generated has no gap beside it.
|
|
15
|
-
*
|
|
16
|
-
* Text or an element, because neither alone is the test. An element on its own
|
|
17
|
-
* counts a whitespace-only span as content; text on its own counts an icon as
|
|
18
|
-
* nothing.
|
|
19
|
-
*
|
|
20
|
-
* `flatten` is deliberate here, unlike the tooltip's emptiness test: this asks
|
|
21
|
-
* "will anything render", where that one asks "did the consumer provide
|
|
22
|
-
* anything", and a slot showing its fallback does render.
|
|
23
|
-
*/
|
|
24
1
|
export const slotFilled = (slot) => (slot?.assignedNodes({ flatten: true }) ?? []).some((node) => node.nodeType === Node.ELEMENT_NODE || node.textContent?.trim() !== '');
|
|
@@ -1,61 +1,8 @@
|
|
|
1
|
-
/** Triggers already told they cannot carry the state, so it is said once. */
|
|
2
1
|
const warned = new WeakSet();
|
|
3
|
-
/**
|
|
4
|
-
* Tells a slotted trigger that it opens something, and whether it is open.
|
|
5
|
-
*
|
|
6
|
-
* **Writing `aria-expanded` onto the consumer's element is a hydration
|
|
7
|
-
* mismatch, so it is not written.** The gap that answer was reaching for is
|
|
8
|
-
* real: a consumer who has to mirror `open` back onto their own trigger is a
|
|
9
|
-
* consumer who will forget, and the failure is silent to everyone who can see
|
|
10
|
-
* the screen. It is still the wrong answer, because a component decorating a
|
|
11
|
-
* consumer's markup after upgrading is one whose output a React consumer's
|
|
12
|
-
* hydration disagrees with.
|
|
13
|
-
*
|
|
14
|
-
* **Measured 28 Aug 2026 against React 19.2.8.** Four
|
|
15
|
-
* families were server-rendered as React emits them and hydrated with the
|
|
16
|
-
* element definitions loaded either side of `hydrateRoot`:
|
|
17
|
-
*
|
|
18
|
-
* ```
|
|
19
|
-
* case writes to trigger hydration mismatch
|
|
20
|
-
* megamenu aria-expanded yes, both load orders
|
|
21
|
-
* speed dial aria-expanded yes, both load orders
|
|
22
|
-
* popover nothing no
|
|
23
|
-
* menu nothing no
|
|
24
|
-
* ```
|
|
25
|
-
*
|
|
26
|
-
* React reports "some attributes of the server rendered HTML didn't match the
|
|
27
|
-
* client properties. This won't be patched up", and the cost is not the console
|
|
28
|
-
* line: it answers a mismatch by discarding the server's HTML for that subtree
|
|
29
|
-
* and rebuilding it, which is what the carousel's own note records blinding an
|
|
30
|
-
* `<helix-aura>` two sections away.
|
|
31
|
-
*
|
|
32
|
-
* **A property crosses the gap that an attribute cannot.** React diffs the
|
|
33
|
-
* attributes it rendered and never looks at a property, so the state reaches
|
|
34
|
-
* the trigger, lands as `aria-expanded` inside *that element's* shadow root,
|
|
35
|
-
* and the consumer's markup is exactly what they served. Both positions get
|
|
36
|
-
* what they were protecting: nothing is written onto the consumer's element,
|
|
37
|
-
* and the reader is still told what the control does.
|
|
38
|
-
*
|
|
39
|
-
* **Set before upgrade is safe**, which is what lets this run without waiting
|
|
40
|
-
* for a definition: `ReactiveElement` captures an instance property assigned
|
|
41
|
-
* before it upgrades and re-applies it, so a trigger whose module has not
|
|
42
|
-
* loaded yet still ends up with the value.
|
|
43
|
-
*
|
|
44
|
-
* **A plain element is warned about rather than written to.** A
|
|
45
|
-
* `<button slot="trigger">` has no shadow root to keep the state out of the
|
|
46
|
-
* markup and no property to take it, and a consumer cannot mirror it themselves
|
|
47
|
-
* because the open state belongs to the component. Saying so once is the same
|
|
48
|
-
* answer `Tooltip` gives about the link it will not write for them.
|
|
49
|
-
*/
|
|
50
2
|
export const tellDisclosure = (trigger, open) => {
|
|
51
3
|
if (!trigger) {
|
|
52
4
|
return;
|
|
53
5
|
}
|
|
54
|
-
/* A custom element, which is the only kind that can hold this without putting
|
|
55
|
-
it in the markup. The tag is the test rather than `'expanded' in trigger`,
|
|
56
|
-
because the trigger may not have upgraded yet and the property lives on the
|
|
57
|
-
prototype: asking too early would fall through to the warning for an
|
|
58
|
-
element that is about to be perfectly capable. */
|
|
59
6
|
if (trigger.localName.includes('-')) {
|
|
60
7
|
;
|
|
61
8
|
trigger.expanded = open;
|
|
@@ -15,16 +15,6 @@ const ticker = () => {
|
|
|
15
15
|
}
|
|
16
16
|
return found;
|
|
17
17
|
};
|
|
18
|
-
/**
|
|
19
|
-
* Replaces the ticker with an empty one on the given clock, and hands back the
|
|
20
|
-
* way to put the real one back.
|
|
21
|
-
*
|
|
22
|
-
* **On `globalThis` with the rest of it, deliberately.** The spec bundler emits
|
|
23
|
-
* one bundle per family and the runner loads them all into one page, so a
|
|
24
|
-
* module-level clock would have the reach of one bundle while the timer it
|
|
25
|
-
* schedules has the reach of the document. That is the fault the arrangement
|
|
26
|
-
* above exists to avoid, and this must not reintroduce it.
|
|
27
|
-
*/
|
|
28
18
|
export const withClock = (clock) => {
|
|
29
19
|
const global = globalThis;
|
|
30
20
|
const before = global[KEY];
|
|
@@ -33,7 +23,6 @@ export const withClock = (clock) => {
|
|
|
33
23
|
global[KEY] = before;
|
|
34
24
|
};
|
|
35
25
|
};
|
|
36
|
-
/** Sets the one timeout, for the soonest thing anybody is waiting on. */
|
|
37
26
|
const schedule = () => {
|
|
38
27
|
const held = ticker();
|
|
39
28
|
if (held.timer !== undefined) {
|
|
@@ -44,14 +33,10 @@ const schedule = () => {
|
|
|
44
33
|
return;
|
|
45
34
|
}
|
|
46
35
|
const soonest = Math.min(...[...held.waiting].map((one) => one.at));
|
|
47
|
-
/* Floored at a second: a subscriber handed a zero would be re-armed inside the
|
|
48
|
-
callback that fired it, which is a busy loop wearing a timer's clothes. */
|
|
49
36
|
const wait = Math.max(1000, soonest - held.clock.now());
|
|
50
37
|
held.timer = held.clock.setTimer(() => {
|
|
51
38
|
held.timer = undefined;
|
|
52
39
|
const now = held.clock.now();
|
|
53
|
-
/* Copied before iterating, because every callback re-subscribes and would
|
|
54
|
-
otherwise be walked again inside the same pass. */
|
|
55
40
|
for (const one of [...held.waiting]) {
|
|
56
41
|
if (one.at <= now) {
|
|
57
42
|
held.waiting.delete(one);
|
|
@@ -61,13 +46,6 @@ const schedule = () => {
|
|
|
61
46
|
schedule();
|
|
62
47
|
}, wait);
|
|
63
48
|
};
|
|
64
|
-
/**
|
|
65
|
-
* Asks to be run in `delay` milliseconds, and returns the way to stop asking.
|
|
66
|
-
*
|
|
67
|
-
* The returned function is the whole of the contract: an element that forgets to
|
|
68
|
-
* call it on disconnect leaks a closure over itself for the life of the page,
|
|
69
|
-
* and a panel that opens and closes leaks one per open.
|
|
70
|
-
*/
|
|
71
49
|
export const subscribe = (delay, run) => {
|
|
72
50
|
const held = ticker();
|
|
73
51
|
const entry = { at: held.clock.now() + delay, run };
|
|
@@ -1,32 +1,8 @@
|
|
|
1
1
|
import { checkCircleFill, infoFill, warningFill, xCircleFill, } from '@fusion.dev/helix-icons';
|
|
2
|
-
/**
|
|
3
|
-
* The one status vocabulary.
|
|
4
|
-
*
|
|
5
|
-
* A reader meets these marks in a passage and in a message and has to read them
|
|
6
|
-
* as the same four things, so there is one place they come from.
|
|
7
|
-
*
|
|
8
|
-
* **Filled at every tone**, or a set meant to be read as one vocabulary has a
|
|
9
|
-
* member drawn a different way. Filled because a tone mark is a status
|
|
10
|
-
* indicator and not an action: it wants to read at a glance and at a small
|
|
11
|
-
* size, where an outline at this weight loses its interior.
|
|
12
|
-
*
|
|
13
|
-
* **The label is not the tone name.** `danger` is announced as "Error",
|
|
14
|
-
* because that is the word a reader understands and `danger` is the word the API
|
|
15
|
-
* uses. Both matter and they are not the same string.
|
|
16
|
-
*/
|
|
17
2
|
export const TONE_MARKS = {
|
|
18
3
|
danger: { icon: xCircleFill, label: 'Error' },
|
|
19
4
|
info: { icon: infoFill, label: 'Information' },
|
|
20
5
|
success: { icon: checkCircleFill, label: 'Success' },
|
|
21
6
|
warning: { icon: warningFill, label: 'Warning' },
|
|
22
7
|
};
|
|
23
|
-
/**
|
|
24
|
-
* The mark for a tone, or `undefined` where the name is not one of the four.
|
|
25
|
-
*
|
|
26
|
-
* A lookup rather than direct indexing, because `tone` reaches a component as an
|
|
27
|
-
* attribute and an attribute can say anything, including the three tones that
|
|
28
|
-
* have no mark. Callers decide what an unknown one means: `Callout` falls back
|
|
29
|
-
* to `info`, since a callout is always *about* something, and `Toast.Item` draws
|
|
30
|
-
* nothing, since a message is just the next thing that happened.
|
|
31
|
-
*/
|
|
32
8
|
export const toneMark = (tone) => tone ? TONE_MARKS[tone] : undefined;
|
|
@@ -1,39 +1,6 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* A date or time control's string value, read as the platform reads it.
|
|
3
|
-
*
|
|
4
|
-
* Shared, because six families hold three formats between them and separate
|
|
5
|
-
* implementations of one conversion are chances to disagree about what an
|
|
6
|
-
* invalid value returns. A pure function of a string, so it sits here beside
|
|
7
|
-
* `formatNumbers` and `relativeTime` rather than under `helix/headless`.
|
|
8
|
-
*
|
|
9
|
-
* **UTC throughout, which is the platform's own choice and looks wrong until it
|
|
10
|
-
* is explained.** `<input type="date">.valueAsDate` returns midnight UTC rather
|
|
11
|
-
* than midnight local, so a date that is the same date everywhere does not
|
|
12
|
-
* become a different one by crossing a timezone. Constructing through
|
|
13
|
-
* `new Date('2026-08-26')` happens to do this and `new Date(2026, 7, 26)` does
|
|
14
|
-
* not, and the difference is a whole day for anybody west of Greenwich: the
|
|
15
|
-
* local constructor gives a `Date` whose UTC date is the 25th. `Date.UTC` says
|
|
16
|
-
* which one is meant rather than relying on a parse rule.
|
|
17
|
-
*
|
|
18
|
-
* **Invalid is `null` and `NaN`, never a thrown error and never a guess.** That
|
|
19
|
-
* is what the platform answers for a half-typed date, which is most of the
|
|
20
|
-
* keystrokes a reader makes in one: a control that threw on the way to a valid
|
|
21
|
-
* value would be unusable, and one that guessed would submit a date nobody
|
|
22
|
-
* chose.
|
|
23
|
-
*/
|
|
24
|
-
/** `YYYY-MM-DD`, and nothing that merely starts like it. */
|
|
25
1
|
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
26
|
-
/** `HH:mm`, optionally with seconds, which is what a step under a minute produces. */
|
|
27
2
|
const ISO_TIME = /^(\d{2}):(\d{2})(?::(\d{2}(?:\.\d{1,3})?))?$/;
|
|
28
|
-
/** Milliseconds in one day, for a time value, which is an offset rather than an instant. */
|
|
29
3
|
const DAY = 86_400_000;
|
|
30
|
-
/**
|
|
31
|
-
* The date a `YYYY-MM-DD` value names, at midnight UTC, or `null`.
|
|
32
|
-
*
|
|
33
|
-
* The range check is not decoration. `2026-02-31` matches the pattern and
|
|
34
|
-
* `Date.UTC` rolls it forward to 3 March, so without reading the parts back a
|
|
35
|
-
* control would report a date the reader never typed and never saw.
|
|
36
|
-
*/
|
|
37
4
|
export const valueAsDate = (value) => {
|
|
38
5
|
const parts = ISO_DATE.exec(value);
|
|
39
6
|
if (!parts) {
|
|
@@ -44,20 +11,12 @@ export const valueAsDate = (value) => {
|
|
|
44
11
|
const rolled = date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day;
|
|
45
12
|
return rolled ? null : date;
|
|
46
13
|
};
|
|
47
|
-
/** The `YYYY-MM-DD` value for a date, or the empty string, which is how a control says nothing. */
|
|
48
14
|
export const dateAsValue = (date) => {
|
|
49
15
|
if (!date || Number.isNaN(date.getTime())) {
|
|
50
16
|
return '';
|
|
51
17
|
}
|
|
52
18
|
return date.toISOString().slice(0, 10);
|
|
53
19
|
};
|
|
54
|
-
/**
|
|
55
|
-
* The time an `HH:mm` value names, on 1 January 1970 UTC, or `null`.
|
|
56
|
-
*
|
|
57
|
-
* The platform's own answer for `<input type="time">`, and the epoch day is
|
|
58
|
-
* what makes `valueAsNumber` come out as milliseconds since midnight rather
|
|
59
|
-
* than as an instant nobody meant.
|
|
60
|
-
*/
|
|
61
20
|
export const valueAsTime = (value) => {
|
|
62
21
|
const parts = ISO_TIME.exec(value);
|
|
63
22
|
if (!parts) {
|
|
@@ -70,13 +29,6 @@ export const valueAsTime = (value) => {
|
|
|
70
29
|
}
|
|
71
30
|
return new Date(Date.UTC(1970, 0, 1, hours, minutes, Math.floor(seconds), (seconds % 1) * 1000));
|
|
72
31
|
};
|
|
73
|
-
/**
|
|
74
|
-
* The `HH:mm` value for a time, or the empty string.
|
|
75
|
-
*
|
|
76
|
-
* Seconds are written only where there are any, because `HH:mm` is what this
|
|
77
|
-
* library's time controls hold and a trailing `:00` on every value would be a
|
|
78
|
-
* format change dressed as a conversion.
|
|
79
|
-
*/
|
|
80
32
|
export const timeAsValue = (date) => {
|
|
81
33
|
if (!date || Number.isNaN(date.getTime())) {
|
|
82
34
|
return '';
|
|
@@ -88,13 +40,5 @@ export const timeAsValue = (date) => {
|
|
|
88
40
|
const hm = `${pad(clock.getUTCHours())}:${pad(clock.getUTCMinutes())}`;
|
|
89
41
|
return seconds === 0 ? hm : `${hm}:${pad(clock.getUTCSeconds())}`;
|
|
90
42
|
};
|
|
91
|
-
/**
|
|
92
|
-
* The number a value holds, or `NaN`.
|
|
93
|
-
*
|
|
94
|
-
* `Number('')` is zero, which is the trap: an empty control would report a
|
|
95
|
-
* number it does not hold and a form would submit it. The platform answers
|
|
96
|
-
* `NaN` for an empty `<input type="number">` and so does this.
|
|
97
|
-
*/
|
|
98
43
|
export const valueAsNumber = (value) => value.trim() === '' ? Number.NaN : Number(value);
|
|
99
|
-
/** The value for a number, or the empty string, which is how a control says nothing. */
|
|
100
44
|
export const numberAsValue = (count) => (Number.isNaN(count) ? '' : String(count));
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fusion.dev/helix-core",
|
|
3
3
|
"description": "Helix element primitives: base classes, mixins, decorators, utilities.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ross Sheppard <ross.sheppard@fusion.dev>",
|
|
7
7
|
"repository": {
|
|
@@ -26,19 +26,19 @@
|
|
|
26
26
|
"sideEffects": false,
|
|
27
27
|
"scripts": {
|
|
28
28
|
"analyze": "cem analyze --config custom-elements-manifest.config.ts",
|
|
29
|
-
"build": "tsc -p tsconfig.json",
|
|
29
|
+
"build": "tsc -p tsconfig.json && tsc -p tsconfig.types.json",
|
|
30
30
|
"test": "bun test src",
|
|
31
31
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
32
32
|
},
|
|
33
33
|
"bugs": "https://github.com/project-fusion/fusion/issues",
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@floating-ui/dom": "^1.8.0",
|
|
36
|
-
"@fusion.dev/helix-icons": "0.
|
|
36
|
+
"@fusion.dev/helix-icons": "0.4.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@custom-elements-manifest/analyzer": "^0.11.0",
|
|
40
|
-
"@fusion.dev/helix-cem": "0.
|
|
41
|
-
"@fusion.dev/helix-testing": "0.
|
|
40
|
+
"@fusion.dev/helix-cem": "0.4.0",
|
|
41
|
+
"@fusion.dev/helix-testing": "0.4.0",
|
|
42
42
|
"lit": "^3.2.1"
|
|
43
43
|
},
|
|
44
44
|
"homepage": "https://fusion.dev",
|