@fusion.dev/helix-headless 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 (127) hide show
  1. package/custom-elements.json +234 -234
  2. package/dist/accordion/item/accordion-item.js +0 -108
  3. package/dist/accordion/root/accordion-root.js +0 -33
  4. package/dist/announcement/root/announcement-root.js +1 -76
  5. package/dist/app-header/root/app-header-root.js +0 -84
  6. package/dist/avatar/group/avatar-group.js +0 -77
  7. package/dist/avatar/root/avatar-root.js +0 -72
  8. package/dist/badge/root/badge-root.js +0 -67
  9. package/dist/bar-chart/root/bar-chart-root.js +3 -263
  10. package/dist/blockquote/root/blockquote-root.js +0 -46
  11. package/dist/breadcrumbs/breadcrumb/breadcrumb.js +0 -72
  12. package/dist/breadcrumbs/root/breadcrumbs-root.js +0 -52
  13. package/dist/button/root/button-root.js +0 -215
  14. package/dist/button-group/root/button-group-root.js +0 -30
  15. package/dist/callout/root/callout-root.js +0 -52
  16. package/dist/card/root/card-root.js +0 -41
  17. package/dist/carousel/root/carousel-root.d.ts +2 -2
  18. package/dist/carousel/root/carousel-root.js +2 -362
  19. package/dist/chat/composer/chat-composer.js +0 -131
  20. package/dist/chat/message/chat-message.js +0 -137
  21. package/dist/chat/root/chat-root.js +0 -193
  22. package/dist/checkbox/root/checkbox-root.js +0 -113
  23. package/dist/checkbox-group/root/checkbox-group-root.js +1 -157
  24. package/dist/code/root/code-root.js +0 -26
  25. package/dist/code-block/root/code-block-root.js +0 -100
  26. package/dist/color-input/root/color-input-root.js +0 -217
  27. package/dist/color-picker/color.js +0 -69
  28. package/dist/color-picker/root/color-picker-root.js +0 -272
  29. package/dist/combobox/root/combobox-root.js +0 -428
  30. package/dist/command/root/command-root.js +0 -177
  31. package/dist/copy-button/root/copy-button-root.js +0 -134
  32. package/dist/data-table/root/data-table-root.js +0 -336
  33. package/dist/data-table/root/order.js +0 -100
  34. package/dist/date-input/root/date-input-root.js +1 -398
  35. package/dist/date-picker/root/date-picker-root.js +0 -358
  36. package/dist/dialog/root/dialog-root.js +0 -189
  37. package/dist/divider/root/divider-root.js +0 -71
  38. package/dist/donut-chart/root/donut-chart-root.js +0 -214
  39. package/dist/drawer/root/drawer-root.js +0 -116
  40. package/dist/drilldown/item/drilldown-item.js +0 -82
  41. package/dist/drilldown/root/drilldown-root.js +0 -91
  42. package/dist/element-events.generated.js +0 -5
  43. package/dist/empty/root/empty-root.js +0 -35
  44. package/dist/file-input/root/file-input-root.js +0 -232
  45. package/dist/format-date/root/format-date-root.js +0 -95
  46. package/dist/heatmap/root/buckets.js +0 -57
  47. package/dist/heatmap/root/heatmap-root.js +0 -313
  48. package/dist/icon/root/icon-root.js +0 -48
  49. package/dist/icon-button/root/icon-button-root.js +0 -30
  50. package/dist/image/root/image-root.js +0 -174
  51. package/dist/index.js +0 -9
  52. package/dist/input/root/input-root.js +0 -228
  53. package/dist/kbd/root/kbd-root.js +0 -117
  54. package/dist/legend/root/legend-root.js +0 -73
  55. package/dist/line-chart/root/line-chart-root.js +0 -172
  56. package/dist/list/item/list-item.js +0 -92
  57. package/dist/list/root/list-root.js +0 -26
  58. package/dist/listbox/root/listbox-root.js +2 -429
  59. package/dist/loader/root/loader-root.js +0 -40
  60. package/dist/megamenu/root/megamenu-root.js +0 -284
  61. package/dist/menu/item/menu-item.js +1 -221
  62. package/dist/menu/root/menu-root.js +1 -171
  63. package/dist/meter/root/meter-root.js +1 -152
  64. package/dist/navigation/root/navigation-root.js +0 -56
  65. package/dist/notifications/notification/notification.js +0 -159
  66. package/dist/notifications/root/notifications-root.js +0 -148
  67. package/dist/number-input/root/number-input-root.js +1 -263
  68. package/dist/option/group/option-group.js +0 -26
  69. package/dist/option/root/option-root.js +0 -157
  70. package/dist/outline/root/outline-root.js +0 -292
  71. package/dist/pagination/root/pagination-root.js +0 -361
  72. package/dist/pagination/size/pagination-size.js +0 -104
  73. package/dist/pagination/summary/pagination-summary.js +0 -90
  74. package/dist/pin-input/root/pin-input-root.js +0 -276
  75. package/dist/popover/root/popover-root.js +0 -103
  76. package/dist/progress/root/progress-root.js +1 -133
  77. package/dist/qr-code/root/qr-code-root.js +0 -96
  78. package/dist/quote/root/quote-root.js +0 -25
  79. package/dist/radio/group/radio-group.js +0 -81
  80. package/dist/radio/root/radio-root.js +0 -85
  81. package/dist/rating/root/rating-root.js +0 -220
  82. package/dist/scroll-area/root/scroll-area-root.js +0 -100
  83. package/dist/select/root/select-root.js +2 -218
  84. package/dist/skeleton/root/skeleton-root.js +0 -20
  85. package/dist/slider/root/slider-root.js +1 -267
  86. package/dist/sparkline/root/sparkline-root.js +3 -164
  87. package/dist/speed-dial/item/speed-dial-item.js +0 -52
  88. package/dist/speed-dial/root/speed-dial-root.js +1 -116
  89. package/dist/splitter/root/splitter-root.js +0 -489
  90. package/dist/stat/group/stat-group.js +0 -18
  91. package/dist/stat/root/stat-root.js +0 -23
  92. package/dist/steps/root/steps-root.js +0 -62
  93. package/dist/steps/step/step.js +0 -131
  94. package/dist/switch/root/switch-root.js +0 -80
  95. package/dist/table/cell/table-cell.js +0 -23
  96. package/dist/table/column/table-column.js +0 -158
  97. package/dist/table/root/table-root.js +0 -82
  98. package/dist/table/row/table-row.js +0 -21
  99. package/dist/tabs/panel/tab-panel.js +0 -49
  100. package/dist/tabs/root/tabs-root.js +0 -98
  101. package/dist/tabs/tab/tab.js +0 -59
  102. package/dist/tag/root/tag-root.js +0 -31
  103. package/dist/textarea/root/textarea-root.js +0 -162
  104. package/dist/time-input/root/time-input-root.js +0 -283
  105. package/dist/time-picker/root/time-picker-root.js +0 -326
  106. package/dist/timeline/item/timeline-item.js +0 -66
  107. package/dist/timeline/root/timeline-root.js +0 -41
  108. package/dist/toast/item/toast-item.js +0 -165
  109. package/dist/toast/root/toast-root.js +0 -51
  110. package/dist/toggle/group/toggle-group.js +0 -150
  111. package/dist/toggle/root/toggle-root.js +0 -78
  112. package/dist/toolbar/root/toolbar-root.js +0 -116
  113. package/dist/tooltip/root/tooltip-root.js +0 -427
  114. package/dist/tour/root/tour-root.js +0 -130
  115. package/dist/tour/stop/tour-stop.js +1 -127
  116. package/dist/tree/item/tree-item.js +0 -198
  117. package/dist/tree/root/tree-root.js +0 -97
  118. package/dist/trend/root/trend-root.js +0 -53
  119. package/dist/video/root/video-root.d.ts +8 -12
  120. package/dist/video/root/video-root.js +5 -756
  121. package/dist/video-playlist/item/video-playlist-item.js +1 -90
  122. package/dist/video-playlist/menu/video-playlist-menu.js +1 -132
  123. package/dist/video-playlist/root/video-playlist-root.js +0 -113
  124. package/dist/virtual-list/root/virtual-list-root.js +0 -105
  125. package/jsx.d.ts +1408 -2576
  126. package/package.json +6 -6
  127. package/vue.d.ts +1 -1
