@fusion.dev/helix-headless 0.153.0 → 0.155.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 (81) hide show
  1. package/custom-elements.json +1885 -140
  2. package/dist/accordion/item/accordion-item.d.ts +15 -0
  3. package/dist/accordion/item/accordion-item.js +12 -1
  4. package/dist/accordion/root/accordion-root.d.ts +2 -1
  5. package/dist/accordion/root/accordion-root.js +1 -0
  6. package/dist/badge/root/badge-root.d.ts +15 -2
  7. package/dist/badge/root/badge-root.js +20 -3
  8. package/dist/breadcrumbs/breadcrumb/breadcrumb.d.ts +36 -3
  9. package/dist/breadcrumbs/breadcrumb/breadcrumb.js +42 -2
  10. package/dist/breadcrumbs/root/breadcrumbs-root.d.ts +15 -5
  11. package/dist/breadcrumbs/root/breadcrumbs-root.js +29 -4
  12. package/dist/button-group/root/button-group-root.d.ts +2 -0
  13. package/dist/button-group/root/button-group-root.js +1 -0
  14. package/dist/card/root/card-root.d.ts +31 -11
  15. package/dist/card/root/card-root.js +25 -11
  16. package/dist/code/root/code-root.d.ts +7 -0
  17. package/dist/code/root/code-root.js +1 -1
  18. package/dist/code-block/root/code-block-root.d.ts +37 -1
  19. package/dist/code-block/root/code-block-root.js +38 -5
  20. package/dist/color-input/root/color-input-root.d.ts +9 -3
  21. package/dist/color-input/root/color-input-root.js +3 -1
  22. package/dist/color-picker/channels.d.ts +5 -3
  23. package/dist/color-picker/channels.js +79 -21
  24. package/dist/color-picker/root/color-picker-root.d.ts +58 -5
  25. package/dist/color-picker/root/color-picker-root.js +75 -13
  26. package/dist/date-input/root/date-input-root.d.ts +1 -0
  27. package/dist/date-input/root/date-input-root.js +2 -1
  28. package/dist/date-picker/root/date-picker-root.d.ts +43 -13
  29. package/dist/date-picker/root/date-picker-root.js +54 -31
  30. package/dist/element-events.generated.js +2 -1
  31. package/dist/empty/root/empty-root.d.ts +19 -1
  32. package/dist/empty/root/empty-root.js +13 -4
  33. package/dist/icon/fetch.d.ts +20 -0
  34. package/dist/icon/fetch.js +70 -0
  35. package/dist/icon/root/icon-root.d.ts +27 -5
  36. package/dist/icon/root/icon-root.js +41 -17
  37. package/dist/navigation/root/navigation-root.d.ts +26 -0
  38. package/dist/navigation/root/navigation-root.js +58 -1
  39. package/dist/quote/root/quote-root.d.ts +17 -1
  40. package/dist/quote/root/quote-root.js +15 -2
  41. package/dist/select/root/select-root.d.ts +29 -3
  42. package/dist/select/root/select-root.js +34 -9
  43. package/dist/skeleton/root/skeleton-root.d.ts +7 -0
  44. package/dist/skeleton/root/skeleton-root.js +4 -1
  45. package/dist/slider/root/slider-root.d.ts +71 -9
  46. package/dist/slider/root/slider-root.js +129 -42
  47. package/dist/speed-dial/item/speed-dial-item.d.ts +29 -1
  48. package/dist/speed-dial/item/speed-dial-item.js +48 -7
  49. package/dist/speed-dial/root/speed-dial-root.d.ts +32 -6
  50. package/dist/speed-dial/root/speed-dial-root.js +50 -5
  51. package/dist/splitter/root/splitter-root.d.ts +22 -0
  52. package/dist/splitter/root/splitter-root.js +36 -7
  53. package/dist/stat/group/stat-group.d.ts +23 -4
  54. package/dist/stat/group/stat-group.js +13 -1
  55. package/dist/stat/root/stat-root.d.ts +46 -1
  56. package/dist/stat/root/stat-root.js +29 -5
  57. package/dist/steps/reach.d.ts +5 -1
  58. package/dist/steps/reach.js +2 -2
  59. package/dist/steps/root/steps-root.d.ts +37 -2
  60. package/dist/steps/root/steps-root.js +49 -7
  61. package/dist/steps/step/step.d.ts +6 -0
  62. package/dist/steps/step/step.js +4 -0
  63. package/dist/tabs/root/tabs-root.d.ts +37 -0
  64. package/dist/tabs/root/tabs-root.js +92 -2
  65. package/dist/tabs/tab/tab.d.ts +38 -2
  66. package/dist/tabs/tab/tab.js +45 -4
  67. package/dist/tag/group/tag-group.d.ts +29 -3
  68. package/dist/tag/group/tag-group.js +42 -11
  69. package/dist/toggle/root/toggle-root.d.ts +2 -0
  70. package/dist/toggle/root/toggle-root.js +1 -1
  71. package/dist/tree/item/tree-item.d.ts +10 -0
  72. package/dist/tree/item/tree-item.js +12 -0
  73. package/dist/tree/root/tree-root.d.ts +40 -1
  74. package/dist/tree/root/tree-root.js +120 -4
  75. package/dist/tree/selection.d.ts +6 -0
  76. package/dist/tree/selection.js +11 -0
  77. package/lint.json +1 -1
  78. package/package.json +5 -5
  79. package/types/jsx.d.ts +1412 -207
  80. package/types/vue.d.ts +294 -65
  81. package/types/words.d.ts +49 -1
