@fusion.dev/helix-core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/custom-elements.json +4 -4
  2. package/dist/controllers/aria-forward-controller/aria-forward-controller.js +0 -105
  3. package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +0 -71
  4. package/dist/controllers/command-controller/command-controller.js +0 -62
  5. package/dist/controllers/copy-controller/copy-controller.js +0 -63
  6. package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.js +0 -40
  7. package/dist/controllers/dismiss-controller/dismiss-controller.js +0 -124
  8. package/dist/controllers/drilldown-controller/drilldown-controller.js +0 -87
  9. package/dist/controllers/floating-controller/floating-controller.js +0 -152
  10. package/dist/controllers/focus-trap-controller/focus-trap-controller.js +0 -35
  11. package/dist/controllers/indicator-controller/indicator-controller.js +0 -82
  12. package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.js +0 -46
  13. package/dist/controllers/popover-controller/popover-controller.js +0 -72
  14. package/dist/controllers/source-controller/source-controller.js +0 -38
  15. package/dist/controllers/token-color-controller/token-color-controller.js +0 -51
  16. package/dist/controllers/virtualize-controller/virtualize-controller.js +0 -128
  17. package/dist/events/element-events.js +0 -22
  18. package/dist/events/events.js +0 -386
  19. package/dist/helix-element/helix-element.d.ts +10 -17
  20. package/dist/helix-element/helix-element.js +0 -505
  21. package/dist/helix-form-element/helix-form-element.js +0 -140
  22. package/dist/mixins/mixin-chart-source/mixin-chart-source.js +0 -77
  23. package/dist/mixins/mixin-collection/mixin-collection.js +0 -83
  24. package/dist/mixins/mixin-controller-active/mixin-controller-active.js +0 -82
  25. package/dist/mixins/mixin-controller-checked/mixin-controller-checked.js +0 -78
  26. package/dist/mixins/mixin-controller-open/mixin-controller-open.js +0 -93
  27. package/dist/mixins/mixin-controller-value/mixin-controller-value.js +0 -87
  28. package/dist/mixins/mixin-dismissible/mixin-dismissible.js +0 -38
  29. package/dist/mixins/mixin-field/mixin-field.js +0 -277
  30. package/dist/utils/anchor-element/anchor-element.js +0 -23
  31. package/dist/utils/capture-pointer/capture-pointer.js +0 -18
  32. package/dist/utils/closest-instance/closest-instance.js +0 -20
  33. package/dist/utils/create-sheet/create-sheet.js +0 -14
  34. package/dist/utils/custom-element/custom-element.js +0 -83
  35. package/dist/utils/draw-icon/draw-icon.js +0 -28
  36. package/dist/utils/draw-tooltip/draw-tooltip.js +1 -50
  37. package/dist/utils/elapsed-time/elapsed-time.js +0 -51
  38. package/dist/utils/focusable-elements/focusable-elements.js +1 -59
  39. package/dist/utils/format-numbers/format-numbers.js +0 -16
  40. package/dist/utils/host-aria/host-aria.js +0 -26
  41. package/dist/utils/inherit-disabled/inherit-disabled.js +0 -36
  42. package/dist/utils/line-path/line-path.js +0 -36
  43. package/dist/utils/on-unless-false/on-unless-false.js +0 -23
  44. package/dist/utils/read-series/read-series.js +0 -71
  45. package/dist/utils/relative-time/relative-time.js +0 -43
  46. package/dist/utils/resolve-active-item/resolve-active-item.js +0 -20
  47. package/dist/utils/restore-value/restore-value.js +0 -54
  48. package/dist/utils/scale/scale.js +0 -19
  49. package/dist/utils/scale/ticks.js +0 -36
  50. package/dist/utils/scroll-item-into-view/scroll-item-into-view.js +0 -53
  51. package/dist/utils/slot-filled/slot-filled.js +0 -23
  52. package/dist/utils/tell-disclosure/tell-disclosure.js +0 -53
  53. package/dist/utils/ticker/ticker.js +0 -22
  54. package/dist/utils/tone-marks/tone-marks.js +0 -24
  55. package/dist/utils/value-as/value-as.js +0 -56
  56. package/package.json +5 -5
@@ -1,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.1.0",
4
+ "version": "0.3.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.1.0"
36
+ "@fusion.dev/helix-icons": "0.3.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@custom-elements-manifest/analyzer": "^0.11.0",
40
- "@fusion.dev/helix-cem": "0.1.0",
41
- "@fusion.dev/helix-testing": "0.1.0",
40
+ "@fusion.dev/helix-cem": "0.3.0",
41
+ "@fusion.dev/helix-testing": "0.3.0",
42
42
  "lit": "^3.2.1"
43
43
  },
44
44
  "homepage": "https://fusion.dev",