@fusion.dev/helix-headless 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -12,286 +12,57 @@ import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
12
12
  import { html } from 'lit/static-html.js';
13
13
  import '../../date-picker/index.js';
14
14
  import '../../tag/index.js';
15
- /** Whether a string is a date the rest of this component can reason about. */
16
15
  const isIso = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value);
17
- /**
18
- * The dates in a value, which may hold more than one.
19
- *
20
- * A range is two and a multiple is any number, comma separated, which is what
21
- * the calendar announces and what a form field can carry as one string. Empty
22
- * entries are dropped so a trailing comma while typing is not a date.
23
- */
24
- const dates = (value) =>
25
- /* The en dash and the spaced hyphen both mean "to", because a range is shown
26
- that way and what is shown is what gets typed back. An ISO date holds
27
- hyphens of its own, so only a spaced one separates: `2026-08-04-2026-08-11`
28
- is not a thing anybody writes, and treating a bare hyphen as a separator
29
- would take `2026-08-04` apart into three numbers. */
30
- value
16
+ const dates = (value) => value
31
17
  .replace(/\s+[–-]\s+/g, ',')
32
18
  .split(',')
33
19
  .map((one) => one.trim())
34
20
  .filter(Boolean);
35
- /**
36
- * @summary A date, typed or picked from a calendar.
37
- *
38
- * @description
39
- * **The calendar is `helix-date-picker`, not a second one written here.** It
40
- * already knows about months, ranges, disabled days, the first day of the week
41
- * and every keyboard rule a grid owes a reader, and a picker that reimplemented
42
- * a tenth of that would be a worse grid that also had to be maintained. This
43
- * element is the field, the overlay, and the translation between them.
44
- *
45
- * **Typed as well as picked, and the typing is the reason it is not
46
- * `<input type="date">`.** The native control gives a free picker and a free
47
- * locale and takes almost all styling with it, which is a fair trade until a
48
- * product needs the calendar to match everything around it. What is kept from
49
- * the native one is the part worth keeping: a real text field, so a reader who
50
- * knows the date types it and never opens anything.
51
- *
52
- * **The value is `YYYY-MM-DD`, always.** That is what a form submits, what
53
- * `min` and `max` compare against, and what the calendar speaks. What the field
54
- * *shows* is the same string: a locale-formatted display would have to be parsed
55
- * back on every keystroke, and half-typed input does not survive a round trip
56
- * through a formatter. The same reasoning the sandbox records for its two
57
- * syntaxes, which is that a document being edited must not be a rendering of
58
- * something else.
59
- *
60
- * **Opening does not commit.** The calendar announces a date, this element takes
61
- * it, and the overlay shuts, because a single date has nothing more to say. A
62
- * consumer who owns `value` answers the announcement the way they answer any
63
- * other control here.
64
- *
65
- * @since 0.1.0
66
- * @status experimental
67
- * @category forms
68
- * @aliases DatePicker, DateField, InputDate
69
- * @documentation https://helix.fusion.dev/components/date-input
70
- *
71
- * @slot feedback - What is wrong with it.
72
- * @slot hint - Help under the field.
73
- * @slot trigger - The mark on the field saying a calendar opens.
74
- *
75
- * @event {Event} change - Announced once the value has moved.
76
- * @event {HelixBeforeChangeEvent} helix-before-change - Fired before the date changes. Cancelable: refusing it takes the decision and nothing moves.
77
- * @event {HelixBeforeCloseEvent} helix-before-close - Fired before it closes. Cancelable: refusing it takes the decision and nothing moves.
78
- * @event {HelixBeforeOpenEvent} helix-before-open - Fired before the calendar opens. Cancelable: refusing it takes the decision and nothing moves.
79
- * @event {Event} input - Announced once the value has moved.
80
- * @event {Event} toggle - Says the state moved, carrying `newState` and `oldState`.
81
- *
82
- * @csspart control - The text input.
83
- * @csspart date - One held date, as a tag, in `multiple`.
84
- * @csspart date-dismiss - The remove button, when `dismissible`.
85
- * @csspart date-dismiss-mark - The glyph on the dismiss control, where none is slotted.
86
- * @csspart date-prefix - The category before the label.
87
- * @csspart feedback - What is wrong with it.
88
- * @csspart field - The box holding the text and the trigger.
89
- * @csspart hint - Help under the field, while nothing is wrong.
90
- * @csspart label - The question, over the field.
91
- * @csspart picker - The date picker inside it.
92
- * @csspart picker-cell - One column of a row, which is what a range paints.
93
- * @csspart picker-day - The shape inside a cell, which is what a single date paints.
94
- * @csspart picker-grid - The grid of days.
95
- * @csspart picker-header - The month name and its controls.
96
- * @csspart picker-heading - The month and year being shown.
97
- * @csspart picker-month - One month, its header and its grid.
98
- * @csspart picker-months - The run of months.
99
- * @csspart picker-next - The control that moves to the next month.
100
- * @csspart picker-next-mark - The glyph on the next month control.
101
- * @csspart picker-previous - The control that moves to the previous month.
102
- * @csspart picker-previous-mark - The glyph on the previous month control.
103
- * @csspart picker-week - One row of days.
104
- * @csspart picker-weekday - A column header naming a day of the week.
105
- * @csspart rest - How many dates are held beyond the ones shown.
106
- * @csspart surface - The floating panel.
107
- * @csspart trigger - The control that opens the calendar.
108
- * @csspart trigger-icon - The mark on the button that opens the calendar.
109
- */
110
21
  let HelixHeadlessDateInput = class HelixHeadlessDateInput extends MixinControllerOpen(MixinControllerValue(MixinField(HelixFormElement))) {
111
22
  constructor() {
112
23
  super(...arguments);
113
- /* A consumer's `aria-describedby` names ids in their own tree and the control
114
- is in this shadow root, so the reference has to be carried down as an
115
- element rather than copied as an id. See `AriaForwardController`: measured
116
- in Chrome's own tree, the host held the description and the control the
117
- reader is actually on was exposed without it. */
118
24
  this.aria = new AriaForwardController(this, {
119
25
  target: () => this.renderRoot?.querySelector('[part="control"]') ?? null,
120
26
  });
121
- /**
122
- * What the browser may autofill here, as one of the platform's own tokens.
123
- *
124
- * Empty leaves the attribute off, which is the browser's default and is what
125
- * lets autofill work at all: hardcoding `off` here means a birthday or a
126
- * postal code can never be filled in for a reader.
127
- */
128
27
  this.autocomplete = '';
129
- /**
130
- * How a date is written in the field: `short` through `full`, or ISO.
131
- *
132
- * **The field renders when it is idle and holds the value when it is being
133
- * edited.** A written date is a rendering, and a document being edited must
134
- * not be a rendering of something else: `Intl.DateTimeFormat` formats and does
135
- * not parse, so `4 August 2026` cannot be typed back, and writing a parser per
136
- * locale is how a field ends up rejecting what it just displayed.
137
- *
138
- * The way out is not to choose between the two but to separate them in time.
139
- * Idle, the field shows `4 August 2026`, which is what a date is for reading.
140
- * Focused, it shows `2026-08-04`, which is what a date is for typing, and it
141
- * goes back on the way out. Nothing parses a rendering, and nothing is
142
- * read-only, which is what an earlier version of this settled for.
143
- *
144
- * A range is the exception that needs no swap: the dash between its two dates
145
- * is a separator this element understands, so that much reads back either way.
146
- *
147
- * `iso` turns the rendering off, for a field whose reader is entering data
148
- * rather than reading it, or where the ISO form is itself the point.
149
- *
150
- * The value is always `YYYY-MM-DD` regardless, because that is what a form
151
- * submits and what `min` and `max` compare against.
152
- */
153
28
  this.dateStyle = 'long';
154
- /**
155
- * Dates that cannot be chosen, as a comma-separated `YYYY-MM-DD` list.
156
- */
157
29
  this.disableDates = '';
158
- /**
159
- * Days of the week that cannot be chosen, `0` for Sunday through `6`.
160
- */
161
30
  this.disableDays = '';
162
- /**
163
- * First column of the week in the calendar, `0` for Sunday through `6`.
164
- */
165
31
  this.firstDayOfWeek = 0;
166
- /**
167
- * Language tag the calendar names its months and days in.
168
- */
169
32
  this.locale = '';
170
- /**
171
- * Latest date that can be chosen, as `YYYY-MM-DD`.
172
- */
173
33
  this.max = '';
174
- /**
175
- * How many dates are shown as tags before the rest are counted, in `multiple`.
176
- *
177
- * `0` is all of them, and is the default: a cap that guesses is worse than no
178
- * cap, because the number that fits depends on the width of the field and the
179
- * length of what is in it, neither of which this element knows. What a cap is
180
- * genuinely for is a field in a row or a table cell, where a set of twelve
181
- * dates would push everything below it down a line at a time.
182
- *
183
- * The remainder is counted rather than hidden. `+3` is a fact a reader can
184
- * act on, where a row that silently stops is a field that appears to have lost
185
- * what was put in it, and the count is what tells them there is more to see.
186
- */
187
34
  this.maxTags = 0;
188
- /**
189
- * Earliest date that can be chosen, as `YYYY-MM-DD`.
190
- */
191
35
  this.min = '';
192
- /**
193
- * How many months the calendar shows, side by side.
194
- *
195
- * Worth more than one mainly for a range, where picking a start in March and
196
- * an end in April otherwise means paging between them and losing sight of the
197
- * first. The panel is sized by its content, so this widens it.
198
- */
199
36
  this.months = 1;
200
- /**
201
- * What the field says before a date is in it.
202
- */
203
37
  this.placeholder = 'YYYY-MM-DD';
204
- /**
205
- * Whether the control refuses changes while still submitting its value.
206
- *
207
- * **Enforced by refusing every request in one place**, which is the seam this
208
- * component already has: `requestValue` is what every path here comes through,
209
- * and gating it once is what stops the next call site from being written
210
- * without the check.
211
- *
212
- * Not `disabled`, which is the confusion worth naming: a disabled control
213
- * submits nothing and leaves the tab order, and a read-only one submits its
214
- * value and stays reachable, which is what a form showing a value somebody
215
- * else set needs.
216
- */
217
38
  this.readOnly = false;
218
- /**
219
- * How far apart the selectable dates are, in days.
220
- *
221
- * `<input type="date" step>`'s own unit, handed to the calendar this renders
222
- * so the field and the grid agree about which dates exist. Counted from `min`
223
- * where there is one and from 1 January 1970 where there is not, which is the
224
- * platform's base.
225
- */
226
39
  this.step = 1;
227
- /**
228
- * The overlay, placed against the field rather than the trigger.
229
- *
230
- * Against the field because that is the box a reader sees as the control: a
231
- * calendar hung off a small button at one end of a wide field reads as
232
- * belonging to the button. `Select` anchors to its trigger for the opposite
233
- * reason, its trigger being the whole control.
234
- */
235
40
  this.panel = new PopoverController(this, {
236
41
  anchor: () => this.renderRoot?.querySelector('[part="field"]'),
237
- /* The surface *and* the trigger.
238
- *
239
- * A control is not always one element: the button that opens this panel is
240
- * part of it, and a press on it is not a press outside. Scoped to the
241
- * surface alone, `pointerdown` on the trigger closes the panel and the
242
- * trigger's own `toggleOpen` reopens it on the `click` that follows, so the
243
- * panel can never be closed by the button that opened it.
244
- * `DismissController` documents this failure and takes a list for it. */
245
42
  boundary: () => [
246
43
  this.renderRoot?.querySelector('[part="surface"]'),
247
44
  this.renderRoot?.querySelector('[part="trigger"]'),
248
45
  ],
249
- /* Wider than the four every other panel here takes. Those sit against a
250
- control the same width as themselves and read as an extension of it; this
251
- one is a calendar of its own size hanging off a field, so the gap is what
252
- says the two are separate things rather than one control that grew. */
253
46
  offset: () => 8,
254
47
  onDismiss: () => this.requestOpen(false),
255
48
  placement: 'bottom-start',
256
49
  surface: () => this.renderRoot?.querySelector('[part="surface"]'),
257
50
  });
258
- /**
259
- * Whether the caret is in the field, which is when it holds the value.
260
- */
261
51
  this.editing = false;
262
- /**
263
- * What is being typed but not yet held, for `multiple` only.
264
- *
265
- * The other modes type into the value directly, because one date and a range
266
- * are both a whole answer at the moment they are complete. A multiple is not:
267
- * it is a set being added to, so the field needs somewhere to hold a date
268
- * before it joins the set and somewhere to leave a half-typed one that is not
269
- * a date yet.
270
- */
271
52
  this.draft = '';
272
- /** Relayed under this element's name, so a consumer hears one control. */
273
53
  this.handleInput = (event) => {
274
54
  event.stopPropagation();
275
- // Typing is editing, whatever the focus did. The field has to hold the value
276
- // while it is being changed, or a keystroke lands in a rendering.
277
55
  this.editing = true;
278
56
  const typed = event.target.value;
279
57
  if (this.mode === 'multiple') {
280
58
  this.draft = typed;
281
59
  return;
282
60
  }
283
- /* Stored as commas once every date in it is whole, and kept exactly as typed
284
- until then. A form should submit `2026-08-04,2026-08-11` however the dash
285
- was written, and the dash is a reading of that rather than a second value
286
- shape to support. Half-typed text stays verbatim, for the reason the class
287
- gives about a document being edited: normalizing `2026-08-0` would take
288
- the caret away mid-date. */
289
61
  const held = dates(typed);
290
62
  const whole = held.length > 0 && held.every(isIso);
291
63
  this.requestValue(whole ? held.join(',') : typed);
292
64
  this.restore();
293
65
  };
294
- /** Adds what was typed to the set, or takes the last one back out. */
295
66
  this.handleDraftKey = (event) => {
296
67
  if (this.mode !== 'multiple') {
297
68
  return;
@@ -305,13 +76,9 @@ let HelixHeadlessDateInput = class HelixHeadlessDateInput extends MixinControlle
305
76
  this.draft = '';
306
77
  }
307
78
  else if (event.key === 'Backspace' && this.draft === '' && held.length > 0) {
308
- /* The convention every tag field has: an empty field and a backspace takes
309
- the last one back, so a mistake costs one key rather than a hunt for a
310
- small control. */
311
79
  this.requestValue(held.slice(0, -1).join(','));
312
80
  }
313
81
  };
314
- /** Takes one date back out of the set. */
315
82
  this.removeDate = (date) => {
316
83
  this.requestValue(dates(this.value)
317
84
  .filter((one) => one !== date)
@@ -322,16 +89,11 @@ let HelixHeadlessDateInput = class HelixHeadlessDateInput extends MixinControlle
322
89
  const { value } = event.detail;
323
90
  this.requestValue(value);
324
91
  this.restore();
325
- /* A single date has nothing more to say, so the panel gets out of the way.
326
- A range or a multiple has: the reader is still choosing, and shutting the
327
- calendar under them after the first date is taking the control away
328
- mid-answer. */
329
92
  if (this.mode === undefined) {
330
93
  this.requestOpen(false);
331
94
  this.updateComplete.then(() => this.returnFocus());
332
95
  }
333
96
  };
334
- /** Escape shuts the calendar without moving the date. */
335
97
  this.handleKeydown = (event) => {
336
98
  if (event.key === 'Escape' && this.open) {
337
99
  event.stopPropagation();
@@ -339,64 +101,12 @@ let HelixHeadlessDateInput = class HelixHeadlessDateInput extends MixinControlle
339
101
  this.returnFocus();
340
102
  }
341
103
  };
342
- /**
343
- * Accessible name for the control that opens the calendar.
344
- */
345
104
  this.triggerLabel = 'Choose a date';
346
- /**
347
- * Puts the caret back in the field without the field reopening the calendar.
348
- *
349
- * Focus opens the calendar, so handing focus back after a pick reopens the
350
- * thing that was just answered. `relatedTarget` was the guard and it is not
351
- * enough on its own: it catches a hand-off from the trigger, and it does not
352
- * catch this, because the cell that had focus is gone by the time the caret
353
- * moves and the document has fallen back to something outside this element.
354
- *
355
- * **Reasoned rather than measured, and worth saying which.** In a focused
356
- * document: the reader presses a day, the cell has focus, the panel is hidden,
357
- * the browser moves focus off the removed cell to the body, and the caret is
358
- * then put back here with `relatedTarget` pointing at the body, which is
359
- * outside this element and reads as an arrival. Every step is ordinary browser
360
- * behavior and none of it can be exercised here: `focus()` dispatches no
361
- * event in an unfocused document, which is what both the test runtime and the
362
- * available browser pane are. A spec for this passes with the guard removed,
363
- * so there is no spec: it would cover nothing while looking like proof.
364
- *
365
- * A flag rather than a better guess, because this element is the one moving
366
- * the focus and is the only thing that can know so. Cleared synchronously,
367
- * since `focus()` dispatches before it returns.
368
- */
369
105
  this.returning = false;
370
106
  this.handleBlur = () => {
371
107
  this.editing = false;
372
108
  };
373
- /*
374
- * Focusing the field opens the calendar.
375
- *
376
- * A reader who reaches this control is choosing a date, and making them find
377
- * the trigger afterwards is a second action for something they have already
378
- * asked for. Typing still works with it up, because the calendar follows the
379
- * field rather than the other way round.
380
- *
381
- * **Only where focus arrived from outside this element**, which is the whole
382
- * of the difficulty: picking a date shuts the panel and puts the caret back in
383
- * the field, so an unguarded version reopens the calendar the reader just
384
- * answered. `relatedTarget` is where focus came from, so a hand-off from the
385
- * calendar or the trigger is left alone and only a genuine arrival opens
386
- * anything.
387
- *
388
- * On the input rather than on the field, too. Focus does not bubble, so a
389
- * listener here covers the text and not the trigger, which is what keeps
390
- * pressing the trigger from opening the panel on focus and then shutting it
391
- * again on the click.
392
- */
393
109
  this.handleFocus = (event) => {
394
- /* Not while the caret is being handed back. Picking a date returns focus to
395
- the field, and treating that as editing puts the field into the shape it
396
- takes for typing: a reader picks 4 August 2026 from the calendar and the
397
- field answers `2026-08-04`. Focus is a good enough signal for editing
398
- everywhere except the one moment this element moved the focus itself, and
399
- it already knows when that is. */
400
110
  this.editing = !this.returning;
401
111
  const from = event.relatedTarget;
402
112
  if (this.open || this.isDisabled || this.returning) {
@@ -414,26 +124,14 @@ let HelixHeadlessDateInput = class HelixHeadlessDateInput extends MixinControlle
414
124
  }
415
125
  return this.renderRoot.querySelector('[part="control"]');
416
126
  }
417
- /**
418
- * What the calendar should show, which is only ever a whole date.
419
- *
420
- * Half-typed input reaches here on every keystroke, and `2026-08-1` is not a
421
- * date: handing it to the calendar would move the month under a reader
422
- * mid-word. So the grid follows the field only once the field says something
423
- * complete.
424
- */
425
127
  get picked() {
426
128
  const held = dates(this.value);
427
129
  return held.length > 0 && held.every(isIso) ? held.join(',') : '';
428
130
  }
429
- /** One date, written the way `dateStyle` asks for. */
430
131
  written(date) {
431
132
  if (this.dateStyle === 'iso' || !isIso(date)) {
432
133
  return date;
433
134
  }
434
- /* UTC, because the dates are UTC throughout: a date parsed in a zone behind
435
- Greenwich renders as the day before, which is the classic off-by-one that
436
- makes a calendar disagree with the field beside it. */
437
135
  const at = new Date(`${date}T00:00:00Z`);
438
136
  return Number.isNaN(at.getTime())
439
137
  ? date
@@ -442,50 +140,24 @@ let HelixHeadlessDateInput = class HelixHeadlessDateInput extends MixinControlle
442
140
  timeZone: 'UTC',
443
141
  }).format(at);
444
142
  }
445
- /*
446
- * What the field shows, which is the value in every mode but one.
447
- *
448
- * A range reads `2026-08-04 – 2026-08-11`, because two dates separated by a
449
- * comma is a list and a range is a span: the comma says "and these", the dash
450
- * says "through". It is the one place this component shows something other
451
- * than the value it holds, and it is safe because the dash reads back: `dates`
452
- * takes a spaced dash as a separator, so what is shown is what can be typed.
453
- *
454
- * `multiple` shows nothing here at all. Its dates are tags beside the input
455
- * and the input is a draft.
456
- */
457
143
  get display() {
458
144
  if (this.mode === 'multiple') {
459
145
  return this.draft;
460
146
  }
461
147
  const held = dates(this.value);
462
- /* Being edited, the field is the value. The rendering is what it shows when
463
- nobody is in it, so there is never a moment where what is on screen is
464
- something this element could not read back. */
465
148
  if (this.editing || this.dateStyle === 'iso') {
466
149
  return this.mode === 'range' && held.length === 2 ? held.join(' – ') : this.value;
467
150
  }
468
151
  const written = held.map((one) => this.written(one));
469
152
  return this.mode === 'range' ? written.join(' – ') : written.join(', ');
470
153
  }
471
- /** The dates that get a tag, which is all of them until `maxTags` says not. */
472
154
  get shownDates() {
473
155
  const held = dates(this.value);
474
156
  return this.maxTags > 0 ? held.slice(0, this.maxTags) : held;
475
157
  }
476
- /** How many are held beyond the ones shown. */
477
158
  get restCount() {
478
159
  return Math.max(0, dates(this.value).length - this.shownDates.length);
479
160
  }
480
- /**
481
- * Puts the field back where the answer differs from what was typed.
482
- *
483
- * `requestValue` is the mixin's, and it announces and decides; this is the
484
- * other half, which `Input` needs for the same reason: the native control has
485
- * already changed, so wherever the answer does not follow it, it has to be
486
- * restored. A refusal and a consumer who owns `value` and has not given a new
487
- * one are the same thing from here.
488
- */
489
161
  restore() {
490
162
  const control = this.control;
491
163
  if (control && control.value !== this.display) {
@@ -493,21 +165,12 @@ let HelixHeadlessDateInput = class HelixHeadlessDateInput extends MixinControlle
493
165
  }
494
166
  this.sync();
495
167
  }
496
- /** Reports the value and the validity the host stands behind. */
497
168
  sync() {
498
169
  this.internals.setFormValue(this.value);
499
170
  const control = this.control;
500
171
  if (!control) {
501
172
  return;
502
173
  }
503
- /* Bounds are checked here rather than left to the input, which is `text`
504
- and has no idea what a date is: `min` and `max` on a text input do
505
- nothing, and `type="date"` is what was given up to own the calendar.
506
- String comparison is enough because ISO dates sort lexically, which is
507
- the whole reason the value is that shape. */
508
- /* Every date in the value, not the value itself: a range is two of them and
509
- a multiple is any number, so a string comparison against `min` would be
510
- comparing a bound to a list. */
511
174
  const held = dates(this.value);
512
175
  let message = '';
513
176
  if (held.length > 0 && !held.every(isIso)) {
@@ -533,7 +196,6 @@ let HelixHeadlessDateInput = class HelixHeadlessDateInput extends MixinControlle
533
196
  }
534
197
  this.refreshFeedback();
535
198
  }
536
- /** Refuses every change while read-only. See `readOnly`. */
537
199
  requestValue(next) {
538
200
  if (this.readOnly) {
539
201
  return false;
@@ -542,24 +204,13 @@ let HelixHeadlessDateInput = class HelixHeadlessDateInput extends MixinControlle
542
204
  }
543
205
  firstUpdated(changed) {
544
206
  super.firstUpdated(changed);
545
- // Whatever the markup arrived with, so a form knows before anything is typed.
546
207
  this.sync();
547
208
  }
548
209
  updated(changed) {
549
210
  super.updated(changed);
550
- // Every update, not only the ones that name `value`: see the note on
551
- // `ColorInput.updated`.
552
211
  this.sync();
553
- /* Driven from here rather than from the toggle, because `open` is a property
554
- a consumer may set directly and the panel has to follow either way. The
555
- controller is idempotent, so arriving twice for the same open costs
556
- nothing. Select drives it the same way, and without this the surface stays
557
- `display: none`: a `popover` element is hidden until something shows it,
558
- so the calendar rendered its forty-two cells into a box of nothing. */
559
212
  if (this.open) {
560
213
  this.panel.show();
561
- // Only on the update that opened it, or every keystroke while it is up
562
- // would steal focus back into the grid.
563
214
  if (changed.has('open')) {
564
215
  this.renderRoot?.querySelector('[part="picker"]')?.focus();
565
216
  }
@@ -573,19 +224,9 @@ let HelixHeadlessDateInput = class HelixHeadlessDateInput extends MixinControlle
573
224
  this.control?.focus();
574
225
  this.returning = false;
575
226
  }
576
- /** Puts the caret in the field, the way a label pointing at one would. */
577
227
  focus(options) {
578
228
  this.control?.focus(options);
579
229
  }
580
- /**
581
- * The calendar the field opens, behind a seam the styled tier can move.
582
- *
583
- * A method rather than a fragment inline, so a tier above can replace, wrap
584
- * or omit it without reimplementing the whole template. The tag is
585
- * interpolated rather than written, so `tier` resolves it: this renders the
586
- * headless element, and a styled subclass that sets `tier` renders the styled
587
- * one from the same template.
588
- */
589
230
  renderPicker() {
590
231
  return html `<${this.tag('date-picker')}
591
232
  part="picker"
@@ -609,15 +250,6 @@ let HelixHeadlessDateInput = class HelixHeadlessDateInput extends MixinControlle
609
250
  @input=${this.stopInner}
610
251
  ></${this.tag('date-picker')}>`;
611
252
  }
612
- /**
613
- * One chosen date, as a tag, behind a seam the styled tier can move.
614
- *
615
- * A method rather than a fragment inline, so a tier above can replace, wrap
616
- * or omit it without reimplementing the whole template. The tag is
617
- * interpolated rather than written, so `tier` resolves it: this renders the
618
- * headless element, and a styled subclass that sets `tier` renders the styled
619
- * one from the same template.
620
- */
621
253
  renderTag(date) {
622
254
  return html `<${this.tag('tag')}
623
255
  part="date"
@@ -632,21 +264,6 @@ let HelixHeadlessDateInput = class HelixHeadlessDateInput extends MixinControlle
632
264
  >${this.written(date)}</${this.tag('tag')}
633
265
  >`;
634
266
  }
635
- /**
636
- * The mark on the button that opens the calendar, behind a seam.
637
- *
638
- * Slot fallback, so a consumer replaces it by slotting; and a method, so the
639
- * tier above replaces it by overriding.
640
- *
641
- * **A named import rather than `<helix-icon>`**, which loses on three counts.
642
- * `helix-icon` indexes the whole icon map at runtime, `icons[this.name]`, and
643
- * no bundler can shake a runtime lookup, so every consumer of any component
644
- * that renders one ships all 50kB of path data; a named import ships one
645
- * path. The import is checked when the package compiles, where a `name`
646
- * string is checked only by a later name lookup. And `helix-icon` is not
647
- * registered on the headless tier at all, which registers `helix-headless-icon`,
648
- * so a headless consumer gets an inert unknown element and no mark whatsoever.
649
- */
650
267
  renderTrigger() {
651
268
  return html `<slot name="trigger"><svg aria-hidden="true" fill="currentColor" part="trigger-icon" viewBox=${calendarBlank.viewBox}>
652
269
  ${unsafeSVG(calendarBlank.content)}
@@ -696,26 +313,12 @@ let HelixHeadlessDateInput = class HelixHeadlessDateInput extends MixinControlle
696
313
  </div>
697
314
  ${this.renderHint()}${this.renderFeedback()}`;
698
315
  }
699
- /**
700
- * The value as a `Date`, at midnight UTC, or `null` while there is no date.
701
- *
702
- * The platform's own pair on `<input type="date">`, and this control holds the
703
- * same `YYYY-MM-DD` string, so a consumer moving from one to the other reads
704
- * the same two properties. UTC because the platform chose UTC: a date that is
705
- * the same date everywhere must not become a different one by crossing a
706
- * timezone, and midnight local would do exactly that for anybody west of
707
- * Greenwich.
708
- *
709
- * `null` while the value is half typed, which is most of the keystrokes a
710
- * reader makes in one of these.
711
- */
712
316
  get valueAsDate() {
713
317
  return asDate(this.value);
714
318
  }
715
319
  set valueAsDate(date) {
716
320
  this.value = fromDate(date);
717
321
  }
718
- /** The value as milliseconds since the epoch, or `NaN` while there is no date. */
719
322
  get valueAsNumber() {
720
323
  return asDate(this.value)?.getTime() ?? Number.NaN;
721
324
  }