@fusion.dev/helix-core 0.170.0 → 0.172.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.
@@ -983,12 +983,83 @@
983
983
  "readonly": true,
984
984
  "default": "options"
985
985
  },
986
+ {
987
+ "kind": "field",
988
+ "name": "invokers",
989
+ "privacy": "private",
990
+ "readonly": true,
991
+ "default": "new Set<Element>()",
992
+ "description": "The invokers this host tells whether it is open: each one that sent an\nopening command, unless it carried `aria-expanded` of its own before then."
993
+ },
994
+ {
995
+ "kind": "field",
996
+ "name": "theirs",
997
+ "privacy": "private",
998
+ "readonly": true,
999
+ "default": "new WeakSet<Element>()",
1000
+ "description": "Invokers that carried `aria-expanded` before a command, which stay the consumer's."
1001
+ },
986
1002
  {
987
1003
  "kind": "field",
988
1004
  "name": "handle",
989
1005
  "privacy": "private",
990
1006
  "readonly": true
991
1007
  },
1008
+ {
1009
+ "kind": "method",
1010
+ "name": "note",
1011
+ "privacy": "private",
1012
+ "return": {
1013
+ "type": {
1014
+ "text": "void"
1015
+ }
1016
+ },
1017
+ "parameters": [
1018
+ {
1019
+ "name": "source",
1020
+ "type": {
1021
+ "text": "Element | null"
1022
+ }
1023
+ }
1024
+ ],
1025
+ "description": "Takes the invoker on as one to tell, for a host that opens.\n\n**The platform says it for a native popover and not for this.** Chromium\nexposes a `popovertarget` button, or one with `command=\"toggle-popover\"`,\nas expanded or collapsed, and a custom command reaches a custom element\nwith nothing said. Measured before this: a `command=\"--toggle\"` button\nopened a Popover and its node had no expanded state. React Aria's trigger\nsays `aria-expanded` on every overlay it opens.\n\nFrom the first command on, so nothing is written onto markup a server\nsent until a reader presses. A plain invoker is also pointed at the host\nthrough `ariaControlsElements`, which writes no attribute."
1026
+ },
1027
+ {
1028
+ "kind": "method",
1029
+ "name": "tell",
1030
+ "privacy": "private",
1031
+ "return": {
1032
+ "type": {
1033
+ "text": "void"
1034
+ }
1035
+ },
1036
+ "description": "Writes the host's state on each invoker it tells, forgetting one that has left."
1037
+ },
1038
+ {
1039
+ "kind": "method",
1040
+ "name": "write",
1041
+ "privacy": "private",
1042
+ "return": {
1043
+ "type": {
1044
+ "text": "void"
1045
+ }
1046
+ },
1047
+ "parameters": [
1048
+ {
1049
+ "name": "invoker",
1050
+ "type": {
1051
+ "text": "Element"
1052
+ }
1053
+ },
1054
+ {
1055
+ "name": "open",
1056
+ "type": {
1057
+ "text": "boolean"
1058
+ }
1059
+ }
1060
+ ],
1061
+ "description": "Says `open` on one invoker: a Helix control through its `expanded`\nproperty, which its own button carries and which puts nothing in the\nmarkup, and a plain element through `aria-expanded`."
1062
+ },
992
1063
  {
993
1064
  "kind": "method",
994
1065
  "name": "hostConnected",
@@ -998,6 +1069,15 @@
998
1069
  }
999
1070
  }
1000
1071
  },
1072
+ {
1073
+ "kind": "method",
1074
+ "name": "hostUpdated",
1075
+ "return": {
1076
+ "type": {
1077
+ "text": "void"
1078
+ }
1079
+ }
1080
+ },
1001
1081
  {
1002
1082
  "kind": "method",
1003
1083
  "name": "hostDisconnected",
@@ -3923,7 +4003,7 @@
3923
4003
  },
3924
4004
  "privacy": "private",
3925
4005
  "readonly": true,