@@ -10,19 +10,6 @@ import { nothing } from 'lit';
10
10
  import { property, state } from 'lit/decorators.js';
11
11
  import { html } from 'lit/static-html.js';
12
12
  import '../../tag/index.js';
13
- /**
14
- * A file's size, at the largest unit that leaves a number worth reading.
15
- *
16
- * Binary steps, because that is what a file system reports and what the number
17
- * beside it in a finder will say. One decimal place from kilobytes up and none
18
- * for bytes: `1.2 MB` is the reading, `1.23 MB` is noise, and `1.5 bytes` is not
19
- * a thing.
20
- *
21
- * `Intl.NumberFormat` for the number rather than `toFixed`, so a decimal comma
22
- * lands where a decimal comma belongs. The unit is not localized with it: `MB`
23
- * is written the same way in the places this ships, and `Intl`'s own byte units
24
- * carry no scale to pick from.
25
- */
26
13
  const size = (bytes, locale) => {
27
14
  const units = ['bytes', 'KB', 'MB', 'GB', 'TB'];
28
15
  let value = bytes;
@@ -34,19 +21,9 @@ const size = (bytes, locale) => {
34
21
  const written = new Intl.NumberFormat(locale || undefined, {
35
22
  maximumFractionDigits: step === 0 ? 0 : 1,
36
23
  }).format(value);
37
- // One byte is a byte. The plural is only wrong at exactly one and it is the
38
- // size an empty-ish file has, so it is the one a reader sees while testing.
39
24
  const unit = step === 0 && value === 1 ? 'byte' : units[step];
40
25
  return `${written} ${unit}`;
41
26
  };
42
- /**
43
- * What kind of file it is, in the shortest honest form.
44
- *
45
- * The subtype of the MIME type, so `image/png` reads `PNG`, which is the word a
46
- * reader already has for it. A browser leaves `type` empty for anything it does
47
- * not recognize, so the extension is the fallback: it is what the name already
48
- * shows, which makes it a poor label and a better one than nothing.
49
- */
50
27
  const kind = (file) => {
51
28
  const subtype = file.type.split('/')[1];
52
29
  if (subtype) {
@@ -55,131 +32,22 @@ const kind = (file) => {
55
32
  const extension = file.name.includes('.') ? file.name.split('.').pop() : '';
56
33
  return extension ? extension.toUpperCase() : 'File';
57
34
  };
58
- /**
59
- * @summary A control for choosing files, by pointer or by dropping them on it.
60
- *
61
- * @description
62
- * **A real `<input type="file">`, kept and made invisible rather than
63
- * replaced.** It is the only way to open a file picker: the dialog is a
64
- * privileged action a script cannot start on its own, and it opens for a click
65
- * on the input or on a `<label>` pointing at it. A component that draws its own
66
- * button ends up calling `.click()` on a hidden input anyway, and then has two
67
- * things a screen reader can find where there is one control.
68
- *
69
- * So the input stays, holds the label, the name, the validity and the focus,
70
- * and the visible trigger is a `<label for>` rather than a button. A label is
71
- * not focusable and does not need to be: the input is, and Enter and Space on it
72
- * open the picker because that is what the platform does with a file input.
73
- *
74
- * **Dropping is why this is a component rather than an attribute.** A bare file
75
- * input accepts a drop only on the input itself, which is invisible here, so the
76
- * field is the drop target and it says when something is over it.
77
- *
78
- * **Files are not a value a consumer can hand back.** Every other control here
79
- * takes the controlled and uncontrolled pair, and this one does not: a `FileList`
80
- * cannot be constructed from markup, and browsers refuse a consumer writing
81
- * `value` on a file input outright, because a page that could name a file could
82
- * read it. So this element owns what was chosen, announces it, and offers
83
- * `clear()` for the one change a consumer can legitimately ask for.
84
- *
85
- * **The list under it is the control's own, not decoration.** A file input with
86
- * files chosen reports "3 files" and nothing else, which is unusable for
87
- * checking that the right ones were picked. The names are rendered, and each
88
- * carries the way to take it back out.
89
- *
90
- * @since 0.1.0
91
- * @status stable
92
- * @category forms
93
- * @aliases FileUpload, FilePicker, Dropzone, InputFile
94
- * @documentation https://helix.fusion.dev/components/file-input
95
- *
96
- * @slot feedback - What is wrong with it.
97
- * @slot hint - Help under the field.
98
- * @slot trigger - What the reader presses to open the picker.
99
- *
100
- * @event {Event} change - Announced once the value has moved.
101
- * @event {HelixBeforeChangeEvent} helix-before-change - Fired before the chosen files change, with their names. Cancelable: refusing it takes the decision and nothing moves.
102
- * @event {Event} input - Announced once the value has moved.
103
- *
104
- * @csspart control - The file input itself, which carries the label and validity.
105
- * @csspart detail - Its name and its reading, stacked.
106
- * @csspart feedback - What is wrong with it.
107
- * @csspart field - The box that is pressed and dropped on.
108
- * @csspart file - One chosen file.
109
- * @csspart hint - Help under the field, while nothing is wrong.
110
- * @csspart kind - What kind of file it is.
111
- * @csspart kind-dismiss - The remove button, when `dismissible`.
112
- * @csspart kind-dismiss-mark - The glyph on the dismiss control, where none is slotted.
113
- * @csspart kind-prefix - The category before the label.
114
- * @csspart label - The question, over the field.
115
- * @csspart list - The chosen files.
116
- * @csspart meta - The row holding its size and kind.
117
- * @csspart name - Its name.
118
- * @csspart remove - The control that takes it back out.
119
- * @csspart size - How big it is.
120
- * @csspart size-dismiss - The remove button, when `dismissible`.
121
- * @csspart size-dismiss-mark - The glyph on the dismiss control, where none is slotted.
122
- * @csspart size-prefix - The category before the label.
123
- * @csspart trigger - The label that opens the picker.
124
- */
125
35
  let HelixHeadlessFileInput = class HelixHeadlessFileInput extends MixinField(HelixFormElement) {
126
36
  constructor() {
127
37
  super(...arguments);
128
- /* A consumer's `aria-describedby` names ids in their own tree and the control
129
- is in this shadow root, so the reference has to be carried down as an
130
- element rather than copied as an id. See `AriaForwardController`: measured
131
- in Chrome's own tree, the host held the description and the control the
132
- reader is actually on was exposed without it. */
133
38
  this.aria = new AriaForwardController(this, {
134
39
  target: () => this.renderRoot?.querySelector('[part="control"]') ?? null,
135
40
  });
136
- /**
137
- * Which files the picker offers, as a native `accept` list.
138
- */
139
41
  this.accept = '';
140
- /**
141
- * Language tag the sizes are written in.
142
- *
143
- * Absent uses the runtime's own.
144
- */
145
42
  this.locale = '';
146
- /**
147
- * Whether more than one file can be chosen.
148
- */
149
43
  this.multiple = false;
150
- /**
151
- * What the control removing one file is called, per file.
152
- */
153
44
  this.removeLabel = 'Remove';
154
- /**
155
- * What the trigger reads, where nothing is slotted into it.
156
- */
157
45
  this.triggerLabel = 'Choose files';
158
- /**
159
- * Whether something is being dragged over the field.
160
- *
161
- * State rather than a property: it is a fact about a gesture in progress and
162
- * nothing a consumer sets. It reaches the stylesheet as `data-dropping` on an
163
- * element in this shadow root, never on the host, which is markup a consumer
164
- * rendered.
165
- */
166
46
  this.dropping = false;
167
47
  this.handleChange = (event) => {
168
- // The native `change` is relayed under this element's own name, so a
169
- // consumer hears one event from one element rather than two from two.
170
48
  event.stopPropagation();
171
49
  this.announce();
172
50
  };
173
- /*
174
- * Takes a drop, which a bare file input can only do on itself.
175
- *
176
- * The input is invisible here, so the field is the target and the files are
177
- * put into the input afterwards: that keeps one source of truth, and it is
178
- * what makes a dropped file and a picked file indistinguishable to a form.
179
- *
180
- * `preventDefault` on dragover as well as on drop, because without it the
181
- * browser navigates to the file and the page is gone.
182
- */
183
51
  this.handleDragOver = (event) => {
184
52
  if (this.isDisabled) {
185
53
  return;
@@ -202,8 +70,6 @@ let HelixHeadlessFileInput = class HelixHeadlessFileInput extends MixinField(Hel
202
70
  return;
203
71
  }
204
72
  const keeping = new DataTransfer();
205
- // A single-file control takes the last thing dropped, the way choosing
206
- // again in the picker replaces rather than adds.
207
73
  for (const file of this.multiple ? [...this.files, ...dropped] : dropped.slice(-1)) {
208
74
  keeping.items.add(file);
209
75
  }
@@ -217,38 +83,15 @@ let HelixHeadlessFileInput = class HelixHeadlessFileInput extends MixinField(Hel
217
83
  }
218
84
  };
219
85
  }
220
- /**
221
- * The files chosen, which is the element's own and never assigned.
222
- *
223
- * Read from the input rather than mirrored into a field. The input is what
224
- * the picker filled and what a form submits, so a copy here could only ever
225
- * disagree with it.
226
- */
227
86
  get files() {
228
87
  return [...(this.control?.files ?? [])];
229
88
  }
230
- /**
231
- * The input, or nothing where there is no tree to ask.
232
- *
233
- * The guard is for the server, whose element shim carries attributes but not
234
- * traversal: `render` reads `files`, `files` reads this, and an unguarded
235
- * `querySelector` threw before a single character of markup came out. Nothing
236
- * is lost by answering `null` there, because a file only ever arrives through
237
- * a reader and a server has never met one.
238
- */
239
89
  get control() {
240
90
  if (typeof this.renderRoot?.querySelector !== 'function') {
241
91
  return null;
242
92
  }
243
93
  return this.renderRoot.querySelector('[part="control"]');
244
94
  }
245
- /**
246
- * Takes every file back out.
247
- *
248
- * The one change a consumer can ask for. Setting a `FileList` is refused by
249
- * every browser for a reason worth keeping, but emptying one is not: nothing
250
- * about the page learns anything it did not already have.
251
- */
252
95
  clear() {
253
96
  const control = this.control;
254
97
  if (!control) {
@@ -257,17 +100,6 @@ let HelixHeadlessFileInput = class HelixHeadlessFileInput extends MixinField(Hel
257
100
  control.value = '';
258
101
  this.announce();
259
102
  }
260
- /**
261
- * Drops one file, by index.
262
- *
263
- * `removeAt` rather than `remove`, which is `Element`'s own and takes no
264
- * arguments: shadowing it would break every caller who meant the node.
265
- *
266
- * Through `DataTransfer`, which is the only way to build a `FileList`: the
267
- * type has no constructor and `input.files` takes nothing else. It is the same
268
- * mechanism a drop uses, so a file put in by dragging and one put in by the
269
- * picker come back out the same way.
270
- */
271
103
  removeAt(at) {
272
104
  const control = this.control;
273
105
  if (!control || typeof DataTransfer === 'undefined') {
@@ -282,32 +114,15 @@ let HelixHeadlessFileInput = class HelixHeadlessFileInput extends MixinField(Hel
282
114
  control.files = keeping.files;
283
115
  this.announce();
284
116
  }
285
- /** Says what is now chosen, by name, and puts it in the form. */
286
117
  announce() {
287
118
  this.sync();
288
119
  this.requestUpdate();
289
120
  this.dispatchEvent(new HelixBeforeChangeEvent({
290
121
  value: this.files.map((file) => file.name).join(', '),
291
122
  }));
292
- /* And under the platform's own names, which is what `onChange` and `onInput`
293
- bind and what a native file input sends when the chosen files move: both,
294
- in that order, the way every other control here does.
295
-
296
- Without the explicit `input`, the only one a consumer receives is the
297
- inner `<input type="file">`'s own, composed out of this shadow root, so
298
- the documented event does not exist and an undocumented one arrives in
299
- its place. */
300
123
  this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
301
124
  this.dispatchEvent(new Event('change', { bubbles: true, composed: false }));
302
125
  }
303
- /**
304
- * Reports the files and the validity the host stands behind.
305
- *
306
- * `FormData` rather than a string, because a file input submits the file
307
- * itself and several of them under one name. The anchor on `setValidity` is
308
- * the input, so the browser's own bubble points at something a reader can see
309
- * rather than at a host with no text in it.
310
- */
311
126
  sync() {
312
127
  const data = new FormData();
313
128
  if (this.name) {
@@ -315,18 +130,6 @@ let HelixHeadlessFileInput = class HelixHeadlessFileInput extends MixinField(Hel
315
130
  data.append(this.name, file);
316
131
  }
317
132
  }
318
- /* **An empty one contributes no entry, and the native control contributes an
319
- empty file.** Measured, one form holding both: `<input type="file"
320
- name="nf">` with nothing chosen submits `nf=` carrying a `File` of zero
321
- bytes and no name, and this submits nothing at all.
322
-
323
- Kept, as a decision rather than an omission. A phantom entry naming a file
324
- that does not exist is a thing every server reading this form has to know
325
- to discard, and the reason the platform sends one is compatibility with
326
- form encodings that predate `FormData`. `value` is excused on this family
327
- already, for the neighboring reason that the native one is
328
- `C:\fakepath\name`; this is the same component declining to reproduce a
329
- lie, one level down. */
330
133
  this.internals.setFormValue(data);
331
134
  const control = this.control;
332
135
  if (!control) {
@@ -337,52 +140,17 @@ let HelixHeadlessFileInput = class HelixHeadlessFileInput extends MixinField(Hel
337
140
  }
338
141
  firstUpdated(changed) {
339
142
  super.firstUpdated(changed);
340
- // Whatever the markup arrived with, so a form knows before anything is
341
- // touched. `willUpdate` is too early: there is no control to read yet.
342
143
  this.sync();
343
144
  }
344
- /** Puts focus on the input, the way a label pointing at one would. */
345
145
  focus(options) {
346
146
  this.control?.focus(options);
347
147
  }
348
- /** Empties it, which is what a form reset means for a file input. */
349
148
  formResetCallback() {
350
149
  if (this.control) {
351
150
  restoreValue(this.control, '');
352
151
  }
353
152
  this.announce();
354
153
  }
355
- /*
356
- * The hint goes under the field, and the list after both.
357
- *
358
- * A hint is instruction for the next action rather than a note about the
359
- * result: "up to 10MB each" is a rule for what may be added, and a rule read
360
- * after the outcome is a rule read too late. Its position also has to be
361
- * stable, and under the list it travels down the page as files are added, a
362
- * card's height at a time. Every other field here puts it immediately under
363
- * the control.
364
- *
365
- * Feedback stands in place of the hint rather than beside it, so wherever the
366
- * hint is, an error is. An error about a file that was refused belongs
367
- * against the control that refused it, not below a list of the ones that were
368
- * not.
369
- */
370
- /**
371
- * What is known about a chosen file, as tags, behind a seam the styled tier
372
- * can move.
373
- *
374
- * One method for both the size and the kind, because they are the same
375
- * element saying two things and two methods would be two places to keep in
376
- * step. Both parts are written out rather than passed in, and that is not
377
- * style: a part name behind an interpolation is a part the manifest cannot
378
- * see, so a stylesheet targeting it matches nothing.
379
- *
380
- * A method rather than a fragment inline, so a tier above can render its own,
381
- * wrap this one, or leave it out without reimplementing the whole template.
382
- * The tag is interpolated rather than written, so `tier` resolves it: this
383
- * renders the headless element, and a styled subclass that sets `tier`
384
- * renders the styled one from the same template.
385
- */
386
154
  renderMeta(file) {
387
155
  return html `<${this.tag('tag')} part="size"
388
156
  exportparts="dismiss: size-dismiss, dismiss-mark: size-dismiss-mark, prefix: size-prefix">${size(file.size, this.locale)}</${this.tag('tag')}>
@@ -7,93 +7,17 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  import { customElement, elapsedTime, HelixElement, subscribe } from '@fusion.dev/helix-core';
8
8
  import { html, nothing } from 'lit';
9
9
  import { property, state } from 'lit/decorators.js';
10
- /**
11
- * @summary A timestamp, written the way the reader's locale writes one.
12
- *
13
- * @description
14
- * A date in a page is almost never wanted as an ISO string, and formatting one
15
- * in the consumer's own code means every consumer ships a copy of the same
16
- * `Intl` call and a different set of options. This is that call, as an element,
17
- * so a date is written where it is shown.
18
- *
19
- * **`elapsed` is the mode a feed wants, and it stops being relative on
20
- * purpose.** "42m", "3h", "1d" is what a row of activity reads as, and past a
21
- * week the compact form is a lie: `Intl.NumberFormat` narrow renders both
22
- * `minute` and `month` as `m` in en-US, so "3m" is three minutes at the top of a
23
- * feed and three months further down it. Past six days this shows a date, which
24
- * is shorter to read and impossible to misread. `elapsedTime` in `helix/core`
25
- * carries the measurement.
26
- *
27
- * **The reading refreshes itself, from one timer the page shares.** A relative
28
- * timestamp is correct for as long as its unit lasts and wrong afterwards, and a
29
- * page left open is exactly where that shows. Each element asks to be woken when
30
- * its own reading expires rather than polling, and an element showing a date
31
- * asks for nothing at all: fifty rows and fifty intervals is fifty wakeups a
32
- * second where one will do, and a feed of last month's notifications should cost
33
- * nothing to leave open.
34
- *
35
- * **`<time datetime>` whatever the text says**, so a screen reader, a copy and
36
- * paste, and anything parsing the page all get the real instant rather than
37
- * "yesterday".
38
- *
39
- * @since 0.1.0
40
- * @status experimental
41
- * @category typography
42
- * @aliases RelativeTime, Timestamp, TimeAgo
43
- * @documentation https://helix.fusion.dev/components/format-date
44
- *
45
- * @csspart time - The `<time>` element carrying the reading.
46
- */
47
10
  let HelixHeadlessFormatDate = class HelixHeadlessFormatDate extends HelixElement {
48
11
  constructor() {
49
12
  super(...arguments);
50
- /**
51
- * The timestamp, as anything `Date` parses.
52
- *
53
- * An ISO string is what an API sends and what `<time datetime>` takes, so it
54
- * is the shape that needs no translation at either end. Left unset this
55
- * renders nothing rather than the epoch, which is what parsing an empty string
56
- * would give.
57
- *
58
- * **The element's own text is read as a fallback, and only in a browser.**
59
- * `<helix-format-date elapsed>2026-08-27T01:00:00Z</helix-format-date>` is the
60
- * spelling that reads best in markup, and it cannot be the only one: measured
61
- * against Lit's server shim, `textContent`, `children`, `firstChild` and
62
- * `querySelector` are all `undefined` there, so a server has no way to see it.
63
- * An element rendered on a server needs the attribute; one rendered in a
64
- * browser may use either.
65
- */
66
13
  this.date = '';
67
- /**
68
- * Show how long ago it was, compactly, instead of the date.
69
- *
70
- * Reflected, because it is the one property that decides which of two readings
71
- * this is and a stylesheet has a reason to know: a compact reading is a fixed
72
- * few characters and a date is not.
73
- */
74
14
  this.elapsed = false;
75
- /**
76
- * Locale for the reading.
77
- *
78
- * Empty is the runtime's own.
79
- */
80
15
  this.locale = '';
81
- /**
82
- * The clock this element last drew against.
83
- *
84
- * A state field and not a read of `Date.now()` inside `render`, which would
85
- * make the template a function of something Lit cannot see change: the element
86
- * would draw the right text once and never notice it had gone stale.
87
- */
88
16
  this.clock = undefined;
89
- /**
90
- * The date read out of the light DOM, where no attribute carried one.
91
- */
92
17
  this.written = '';
93
18
  this.stopTicking = undefined;
94
19
  this.watching = undefined;
95
20
  }
96
- /** The timestamp, or nothing where there is not one to read. */
97
21
  get moment() {
98
22
  const source = this.date || this.written;
99
23
  if (source === '') {
@@ -102,7 +26,6 @@ let HelixHeadlessFormatDate = class HelixHeadlessFormatDate extends HelixElement
102
26
  const parsed = new Date(source);
103
27
  return Number.isNaN(parsed.getTime()) ? undefined : parsed;
104
28
  }
105
- /** Every `Intl.DateTimeFormat` option a consumer set, and none they did not. */
106
29
  get options() {
107
30
  const asked = {};
108
31
  if (this.day) {
@@ -137,20 +60,12 @@ let HelixHeadlessFormatDate = class HelixHeadlessFormatDate extends HelixElement
137
60
  connectedCallback() {
138
61
  super.connectedCallback();
139
62
  this.tick();
140
- /* Rebuilt on every connection rather than built once, because
141
- `disconnectedCallback` takes it down and a framework moves nodes for
142
- ordinary reasons: built in `firstUpdated` alone this comes back
143
- permanently dead the first time a list is reordered. Guarded on
144
- `hasUpdated` because the first connection happens before there is anything
145
- to watch. */
146
63
  if (this.hasUpdated) {
147
64
  this.watch();
148
65
  }
149
66
  }
150
67
  disconnectedCallback() {
151
68
  super.disconnectedCallback();
152
- /* Both outlive the element otherwise, and a page that mounts and unmounts a
153
- feed leaks one of each every time it opens. */
154
69
  this.stopTicking?.();
155
70
  this.stopTicking = undefined;
156
71
  this.watching?.disconnect();
@@ -167,7 +82,6 @@ let HelixHeadlessFormatDate = class HelixHeadlessFormatDate extends HelixElement
167
82
  this.tick();
168
83
  }
169
84
  }
170
- /** The light DOM's own text, which is the other way to write the value. */
171
85
  read() {
172
86
  if (this.date !== '') {
173
87
  return;
@@ -183,15 +97,6 @@ let HelixHeadlessFormatDate = class HelixHeadlessFormatDate extends HelixElement
183
97
  this.watching = new MutationObserver(() => this.read());
184
98
  this.watching.observe(this, { characterData: true, childList: true, subtree: true });
185
99
  }
186
- /**
187
- * Schedules the next reading, and only the next one.
188
- *
189
- * The formatter says when its own answer expires, so this asks for exactly
190
- * that moment rather than polling: an item minutes old wakes once a minute, an
191
- * item hours old once an hour, and an item showing a date never, which is the
192
- * difference between a feed that costs nothing to leave open and one that
193
- * does.
194
- */
195
100
  tick() {
196
101
  this.stopTicking?.();
197
102
  this.stopTicking = undefined;
@@ -1,28 +1,3 @@
1
- /**
2
- * Which tone a reading gets, and how many readings fit.
3
- *
4
- * Kept apart from the element because both answers are arithmetic and both are
5
- * easy to get subtly wrong in ways a picture hides: a threshold comparison off
6
- * by one boundary colors a handful of cells wrong out of ninety, and a fit
7
- * calculation off by one drops a day silently. Neither is visible in a
8
- * screenshot of a strip.
9
- */
10
- /**
11
- * The tone for one reading, from its own `data-tone` or from the thresholds.
12
- *
13
- * **The cell's own tone wins**, and that ordering is the whole reason both
14
- * exist. A status page already knows a day was degraded; making it encode that
15
- * as a number so a component can decode it again is work for nothing, and the
16
- * encoding would have to agree with the thresholds or the colors would drift
17
- * from the truth. A contribution graph has only counts and wants the buckets.
18
- *
19
- * Thresholds are read as lower bounds, in the order given, and the last one that
20
- * a value reaches wins. `thresholds="0,1,3,6"` with `tones="muted,info,accent,success"`
21
- * puts 0 in `muted`, 1 and 2 in `info`, 3 to 5 in `accent`, and 6 upward in
22
- * `success`. A value below the first bound gets no tone rather than the first
23
- * one: an empty cell in a contribution graph is a day with nothing in it, and
24
- * painting it as the lowest bucket claims a reading that does not exist.
25
- */
26
1
  export const toneOf = (reading, thresholds, tones) => {
27
2
  if (reading.tone) {
28
3
  return reading.tone;
@@ -38,48 +13,16 @@ export const toneOf = (reading, thresholds, tones) => {
38
13
  }
39
14
  return chosen;
40
15
  };
41
- /** A comma or space separated list of numbers, in the order written. */
42
16
  export const numbers = (source) => source
43
17
  .split(/[\s,]+/)
44
18
  .filter((one) => one !== '')
45
19
  .map(Number)
46
20
  .filter((one) => Number.isFinite(one));
47
- /** A comma or space separated list of names, in the order written. */
48
21
  export const words = (source) => source.split(/[\s,]+/).filter((one) => one !== '');
49
- /**
50
- * How many readings fit across a given width, and which ones they are.
51
- *
52
- * **Ninety cells in 320 pixels is three and a half pixels each**, which is not a
53
- * strip, it is a smear. So a narrow container shows fewer cells rather than
54
- * thinner ones, and it shows the *most recent* ones, because a status strip that
55
- * drops the newest days to keep the oldest is answering a question nobody asked.
56
- *
57
- * **The count it returns is what the summary has to be recomputed against.** A
58
- * strip labeled "99.2% over 90 days" that is drawing 45 of them is telling the
59
- * reader something false about the picture in front of them, and nothing about
60
- * the picture says so. That is the one fault in this component that a screenshot
61
- * cannot show, which is why the element reflects the number and announces it.
62
- *
63
- * **What crosses the width is a column, and `rows` is how many cells are in
64
- * one.** Counting cells is the same number only while `rows` is 1. Divide by
65
- * the wrong thing at `rows="7"` and a ten week graph comes back as fifty-nine
66
- * readings in a 592px box: eleven days dropped to fit a strip that wanted 156
67
- * pixels, and fifty-nine is not a multiple of seven, so every column after the
68
- * cut is offset by a day and the day-of-week rows stop meaning anything. A
69
- * calendar heatmap that silently rotates its own weekdays is worse than one
70
- * that drops a week.
71
- *
72
- * The count is therefore always a whole number of columns, which is what keeps
73
- * the tail slice aligned.
74
- */
75
22
  export const fits = (total, width, minimum, gap, rows = 1) => {
76
23
  if (!Number.isFinite(width) || width <= 0 || minimum <= 0) {
77
24
  return total;
78
25
  }
79
- /* n columns need n widths and n-1 gaps, so solving for n gives the +gap on
80
- both sides. Written as `width / (minimum + gap)` it is short by one column
81
- at every size, which is the off-by-one that reads as "the strip has a
82
- margin". */
83
26
  const columns = Math.max(1, Math.floor((width + gap) / (minimum + gap)));
84
27
  return Math.min(total, columns * Math.max(1, rows));
85
28
  };