@@ -4,9 +4,19 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
4
4
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
- import { AriaForwardController, customElement, formatNumber, formatWords, HelixFormElement, MixinControllerValue, MixinField, restoreValue, words, wordsLocale, } from '@fusion.dev/helix-core';
7
+ import { AriaForwardController, customElement, formatNumber, formatWords, HelixFormElement, MixinControllerValue, MixinField, reportValues, restoreValue, words, wordsLocale, } from '@fusion.dev/helix-core';
8
8
  import { html, nothing } from 'lit';
9
9
  import { property } from 'lit/decorators.js';
10
+ const SLIDER_KEYS = new Set([
11
+ 'ArrowDown',
12
+ 'ArrowLeft',
13
+ 'ArrowRight',
14
+ 'ArrowUp',
15
+ 'End',
16
+ 'Home',
17
+ 'PageDown',
18
+ 'PageUp',
19
+ ]);
10
20
  let HelixHeadlessSlider = class HelixHeadlessSlider extends MixinControllerValue(MixinField(HelixFormElement)) {
11
21
  constructor() {
12
22
  super(...arguments);
@@ -24,21 +34,29 @@ let HelixHeadlessSlider = class HelixHeadlessSlider extends MixinControllerValue
24
34
  this.minStepsBetweenThumbs = 0;
25
35
  this.range = false;
26
36
  this.rangeText = '{start} to {end}';
37
+ this.readOnly = false;
27
38
  this.showValue = false;
28
39
  this.step = 1;
40
+ this.thumbLabel = 'thumb {index}';
29
41
  this.tooltip = false;
30
42
  this.handleInput = (event) => {
31
43
  event.stopPropagation();
32
44
  const control = event.target;
45
+ const at = Number(control.dataset.index ?? 0);
46
+ if (this.readOnly) {
47
+ restoreValue(control, this.range ? String(this.points[at]) : this.value);
48
+ return;
49
+ }
33
50
  if (this.range) {
34
- const [start, end] = this.bounds;
51
+ const points = this.points;
35
52
  const moved = Number(control.value);
36
53
  const gap = this.gap;
37
- const pair = control.dataset.thumb === 'start'
38
- ? [Math.min(moved, end - gap), end]
39
- : [start, Math.max(moved, start + gap)];
40
- this.requestValue(pair.join(','));
41
- restoreValue(control, String(control.dataset.thumb === 'start' ? this.bounds[0] : this.bounds[1]));
54
+ const low = at > 0 ? (points[at - 1] ?? this.min) + gap : Number.NEGATIVE_INFINITY;
55
+ const high = at < points.length - 1 ? (points[at + 1] ?? this.max) - gap : Number.POSITIVE_INFINITY;
56
+ this.requestValue(points
57
+ .map((one, index) => (index === at ? Math.min(high, Math.max(low, moved)) : one))
58
+ .join(','));
59
+ restoreValue(control, String(this.points[at]));
42
60
  return;
43
61
  }
44
62
  const next = control.value;
@@ -47,16 +65,30 @@ let HelixHeadlessSlider = class HelixHeadlessSlider extends MixinControllerValue
47
65
  };
48
66
  this.handleChange = (event) => {
49
67
  event.stopPropagation();
50
- this.announceChange();
68
+ if (!this.readOnly) {
69
+ this.announceChange();
70
+ }
71
+ };
72
+ this.handleKeydown = (event) => {
73
+ if (this.readOnly && SLIDER_KEYS.has(event.key)) {
74
+ event.preventDefault();
75
+ }
51
76
  };
52
77
  this.valueText = '';
78
+ this.handleThumbSlot = () => {
79
+ this.requestUpdate();
80
+ };
53
81
  }
54
82
  static { this.words = 'helix-slider'; }
55
83
  get reserve() {
56
84
  const widest = Math.max(this.said(this.min).length, this.said(this.max).length);
57
- return this.range
58
- ? widest * 2 + formatWords(this, 'rangeText', { end: '', start: '' }).length
59
- : widest;
85
+ const count = this.points.length;
86
+ if (!this.range) {
87
+ return widest;
88
+ }
89
+ return count > 2
90
+ ? widest * count + 2 * (count - 1) + 4
91
+ : widest * 2 + formatWords(this, 'rangeText', { end: '', start: '' }).length;
60
92
  }
61
93
  get precision() {
62
94
  return String(this.step).split('.')[1]?.length ?? 0;
@@ -83,13 +115,15 @@ let HelixHeadlessSlider = class HelixHeadlessSlider extends MixinControllerValue
83
115
  delegatesFocus: true,
84
116
  }; }