3926
- "default": "new FloatingController(host, { anchor: options.anchor, ...(options.arrow === undefined ? {} : { arrow: options.arrow }), floating: options.surface, ...(options.offset === undefined ? {} : { offset: options.offset }), ...(options.onPlace === undefined ? {} : { onPlace: options.onPlace }), ...(options.placement === undefined ? {} : { placement: options.placement }), ...(options.within === undefined ? {} : { within: options.within }), })"
4006
+ "default": "new FloatingController(host, { anchor: options.anchor, ...(options.arrow === undefined ? {} : { arrow: options.arrow }), floating: options.surface, ...(options.offset === undefined ? {} : { offset: options.offset }), ...(options.crossOffset === undefined ? {} : { crossOffset: options.crossOffset }), ...(options.flip === undefined ? {} : { flip: options.flip }), ...(options.padding === undefined ? {} : { padding: options.padding }), ...(options.onPlace === undefined ? {} : { onPlace: options.onPlace }), ...(options.placement === undefined ? {} : { placement: options.placement }), ...(options.within === undefined ? {} : { within: options.within }), })"
3927
4007
  },
3928
4008
  {
3929
4009
  "kind": "field",
@@ -21133,7 +21213,7 @@
21133
21213
  "text": "TemplateResult | typeof nothing"
21134
21214
  }
21135
21215
  },
21136
- "description": "The `feedback` line, or nothing while there is none."
21216
+ "description": "The `feedback` line, or nothing while there is none.\n\n`role=\"alert\"`, as a field's feedback line has, so the message is\nannounced when it appears and the label and hint are not read with it."
21137
21217
  }
21138
21218
  ],
21139
21219
  "attributes": [
@@ -30178,11 +30258,41 @@
30178
30258
  "kind": "variable",
30179
30259
  "name": "UNITS",
30180
30260
  "type": {
30181
- "text": "Array<{ ms: number; unit: Intl.RelativeTimeFormatUnit }>"
30261
+ "text": "Unit[]"
30182
30262
  },
30183
30263
  "default": "[ { ms: 1000, unit: 'second' }, { ms: 60_000, unit: 'minute' }, { ms: 3_600_000, unit: 'hour' }, { ms: 86_400_000, unit: 'day' }, { ms: 604_800_000, unit: 'week' }, { ms: 2_629_800_000, unit: 'month' }, { ms: 31_557_600_000, unit: 'year' }, ]",
30184
30264
  "description": "How long each unit lasts, in milliseconds, largest last. `elapsedTime`\nreads its minute, hour and day from here."
30185
30265
  },
30266
+ {
30267
+ "kind": "function",
30268
+ "name": "roundedCount",
30269
+ "return": {
30270
+ "type": {
30271
+ "text": "{ count: number; index: number; next: number }"
30272
+ }
30273
+ },
30274
+ "parameters": [
30275
+ {
30276
+ "name": "size",
30277
+ "type": {
30278
+ "text": "number"
30279
+ }
30280
+ },
30281
+ {
30282
+ "name": "units",
30283
+ "type": {
30284
+ "text": "readonly U[]"
30285
+ }
30286
+ },
30287
+ {
30288
+ "name": "growing",
30289
+ "type": {
30290
+ "text": "boolean"
30291
+ }
30292
+ }
30293
+ ],
30294
+ "description": "A size of time as a rounded count of one of `units`, and how long that\ncount holds while the size grows (`growing`, a moment gone) or shrinks (a\nmoment to come).\n\n**Rounded, as Shoelace's relative time rounds.** Truncated, a date 48 hours\nahead, measured a few milliseconds later, read \"tomorrow\", and 59 minutes\nread \"59 minutes\" while 61 read \"1 hour\". A count that rounds up to the next\nunit's size reads in that unit: 59.6 minutes is 1 hour, 6.6 days is 1 week.\n\n`units` run smallest first and the size is at least the first of them.\nExported for `elapsedTime`, which reads its minute, hour and day the same way."
30295
+ },
30186
30296
  {
30187
30297
  "kind": "function",
30188
30298
  "name": "relativeTime",
@@ -30218,6 +30328,14 @@
30218
30328
  "module": "src/utils/relative-time/relative-time.ts"
30219
30329
  }
30220
30330
  },
30331
+ {
30332
+ "kind": "js",
30333
+ "name": "roundedCount",
30334
+ "declaration": {
30335
+ "name": "roundedCount",
30336
+ "module": "src/utils/relative-time/relative-time.ts"
30337
+ }
30338
+ },
30221
30339
  {
30222
30340
  "kind": "js",
30223
30341
  "name": "relativeTime",
@@ -33673,7 +33791,7 @@
33673
33791
  "type": {
33674
33792
  "text": "string"
33675
33793
  },
33676
- "default": "'0.170.0'",
33794
+ "default": "'0.172.0'",
33677
33795
  "description": "The version of the package this file ships in.\n\n**Written by `fusion/scripts/version.mjs`** wherever it writes the package's\n`package.json`, and stamped back into the repository beside it after a\nrelease, so the two never disagree; a spec beside this holds them to it.\nNothing else in the running code knows which release it is."
33678
33796
  }
33679
33797
  ],
@@ -34233,6 +34351,6 @@
34233
34351
  ],
34234
34352
  "helix": {
34235
34353
  "package": "@fusion.dev/helix-core",
34236
- "version": "0.170.0"
34354
+ "version": "0.172.0"
34237
34355
  }
34238
34356
  }
@@ -40,6 +40,12 @@ export declare class HelixFallbackCommandEvent extends Event {
40
40
  export interface CommandOptions {
41
41
  /** Each command the host implements, keyed by the name a trigger writes. */
42
42
  commands: Record<string, (source: Element | null) => void>;
43
+ /**
44
+ * Whether the host is open, for a host a command opens and closes. Given,
45
+ * each invoker that has sent `--show` or `--toggle` says so with
46
+ * `aria-expanded`.
47
+ */
48
+ expanded?: () => boolean;
43
49
  }
44
50
  /**
45
51
  * Lets a control elsewhere on the page operate this one, declaratively.
@@ -75,7 +81,38 @@ export declare class CommandController implements ReactiveController {
75
81
  private readonly host;
76
82
  private readonly options;
77
83
  constructor(host: ReactiveControllerHost & HTMLElement, options: CommandOptions);
84
+ /**
85
+ * The invokers this host tells whether it is open: each one that sent an
86
+ * opening command, unless it carried `aria-expanded` of its own before then.
87
+ */
88
+ private readonly invokers;
89
+ /** Invokers that carried `aria-expanded` before a command, which stay the consumer's. */
90
+ private readonly theirs;
78
91
  private readonly handle;
92
+ /**
93
+ * Takes the invoker on as one to tell, for a host that opens.
94
+ *
95
+ * **The platform says it for a native popover and not for this.** Chromium
96
+ * exposes a `popovertarget` button, or one with `command="toggle-popover"`,
97
+ * as expanded or collapsed, and a custom command reaches a custom element
98
+ * with nothing said. Measured before this: a `command="--toggle"` button
99
+ * opened a Popover and its node had no expanded state. React Aria's trigger
100
+ * says `aria-expanded` on every overlay it opens.
101
+ *
102
+ * From the first command on, so nothing is written onto markup a server
103
+ * sent until a reader presses. A plain invoker is also pointed at the host
104
+ * through `ariaControlsElements`, which writes no attribute.
105
+ */
106
+ private note;
107
+ /** Writes the host's state on each invoker it tells, forgetting one that has left. */
108
+ private tell;
109
+ /**
110
+ * Says `open` on one invoker: a Helix control through its `expanded`
111
+ * property, which its own button carries and which puts nothing in the
112
+ * markup, and a plain element through `aria-expanded`.
113
+ */
114
+ private write;
79
115
  hostConnected(): void;
116
+ hostUpdated(): void;
80
117
  hostDisconnected(): void;
81
118
  }
@@ -6,24 +6,78 @@ export class HelixFallbackCommandEvent extends Event {
6
6
  this.source = source;
7
7
  }
8
8
  }