85
117
  get bounds() {
86
- const parts = this.value.split(',');
87
- const from = Number.parseFloat(parts[0] ?? '');
88
- const to = Number.parseFloat(parts[1] ?? '');
89
- if (!Number.isFinite(from) || !Number.isFinite(to)) {
118
+ const points = this.points;
119
+ return [points[0] ?? this.min, points.at(-1) ?? this.max];
120
+ }
121
+ get points() {
122
+ const parts = this.value.split(',').map((part) => Number.parseFloat(part));
123
+ if (parts.length < 2 || parts.some((part) => !Number.isFinite(part))) {
90
124
  return [this.min, this.max];
91
125
  }
92
- return [Math.min(from, to), Math.max(from, to)];
126
+ return parts.sort((left, right) => left - right);
93
127
  }
94
128
  along(at) {
95
129
  const span = this.max - this.min;
@@ -99,7 +133,12 @@ let HelixHeadlessSlider = class HelixHeadlessSlider extends MixinControllerValue
99
133
  return Math.min(1, Math.max(0, (at - this.min) / span));
100
134
  }
101
135
  sync() {
102
- this.internals.setFormValue(this.value);
136
+ if (this.range && this.name) {
137
+ reportValues(this.internals, this.name, this.points.map(String));
138
+ }
139
+ else {
140
+ this.internals.setFormValue(this.value);
141
+ }
103
142
  this.refreshFeedback();
104
143
  }
105
144
  get fraction() {
@@ -146,15 +185,14 @@ let HelixHeadlessSlider = class HelixHeadlessSlider extends MixinControllerValue
146
185
  }
147
186
  sanitize(text) {
148
187
  if (this.range) {
149
- const [from, to] = text.split(',').map((part) => Number.parseFloat(part));
150
- if (from === undefined ||
151
- to === undefined ||
152
- !Number.isFinite(from) ||
153
- !Number.isFinite(to)) {
188
+ const values = text.split(',').map((part) => Number.parseFloat(part));
189
+ if (values.length < 2 || values.some((one) => !Number.isFinite(one))) {
154
190
  return `${this.min},${this.max}`;
155
191
  }
156
- const ends = [this.settled(from), this.settled(to)].sort((a, b) => a - b);
157
- return ends.join(',');
192
+ return values
193
+ .map((one) => this.settled(one))
194
+ .sort((a, b) => a - b)
195
+ .join(',');
158
196
  }
159
197
  const parsed = Number.parseFloat(text);
160
198
  const at = Number.isFinite(parsed) ? parsed : this.min + (this.max - this.min) / 2;
@@ -188,7 +226,31 @@ let HelixHeadlessSlider = class HelixHeadlessSlider extends MixinControllerValue
188
226
  super.updated(changed);
189
227
  this.sync();
190
228
  }
191
- renderTooltips(start, end) {
229
+ firstUpdated(changed) {
230
+ super.firstUpdated(changed);
231
+ this.finishHydration();
232
+ }
233
+ get thumbContent() {
234
+ return this.knownChildren.some((child) => child.slot === 'thumb' || child.slot === 'thumb-end');
235
+ }
236
+ renderThumbs() {
237
+ const slot = (end) => end
238
+ ? html `<slot name="thumb-end" @slotchange=${this.handleThumbSlot}></slot>`
239
+ : html `<slot name="thumb" @slotchange=${this.handleThumbSlot}></slot>`;
240
+ const box = (fraction, end, thumb) => html `<span part="thumb" data-thumb=${thumb ?? nothing} style="--slider-at: ${fraction}"
241
+ >${slot(end)}</span
242
+ >`;
243
+ return html `<div part="thumbs" aria-hidden="true" ?hidden=${!this.thumbContent}>
244
+ ${this.range
245
+ ? this.points.map((at, index, all) => index === 0
246
+ ? box(this.along(at), false, 'start')
247
+ : index === all.length - 1
248
+ ? box(this.along(at), true, 'end')
249
+ : html `<span part="thumb" data-thumb="middle" style="--slider-at: ${this.along(at)}"></span>`)
250
+ : box(this.fraction, false)}
251
+ </div>`;
252
+ }
253
+ renderTooltips() {
192
254
  if (!this.tooltip) {
193
255
  return nothing;
194
256
  }
@@ -197,49 +259,60 @@ let HelixHeadlessSlider = class HelixHeadlessSlider extends MixinControllerValue
197
259
  >`;
198
260
  return html `<div part="tooltips" aria-hidden="true">
199
261
  ${this.range
200
- ? [
201
- bubble(this.along(start), this.said(start), 'start'),
202
- bubble(this.along(end), this.said(end), 'end'),
203
- ]
262
+ ? this.points.map((at, index, all) => bubble(this.along(at), this.said(at), index === 0 ? 'start' : index === all.length - 1 ? 'end' : 'middle'))
204
263
  : bubble(this.fraction, this.said(this.value))}
205
264
  </div>`;
206
265
  }
207
266
  render() {
208
267
  const [start, end] = this.bounds;
209
- const thumb = (which, at) => html `<input
268
+ const ids = this.points.map((_, index, all) => index === 0 ? 'control' : index === all.length - 1 ? 'control-end' : `control-${index}`);
269
+ const points = this.points;
270
+ const last = points.length - 1;
271
+ const thumb = (at, index) => {
272
+ const which = index === 0 ? 'start' : index === last ? 'end' : 'middle';
273
+ const named = which === 'middle' ? `thumb-name-${index}` : `${which}-name`;
274
+ const before = points[index - 1];
275
+ const after = points[index + 1];
276
+ return html `<input
210
277
  part="control"
211
- id=${which === 'start' ? 'control' : 'control-end'}
278
+ id=${ids[index] ?? nothing}
212
279
  data-thumb=${which}
280
+ data-index=${index}
213
281
  type="range"
214
282
  style="--slider-from: ${this.along(start)}; --slider-to: ${this.along(end)}${this.axis}"
215
283
  min=${this.min}
216
284
  max=${this.max}
217
285
  step=${this.step}
218
286
  .value=${String(at)}
219
- aria-labelledby=${this.labeled ? `${this.labelId} ${which}-name` : `${which}-name`}
220
- aria-valuemin=${which === 'end' ? start + this.gap : nothing}
221
- aria-valuemax=${which === 'start' ? end - this.gap : nothing}
287
+ aria-labelledby=${this.labeled ? `${this.labelId} ${named}` : named}
288
+ aria-valuemin=${before === undefined ? nothing : before + this.gap}
289
+ aria-valuemax=${after === undefined ? nothing : after - this.gap}
222
290
  aria-valuetext=${this.spoken(at)}
223
291
  aria-describedby=${this.describedBy ?? nothing}
224
292
  aria-invalid=${this.invalidity ?? nothing}
293
+ aria-readonly=${this.readOnly ? 'true' : nothing}
225
294
  ?disabled=${this.isDisabled}
226
295
  @input=${this.handleInput}
227
296
  @change=${this.handleChange}
297
+ @keydown=${this.handleKeydown}
228
298
  />`;
299
+ };
229
300
  return html `${this.renderLabel()}
230
301
  ${this.range
231
302
  ? html `<span id="start-name" hidden>${this.minimumLabel}</span
232
- ><span id="end-name" hidden>${this.maximumLabel}</span>`
303
+ >${this.points.slice(1, -1).map((_, index) => html `<span id=${`thumb-name-${index + 1}`} hidden
304
+ >${formatWords(this, 'thumbLabel', { index: index + 2 })}</span
305
+ >`)}<span id="end-name" hidden>${this.maximumLabel}</span>`
233
306
  : nothing}
234
307
  <div
235
308
  part="field"
236
309
  data-markers=${this.markerValues.length > 0 ? '' : nothing}
237
310
  data-range=${this.range ? '' : nothing}
311
+ data-thumbs=${this.thumbContent ? '' : nothing}
238
312
  data-tooltip=${this.tooltip ? '' : nothing}
239
313
  style="--slider-fraction: ${this.fraction}; --slider-from: ${this.along(start)}; --slider-to: ${this.along(end)}"
240
314
  >
241
- ${this.range ? thumb('start', start) : nothing}
242
- ${this.range ? thumb('end', end) : nothing}
315
+ ${this.range ? this.points.map((at, index) => thumb(at, index)) : nothing}
243
316
  ${this.range
244
317
  ? nothing
245
318
  : html `<input
@@ -256,9 +329,11 @@ let HelixHeadlessSlider = class HelixHeadlessSlider extends MixinControllerValue
256
329
  aria-valuetext=${this.spoken(this.value)}
257
330
  aria-describedby=${this.describedBy ?? nothing}
258
331
  aria-invalid=${this.invalidity ?? nothing}
332
+ aria-readonly=${this.readOnly ? 'true' : nothing}
259
333
  ?disabled=${this.isDisabled}
260
334
  @input=${this.handleInput}
261
335
  @change=${this.handleChange}
336
+ @keydown=${this.handleKeydown}
262
337
  />`}
263
338
  ${this.markers > 1
264
339
  ? html `<div part="markers" aria-hidden="true">
@@ -271,16 +346,21 @@ let HelixHeadlessSlider = class HelixHeadlessSlider extends MixinControllerValue
271
346
  >`)}
272
347
  </div>`
273
348
  : nothing}
274
- ${this.renderTooltips(start, end)}
349
+ ${this.renderThumbs()} ${this.renderTooltips()}
275
350
  ${this.showValue
276
351
  ? html `<output
277
352
  part="output"
278
- for=${this.range ? 'control control-end' : 'control'}
353
+ for=${this.range ? ids.join(' ') : 'control'}
279
354
  aria-hidden="true"
280
355
  style=${`--_slider-output-reserve: ${this.reserve}ch`}
281
- >${this.range
282
- ? formatWords(this, 'rangeText', { end: this.said(end), start: this.said(start) })
283
- : this.said(this.value)}</output
356
+ >${!this.range
357
+ ? this.said(this.value)
358
+ : this.points.length > 2
359
+ ? new Intl.ListFormat(wordsLocale(this), { type: 'conjunction' }).format(this.points.map((at) => this.said(at)))
360
+ : formatWords(this, 'rangeText', {
361
+ end: this.said(end),
362
+ start: this.said(start),
363
+ })}</output
284
364
  >`
285
365
  : nothing}
286
366
  </div>
@@ -332,12 +412,19 @@ __decorate([
332
412
  words,
333
413
  property({ attribute: 'range-text' })
334
414
  ], HelixHeadlessSlider.prototype, "rangeText", void 0);
415
+ __decorate([
416
+ property({ attribute: 'readonly', reflect: true, type: Boolean })
417
+ ], HelixHeadlessSlider.prototype, "readOnly", void 0);
335
418
  __decorate([
336
419
  property({ attribute: 'show-value', reflect: true, type: Boolean })
337
420
  ], HelixHeadlessSlider.prototype, "showValue", void 0);
338
421
  __decorate([
339
422
  property({ type: Number })
340
423
  ], HelixHeadlessSlider.prototype, "step", void 0);
424
+ __decorate([
425
+ words,
426
+ property({ attribute: 'thumb-label' })
427
+ ], HelixHeadlessSlider.prototype, "thumbLabel", void 0);
341
428
  __decorate([
342
429
  property({ type: Boolean })
343
430
  ], HelixHeadlessSlider.prototype, "tooltip", void 0);
@@ -19,6 +19,11 @@ import { type PropertyValues, type TemplateResult } from 'lit';
19
19
  * Put a `Kbd` in the `shortcut` slot to draw it as keys instead; the dial
20
20
  * imports no `Kbd` of its own. Showing a shortcut does not bind it.
21
21
  *
22
+ * `href` makes the action a link, as Menu's rows take one: the `item` part is
23
+ * then an `<a>` carrying `menuitem`, so the browser follows it, opens it in a
24
+ * new tab on a middle press, and offers its own menu for it. Choosing it still
25
+ * sends `helix-select` and closes the dial. A disabled action draws no link.
26
+ *
22
27
  * @since 0.1.0
23
28
  * @status stable
24
29
  * @category actions
@@ -32,7 +37,7 @@ import { type PropertyValues, type TemplateResult } from 'lit';
32
37
  * @event {HelixSelectEvent} helix-select - The action was chosen. Cancelable: cancel it to keep the dial open.
33
38
  *
34
39
  * @csspart icon - The box that holds the icon.
35
- * @csspart item - The control for the action.
40
+ * @csspart item - The control for the action, which is an `<a>` where `href` is set.
36
41
  * @csspart label - The action's name, beside the icon.
37
42
  * @csspart shortcut - The box after the name that holds the shortcut, shown while `shortcut` is set or something is in the `shortcut` slot.
38
43
  */
@@ -41,12 +46,30 @@ export declare class HelixHeadlessSpeedDialItem extends HelixElement {
41
46
  * Whether the action is disabled, so it cannot be chosen.
42
47
  */
43
48
  disabled: boolean;
49
+ /**
50
+ * Whether the link downloads the file when the action is chosen. Only
51
+ * meaningful beside an `href`, and only for a file served from the page's
52
+ * origin.
53
+ */
54
+ download: boolean;
55
+ /**
56
+ * The URL the action links to. Setting it draws the action as a link.
57
+ */
58
+ href: string;
59
+ /**
60
+ * The link's `rel`. Set it whenever you set `target="_blank"`.
61
+ */
62
+ rel: string;
44
63
  /**
45
64
  * The action's keyboard shortcut as a chord, such as `mod+s`, carried as
46
65
  * `aria-keyshortcuts` and drawn after the name. `mod` is Command on an Apple
47
66
  * keyboard and Control elsewhere. Showing a shortcut does not bind it.
48
67
  */
49
68
  shortcut: string;
69
+ /**
70
+ * Where the link opens, such as `_blank` for a new tab.
71
+ */
72
+ target: string;
50
73
  /**
51
74
  * The action's value, sent in `helix-select` when the action is chosen.
52
75
  */
@@ -86,6 +109,11 @@ export declare class HelixHeadlessSpeedDialItem extends HelixElement {
86
109
  private readonly readShortcut;
87
110
  private choose;
88
111
  private readonly handleClick;
112
+ /**
113
+ * Space presses a linked action, which an anchor ignores, and the click that
114
+ * comes back both announces and follows the link. Enter is the anchor's own.
115
+ */
116
+ private readonly handleKeydown;
89
117
  protected render(): TemplateResult;
90
118
  /**
91
119
  * The `shortcut` slot, holding the shortcut drawn after the name as Kbd
@@ -12,7 +12,11 @@ let HelixHeadlessSpeedDialItem = class HelixHeadlessSpeedDialItem extends HelixE
12
12
  constructor() {
13
13
  super(...arguments);
14
14
  this.disabled = false;
15
+ this.download = false;
16
+ this.href = '';
17
+ this.rel = '';
15
18
  this.shortcut = '';
19
+ this.target = '';
16
20
  this.value = '';
17
21
  this.isSpeedDialItem = true;
18
22
  this.hasShortcut = false;
@@ -22,6 +26,16 @@ let HelixHeadlessSpeedDialItem = class HelixHeadlessSpeedDialItem extends HelixE
22
26
  this.handleClick = () => {
23
27
  this.choose();
24
28
  };
29
+ this.handleKeydown = (event) => {
30
+ if (event.key !== ' ') {
31
+ return;
32
+ }
33
+ const link = this.renderRoot.querySelector('a[part="item"]');
34
+ if (link) {
35
+ event.preventDefault();
36
+ link.click();
37
+ }
38
+ };
25
39
  }
26
40
  focus(options) {
27
41
  this.partNamed('item')?.focus(options);
@@ -55,6 +69,27 @@ let HelixHeadlessSpeedDialItem = class HelixHeadlessSpeedDialItem extends HelixE
55
69
  }
56
70
  render() {
57
71
  const keys = this.shortcut === '' ? nothing : ariaChord(this.shortcut, !this.hydrating && onApple());
72
+ const inside = html `<span part="icon"><slot name="icon"></slot></span>
73
+ <span part="label"><slot></slot><span
74
+ part="shortcut"
75
+ aria-hidden="true"
76
+ ?hidden=${!this.hasShortcut && this.shortcut === ''}
77
+ >${this.renderShortcut()}</span
78
+ ></span>`;
79
+ if (this.href !== '' && !this.disabled) {
80
+ return html `<a
81
+ part="item"
82
+ href=${this.href}
83
+ target=${this.target || nothing}
84
+ rel=${this.rel || nothing}
85
+ ?download=${this.download}
86
+ role="menuitem"
87
+ aria-keyshortcuts=${keys}
88
+ tabindex="-1"
89
+ @click=${this.handleClick}
90
+ @keydown=${this.handleKeydown}
91
+ >${inside}</a>`;
92
+ }
58
93
  return html `<button
59
94
  part="item"
60
95
  role="menuitem"
@@ -65,13 +100,7 @@ let HelixHeadlessSpeedDialItem = class HelixHeadlessSpeedDialItem extends HelixE
65
100
  tabindex="-1"
66
101
  @click=${this.handleClick}
67
102
  >
68
- <span part="icon"><slot name="icon"></slot></span>
69
- <span part="label"><slot></slot><span
70
- part="shortcut"
71
- aria-hidden="true"
72
- ?hidden=${!this.hasShortcut && this.shortcut === ''}
73
- >${this.renderShortcut()}</span
74
- ></span>
103
+ ${inside}
75
104
  </button>`;
76
105
  }
77
106
  renderShortcut() {
@@ -89,9 +118,21 @@ let HelixHeadlessSpeedDialItem = class HelixHeadlessSpeedDialItem extends HelixE
89
118
  __decorate([
90
119
  property({ reflect: true, type: Boolean })
91
120
  ], HelixHeadlessSpeedDialItem.prototype, "disabled", void 0);
121
+ __decorate([
122
+ property({ type: Boolean })
123
+ ], HelixHeadlessSpeedDialItem.prototype, "download", void 0);
124
+ __decorate([
125
+ property()
126
+ ], HelixHeadlessSpeedDialItem.prototype, "href", void 0);
127
+ __decorate([
128
+ property()
129
+ ], HelixHeadlessSpeedDialItem.prototype, "rel", void 0);
92
130
  __decorate([
93
131
  property()
94
132
  ], HelixHeadlessSpeedDialItem.prototype, "shortcut", void 0);
133
+ __decorate([
134
+ property()
135
+ ], HelixHeadlessSpeedDialItem.prototype, "target", void 0);
95
136
  __decorate([
96
137
  property()
97
138
  ], HelixHeadlessSpeedDialItem.prototype, "value", void 0);
@@ -25,8 +25,9 @@ declare const HelixHeadlessSpeedDial_base: (new (...args: any[]) => import("@fus
25
25
  * action whose name starts with it. The actions are one tab stop, and Tab
26
26
  * closes the dial and moves on.
27
27
  *
28
- * **Keyboard.** On the trigger, Enter, Space or a press opens the dial on its
29
- * first action. With the dial open:
28
+ * **Keyboard.** On the trigger, Enter, Space, Down or a press opens the dial
29
+ * on its first action, and Up opens it on its last, as an APG menu button
30
+ * does. With the dial open:
30
31
  *
31
32
  * | Key | What it does |
32
33
  * |---|---|
@@ -50,6 +51,13 @@ declare const HelixHeadlessSpeedDial_base: (new (...args: any[]) => import("@fus
50
51
  * and a slotted one alike. A press that has opened the dial does not also do
51
52
  * the trigger's action.
52
53
  *
54
+ * **Open on hover.** With `open-on="hover"`, a mouse resting on the trigger
55
+ * opens the dial, as Material's speed dial does, and the dial closes a moment
56
+ * after the mouse leaves both the trigger and the actions. A press, a touch
57
+ * and the keyboard open it as under `click`, and a press on the trigger of a
58
+ * dial already open moves focus to its first action, so the press a mouse
59
+ * makes after hovering keeps the dial open.
60
+ *
53
61
  * @since 0.1.0
54
62
  * @status stable
55
63
  * @category actions
@@ -57,6 +65,7 @@ declare const HelixHeadlessSpeedDial_base: (new (...args: any[]) => import("@fus
57
65
  * @documentation https://fusion.dev/helix/components/speed-dial
58
66
  *
59
67
  * @slot - The actions.
68
+ * @slot icon - Replaces the plus on the button the dial draws when no trigger is slotted, such as with a pencil for a dial of ways to write.
60
69
  * @slot trigger - The control that opens the actions. Without one, a button named by `label` is drawn.
61
70
  *
62
71
  * @event {HelixBeforeCloseEvent} helix-before-close - The actions are about to close. Cancelable: cancel it to keep them open.
@@ -94,11 +103,13 @@ export declare class HelixHeadlessSpeedDial extends HelixHeadlessSpeedDial_base
94
103
  */
95
104
  offset: number;
96
105
  /**
97
- * What opens the dial. `click` opens it on a press of the trigger. Under
98
- * `long-press`, a press is the trigger's own action, and the dial opens on a
99
- * press held for half a second, or on Alt+Down or Alt+Up.
106
+ * What opens the dial. `click` opens it on a press of the trigger. `hover`
107
+ * also opens it while a mouse rests on the trigger, and closes it once the
108
+ * mouse has left. Under `long-press`, a press is the trigger's own action,
109
+ * and the dial opens on a press held for half a second, or on Alt+Down or
110
+ * Alt+Up.
100
111
  */
101
- openOn: 'click' | 'long-press';
112
+ openOn: 'click' | 'hover' | 'long-press';
102
113
  /**
103
114
  * The direction the actions open in. They move to another side if that side
104
115
  * does not fit.
@@ -117,6 +128,10 @@ export declare class HelixHeadlessSpeedDial extends HelixHeadlessSpeedDial_base
117
128
  * wherever the reader is typing.
118
129
  */
119
130
  private focusOnOpen;
131
+ /** Whether the open under way puts focus on the last action, from Up. */
132
+ private focusLast;
133
+ /** The close a mouse leaving a hover dial has asked for, a moment from now. */
134
+ private leaving;
120
135
  protected readonly panel: PopoverController;
121
136
  /** Whatever the consumer slotted, or the button rendered in its place. */
122
137
  private get trigger();
@@ -128,6 +143,17 @@ export declare class HelixHeadlessSpeedDial extends HelixHeadlessSpeedDial_base
128
143
  /** Watches the trigger for a held press, under `open-on="long-press"`. */
129
144
  private readonly longPress;
130
145
  private get pressHeld();
146
+ /** Opens on a mouse coming over the trigger, under `open-on="hover"`. */
147
+ private readonly handleTriggerPointerOver;
148
+ /** Keeps a hover dial open while the mouse is back over it. */
149
+ private readonly handlePointerEnter;
150
+ /**
151
+ * Closes a hover dial a moment after the mouse leaves it, so a mouse
152
+ * crossing the gap between the trigger and the actions keeps it open.
153
+ * Focus inside the dial keeps it open too, as a reader is using it.
154
+ */
155
+ private readonly handlePointerLeave;
156
+ private stay;
131
157
  /**
132
158
  * Puts focus on the trigger, the slotted one or the button drawn in its
133
159
  * place, as a `focus()` on a native control would. The host itself takes no
@@ -31,6 +31,7 @@ let HelixHeadlessSpeedDial = class HelixHeadlessSpeedDial extends MixinControlle
31
31
  typeahead: typeaheadByWords(),
32
32
  });
33
33
  this.focusOnOpen = false;
34
+ this.focusLast = false;
34
35
  this.panel = new PopoverController(this, {
35
36
  anchor: () => this.trigger,
36
37
  boundary: () => this,
@@ -47,7 +48,35 @@ let HelixHeadlessSpeedDial = class HelixHeadlessSpeedDial extends MixinControlle
47
48
  }
48
49
  },
49
50
  });
51
+ this.handleTriggerPointerOver = (event) => {
52
+ if (this.openOn !== 'hover' || event.pointerType !== 'mouse') {
53
+ return;
54
+ }
55
+ this.stay();
56
+ if (!this.open) {
57
+ this.requestOpen(true);
58
+ }
59
+ };
60
+ this.handlePointerEnter = () => {
61
+ this.stay();
62
+ };
63
+ this.handlePointerLeave = (event) => {
64
+ if (this.openOn !== 'hover' || event.pointerType !== 'mouse' || !this.open) {
65
+ return;
66
+ }
67
+ this.stay();
68
+ this.leaving = setTimeout(() => {
69
+ this.leaving = undefined;
70
+ if (this.open && !this.matches(':focus-within')) {
71
+ this.requestOpen(false);
72
+ }
73
+ }, 300);
74
+ };
50
75
  this.handleTriggerClick = () => {
76
+ if (this.open && this.openOn === 'hover') {
77
+ this.items.find((one) => !one.disabled)?.focusItem();
78
+ return;
79
+ }
51
80
  if (this.open) {
52
81
  this.requestOpen(false);
53
82
  return;
@@ -91,11 +120,11 @@ let HelixHeadlessSpeedDial = class HelixHeadlessSpeedDial extends MixinControlle
91
120
  };
92
121
  this.handleKeydown = (event) => {
93
122
  if (!this.open) {
94
- if (this.pressHeld &&
95
- event.altKey &&
96
- (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
123
+ const arrow = event.key === 'ArrowDown' || event.key === 'ArrowUp';
124
+ if (arrow && (this.pressHeld ? event.altKey : !event.altKey)) {
97
125
  event.preventDefault();
98
126
  this.focusOnOpen = this.requestOpen(true);
127
+ this.focusLast = this.focusOnOpen && event.key === 'ArrowUp';
99
128
  }
100
129
  return;
101
130
  }
@@ -132,6 +161,10 @@ let HelixHeadlessSpeedDial = class HelixHeadlessSpeedDial extends MixinControlle
132
161
  get pressHeld() {
133
162
  return this.openOn === 'long-press';
134
163
  }
164
+ stay() {
165
+ clearTimeout(this.leaving);
166
+ this.leaving = undefined;
167
+ }
135
168
  focus(options) {
136
169
  const trigger = this.trigger;
137
170
  if (trigger === this) {
@@ -151,6 +184,8 @@ let HelixHeadlessSpeedDial = class HelixHeadlessSpeedDial extends MixinControlle
151
184
  this.addEventListener('helix-select', this.handleSelect);
152
185
  this.addEventListener('keydown', this.handleKeydown);
153
186
  this.addEventListener('click', this.handleHeldClick, true);
187
+ this.addEventListener('pointerenter', this.handlePointerEnter);
188
+ this.addEventListener('pointerleave', this.handlePointerLeave);
154
189
  }
155
190
  disconnectedCallback() {
156
191
  super.disconnectedCallback();
@@ -162,6 +197,9 @@ let HelixHeadlessSpeedDial = class HelixHeadlessSpeedDial extends MixinControlle
162
197
  this.removeEventListener('helix-select', this.handleSelect);
163
198
  this.removeEventListener('keydown', this.handleKeydown);
164
199
  this.removeEventListener('click', this.handleHeldClick, true);
200
+ this.removeEventListener('pointerenter', this.handlePointerEnter);
201
+ this.removeEventListener('pointerleave', this.handlePointerLeave);
202
+ this.stay();
165
203
  }
166
204
  announce() {
167
205
  const trigger = this.trigger;
@@ -191,11 +229,17 @@ let HelixHeadlessSpeedDial = class HelixHeadlessSpeedDial extends MixinControlle
191
229
  super.updated?.(changed);
192
230
  this.announce();
193
231
  this.describeTrigger();
232
+ if (!this.open) {
233
+ this.stay();
234
+ }
194
235
  if (this.open && this.focusOnOpen) {
195
236
  this.focusOnOpen = false;
237
+ const last = this.focusLast;
238
+ this.focusLast = false;
196
239
  this.updateComplete.then(() => {
197
240
  if (this.open) {
198
- this.items.find((one) => !one.disabled)?.focusItem();
241
+ const usable = this.items.filter((one) => !one.disabled);
242
+ (last ? usable.at(-1) : usable[0])?.focusItem();
199
243
  }
200
244
  });
201
245
  }
@@ -209,6 +253,7 @@ let HelixHeadlessSpeedDial = class HelixHeadlessSpeedDial extends MixinControlle
209
253
  @focusin=${this.handleTriggerFocusIn}
210
254
  @pointercancel=${this.handleTriggerPointerCancel}
211
255
  @pointerdown=${this.handleTriggerPointerDown}
256
+ @pointerover=${this.handleTriggerPointerOver}
212
257
  @pointermove=${this.handleTriggerPointerMove}
213
258
  @pointerup=${this.handleTriggerPointerUp}
214
259
  @slotchange=${() => this.describeTrigger()}
@@ -222,7 +267,7 @@ let HelixHeadlessSpeedDial = class HelixHeadlessSpeedDial extends MixinControlle
222
267
  aria-label=${this.label}
223
268
  >
224
269
  ${''}
225
- ${drawIcon(plus)}
270
+ <slot name="icon">${drawIcon(plus)}</slot>
226
271
  </button>
227
272
  ${held ? html `<span id="long-press" hidden>${this.longPressLabel}</span>` : nothing}
228
273
  </slot>`;