9
+ const OPENING = new Set(['--show', '--toggle']);
9
10
  export class CommandController {
10
11
  constructor(host, options) {
12
+ this.invokers = new Set();
13
+ this.theirs = new WeakSet();
11
14
  this.handle = (event) => {
12
15
  const command = event.command;
13
16
  const run = this.options.commands[command];
14
17
  if (!run) {
15
18
  return;
16
19
  }
17
- run(event.source);
20
+ const source = event.source;
21
+ if (OPENING.has(command)) {
22
+ this.note(source);
23
+ }
24
+ run(source);
18
25
  };
19
26
  this.host = host;
20
27
  this.options = options;
21
28
  host.addController(this);
22
29
  }
30
+ note(source) {
31
+ if (!this.options.expanded || !source || this.invokers.has(source) || this.theirs.has(source)) {
32
+ return;
33
+ }
34
+ if (source.hasAttribute('aria-expanded')) {
35
+ this.theirs.add(source);
36
+ return;
37
+ }
38
+ this.invokers.add(source);
39
+ if (!source.localName.includes('-') && 'ariaControlsElements' in source) {
40
+ ;
41
+ source.ariaControlsElements = [
42
+ this.host,
43
+ ];
44
+ }
45
+ this.tell();
46
+ }
47
+ tell() {
48
+ const expanded = this.options.expanded;
49
+ if (!expanded) {
50
+ return;
51
+ }
52
+ const open = expanded();
53
+ for (const invoker of this.invokers) {
54
+ if (invoker.isConnected) {
55
+ this.write(invoker, open);
56
+ }
57
+ else {
58
+ this.invokers.delete(invoker);
59
+ }
60
+ }
61
+ }
62
+ write(invoker, open) {
63
+ if (invoker.localName.includes('-')) {
64
+ ;
65
+ invoker.expanded = open;
66
+ }
67
+ else if (invoker.getAttribute('aria-expanded') !== String(open)) {
68
+ invoker.setAttribute('aria-expanded', String(open));
69
+ }
70
+ }
23
71
  hostConnected() {
24
72
  this.host.addEventListener('command', this.handle);
25
73
  }
74
+ hostUpdated() {
75
+ this.tell();
76
+ }
26
77
  hostDisconnected() {
27
78
  this.host.removeEventListener('command', this.handle);
79
+ for (const invoker of this.invokers) {
80
+ this.write(invoker, false);
81
+ }
28
82
  }
29
83
  }
@@ -25,6 +25,13 @@ export type Placement = 'bottom' | 'bottom-end' | 'bottom-start' | 'left' | 'lef
25
25
  export interface FloatingOptions {
26
26
  /** The element being pointed at. */
27
27
  anchor: () => HTMLElement | null | undefined;
28
+ /**
29
+ * How far to slide the floating element along the side it is placed on, in
30
+ * pixels: rightward on the top or the bottom, downward on the left or the
31
+ * right, and toward the end of the line where the placement names `-end`.
32
+ * Floating UI's `crossAxis` offset, React Aria's `crossOffset`.
33
+ */
34
+ crossOffset?: number | (() => number);
28
35
  /**
29
36
  * An element to point at the anchor, positioned against whichever side the
30
37
  * floating element landed on.
@@ -32,13 +39,20 @@ export interface FloatingOptions {
32
39
  arrow?: () => HTMLElement | null | undefined;
33
40
  /** The element being placed. */
34
41
  floating: () => HTMLElement | null | undefined;
42
+ /**
43
+ * How close to the window's edges the floating element may come, in
44
+ * pixels, when it slides or is capped to fit, and, where it is given, when
45
+ * it flips. Defaults to 8 for the first two and none for flipping. React
46
+ * Aria's `containerPadding`, Radix's `collisionPadding`.
47
+ */
48
+ padding?: number | (() => number | undefined);
35
49
  /**
36
50
  * Whether to try another side when the preferred one does not fit.
37
51
  *
38
52
  * On by default. A menu that opens off the bottom of the window is one the
39
53
  * reader cannot use.
40
54
  */
41
- flip?: boolean;
55
+ flip?: boolean | (() => boolean);
42
56
  /**
43
57
  * Told the placement the element landed on, after every measurement, and
44
58
  * how wide the window leaves it room to be.
@@ -37,17 +37,24 @@ export class FloatingController {
37
37
  const preferred = typeof this.options.placement === 'function'
38
38
  ? this.options.placement()
39
39
  : this.options.placement;
40
- const middleware = [offset(gap ?? 0)];
40
+ const cross = typeof this.options.crossOffset === 'function'
41
+ ? this.options.crossOffset()
42
+ : (this.options.crossOffset ?? 0);
43
+ const asked = typeof this.options.padding === 'function' ? this.options.padding() : this.options.padding;
44
+ const margin = asked ?? 8;
45
+ const edge = asked === undefined ? {} : { padding: asked };
46
+ const middleware = [offset({ crossAxis: cross, mainAxis: gap ?? 0 })];
41
47
  let room;
42
- if (this.options.flip !== false) {
48
+ const flips = typeof this.options.flip === 'function' ? this.options.flip() : this.options.flip;
49
+ if (flips !== false) {
43
50
  const side = (preferred ?? 'bottom-start').split('-')[0];
44
51
  middleware.push(flip(side === 'left' || side === 'right'
45
- ? { crossAxis: 'alignment', fallbackAxisSideDirection: 'end' }
46
- : {}));
52
+ ? { crossAxis: 'alignment', fallbackAxisSideDirection: 'end', ...edge }
53
+ : edge));
47
54
  }
48
55
  if (this.options.shift !== false) {
49
56
  const inside = this.options.within?.();
50
- middleware.push(shift({ padding: 8, ...(inside ? { boundary: inside } : {}) }));
57
+ middleware.push(shift({ padding: margin, ...(inside ? { boundary: inside } : {}) }));
51
58
  }
52
59
  if (this.options.resize !== false) {
53
60
  middleware.push(size({
@@ -56,7 +63,7 @@ export class FloatingController {
56
63
  elements.floating.style.setProperty('--floating-available-block-size', `${Math.round(availableHeight)}px`);
57
64
  elements.floating.style.setProperty('--floating-anchor-inline-size', `${matchedInlineSize(rects.reference.width, availableWidth)}px`);
58
65
  },
59
- padding: 8,
66
+ padding: margin,
60
67
  }));
61
68
  }
62
69
  if (pointer) {
@@ -21,6 +21,12 @@ export interface PopoverOptions {
21
21
  boundary?: () => Element | readonly (Element | null | undefined)[] | null | undefined;
22
22
  /** Space between the anchor and the surface, in pixels. */
23
23
  offset?: number | (() => number);
24
+ /** Whether the surface may move to the other side to fit. On by default. See `FloatingController`. */
25
+ flip?: boolean | (() => boolean);
26
+ /** How far to slide the surface along its side, in pixels. See `FloatingController`. */
27
+ crossOffset?: number | (() => number);
28
+ /** How close to the window's edges the surface may come, in pixels. See `FloatingController`. */
29
+ padding?: number | (() => number | undefined);
24
30
  /**
25
31
  * A box to keep the surface inside, instead of the viewport.
26
32
  *
@@ -10,6 +10,9 @@ export class PopoverController {
10
10
  ...(options.arrow === undefined ? {} : { arrow: options.arrow }),
11
11
  floating: options.surface,
12
12
  ...(options.offset === undefined ? {} : { offset: options.offset }),
13
+ ...(options.crossOffset === undefined ? {} : { crossOffset: options.crossOffset }),
14
+ ...(options.flip === undefined ? {} : { flip: options.flip }),
15
+ ...(options.padding === undefined ? {} : { padding: options.padding }),
13
16
  ...(options.onPlace === undefined ? {} : { onPlace: options.onPlace }),
14
17
  ...(options.placement === undefined ? {} : { placement: options.placement }),
15
18
  ...(options.within === undefined ? {} : { within: options.within }),
@@ -142,6 +142,10 @@ export const MixinField = (Base) => {
142
142
  this.refreshFeedback();
143
143
  }
144
144
  copyValidity(control) {
145
+ if (!control.willValidate) {
146
+ this.internals.setValidity({});
147
+ return;
148
+ }
145
149
  const message = control.validity.valueMissing && this.valueMissingMessage
146
150
  ? this.valueMissingMessage
147
151
  : control.validationMessage;
@@ -57,7 +57,7 @@ export const MixinPickerFeedback = (Base) => {
57
57
  renderPickerFeedback() {
58
58
  return this.feedback === ''
59
59
  ? nothing
60
- : html `<p part="feedback" id="feedback">${this.feedback}</p>`;
60
+ : html `<p part="feedback" id="feedback" role="alert">${this.feedback}</p>`;
61
61
  }
62
62
  }
63
63
  __decorate([
@@ -1,4 +1,4 @@
1
- import { UNITS as TIME_UNITS } from '../relative-time/relative-time.js';
1
+ import { roundedCount, UNITS as TIME_UNITS } from '../relative-time/relative-time.js';
2
2
  const held = new Map();
3
3
  const dated = new Map();
4
4
  const UNITS = TIME_UNITS.slice(1, 4);
@@ -44,15 +44,9 @@ export const elapsedTime = (locale) => {
44
44
  const format = sameYear ? dates[0] : dates[1];
45
45
  return { next: Number.POSITIVE_INFINITY, text: format.format(from) };
46
46
  }
47
- let index = 0;
48
- for (let at = 0; at < UNITS.length; at += 1) {
49
- if (size >= UNITS[at].ms) {
50
- index = at;
51
- }
52
- }
53
- const chosen = UNITS[index];
54
- const count = Math.trunc(size / chosen.ms);
55
- const remaining = Math.min(chosen.ms - (size % chosen.ms), HORIZON - size);
47
+ const growing = elapsed > 0;
48
+ const { count, index, next } = roundedCount(size, UNITS, growing);
49
+ const remaining = growing ? Math.min(next, HORIZON - size) : next;
56
50
  return {
57
51
  next: Math.max(1000, Math.min(remaining, 3_600_000)),
58
52
  text: short[index].format(count),
@@ -2,10 +2,32 @@
2
2
  * How long each unit lasts, in milliseconds, largest last. `elapsedTime`
3
3
  * reads its minute, hour and day from here.
4
4
  */
5
- export declare const UNITS: Array<{
5
+ export declare const UNITS: Unit[];
6
+ /** One unit of `UNITS`. */
7
+ type Unit = {
6
8
  ms: number;
7
9
  unit: Intl.RelativeTimeFormatUnit;
8
- }>;
10
+ };
11
+ /**
12
+ * A size of time as a rounded count of one of `units`, and how long that
13
+ * count holds while the size grows (`growing`, a moment gone) or shrinks (a
14
+ * moment to come).
15
+ *
16
+ * **Rounded, as Shoelace's relative time rounds.** Truncated, a date 48 hours
17
+ * ahead, measured a few milliseconds later, read "tomorrow", and 59 minutes
18
+ * read "59 minutes" while 61 read "1 hour". A count that rounds up to the next
19
+ * unit's size reads in that unit: 59.6 minutes is 1 hour, 6.6 days is 1 week.
20
+ *
21
+ * `units` run smallest first and the size is at least the first of them.
22
+ * Exported for `elapsedTime`, which reads its minute, hour and day the same way.
23
+ */
24
+ export declare const roundedCount: <U extends {
25
+ ms: number;
26
+ }>(size: number, units: readonly U[], growing: boolean) => {
27
+ count: number;
28
+ index: number;
29
+ next: number;
30
+ };
9
31
  /** What one timestamp reads as, and when that stops being true. */
10
32
  export interface Relative {
11
33
  /**
@@ -42,3 +64,4 @@ export interface RelativeOptions {
42
64
  * says aloud and what every caller wanted before Format Date offered the rest.
43
65
  */
44
66
  export declare const relativeTime: (locale: string, options?: RelativeOptions) => ((from: Date, now: Date) => Relative);
67
+ export {};
@@ -8,6 +8,32 @@ export const UNITS = [
8
8
  { ms: 2_629_800_000, unit: 'month' },
9
9
  { ms: 31_557_600_000, unit: 'year' },
10
10
  ];
11
+ export const roundedCount = (size, units, growing) => {
12
+ let index = 0;
13
+ for (let at = 1; at < units.length; at += 1) {
14
+ if (size >= units[at].ms) {
15
+ index = at;
16
+ }
17
+ }
18
+ const ms = (at) => units[at].ms;
19
+ let count = Math.round(size / ms(index));
20
+ while (index + 1 < units.length && count * ms(index) >= ms(index + 1)) {
21
+ index += 1;
22
+ count = Math.round(size / ms(index));
23
+ }
24
+ if (growing) {
25
+ return { count, index, next: (count + 0.5) * ms(index) - size };
26
+ }
27
+ const below = [(count - 0.5) * ms(index)];
28
+ if (size >= ms(index)) {
29
+ below.push(ms(index));
30
+ }
31
+ if (index > 0) {
32
+ below.push((Math.ceil(ms(index) / ms(index - 1)) - 0.5) * ms(index - 1));
33
+ }
34
+ const floor = Math.max(...below.filter((at) => at <= size));
35
+ return { count, index, next: size - floor + 1 };
36
+ };
11
37
  export const relativeTime = (locale, options = {}) => {
12
38
  const numeric = options.numeric ?? 'auto';
13
39
  const style = options.style ?? 'long';
@@ -30,17 +56,11 @@ export const relativeTime = (locale, options = {}) => {
30
56
  text: found.format(elapsed > 0 ? 0 : -0, 'second'),
31
57
  };
32
58
  }
33
- let chosen = UNITS[1];
34
- for (const unit of UNITS) {
35
- if (size >= unit.ms) {
36
- chosen = unit;
37
- }
38
- }
39
- const count = Math.trunc(elapsed / chosen.ms);
40
- const remaining = chosen.ms - (size % chosen.ms);
59
+ const { count, index, next } = roundedCount(size, UNITS.slice(1), elapsed < 0);
60
+ const chosen = UNITS[index + 1];
41
61
  return {
42
- next: Math.max(1000, Math.min(remaining, 3_600_000)),
43
- text: found.format(count, chosen.unit),
62
+ next: Math.max(1000, Math.min(next, 3_600_000)),
63
+ text: found.format(elapsed < 0 ? -count : count, chosen.unit),
44
64
  };
45
65
  };
46
66
  };
@@ -34,6 +34,8 @@ export type RuntimeKind =
34
34
  | 'misread-direction'
35
35
  /** A React handler prop names an event the element never dispatches itself. Development builds only. */
36
36
  | 'misplaced-handler'
37
+ /** A Chat was given turns both as children and as `turns`, which it draws as two runs. */
38
+ | 'mixed-turns'
37
39
  /** An Image has no `alt`, so a screen reader announces its file name. */
38
40
  | 'missing-alt'
39
41
  /** The three.js driver could not load its layout, so the notes stay where they started. */
package/dist/version.d.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * release, so the two never disagree; a spec beside this holds them to it.
7
7
  * Nothing else in the running code knows which release it is.
8
8
  */
9
- export declare const VERSION = "0.170.0";
9
+ export declare const VERSION = "0.172.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.170.0';
1
+ export const VERSION = '0.172.0';
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.170.0",
4
+ "version": "0.172.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -62,11 +62,11 @@
62
62
  "bugs": "https://github.com/project-fusion/fusion/issues",
63
63
  "dependencies": {
64
64
  "@floating-ui/dom": "^1.8.0",
65
- "@fusion.dev/helix-icons": "0.170.0"
65
+ "@fusion.dev/helix-icons": "0.172.0"
66
66
  },
67
67
  "devDependencies": {
68
- "@fusion.dev/helix-cem": "0.170.0",
69
- "@fusion.dev/helix-testing": "0.170.0",
68
+ "@fusion.dev/helix-cem": "0.172.0",
69
+ "@fusion.dev/helix-testing": "0.172.0",
70
70
  "@lit-labs/ssr": "^4.1.0",
71
71
  "lit": "^3.2.1"
72
72
  },