@fusion.dev/helix-headless 0.154.0 → 0.156.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 (104) hide show
  1. package/custom-elements.json +3213 -179
  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/breadcrumbs/breadcrumb/breadcrumb.d.ts +36 -3
  7. package/dist/breadcrumbs/breadcrumb/breadcrumb.js +42 -2
  8. package/dist/breadcrumbs/root/breadcrumbs-root.d.ts +15 -5
  9. package/dist/breadcrumbs/root/breadcrumbs-root.js +29 -4
  10. package/dist/button-group/root/button-group-root.d.ts +2 -0
  11. package/dist/button-group/root/button-group-root.js +1 -0
  12. package/dist/canvas/node/canvas-node.js +1 -1
  13. package/dist/canvas/root/canvas-root.d.ts +17 -2
  14. package/dist/canvas/root/canvas-root.js +23 -6
  15. package/dist/chat/composer/chat-composer.js +1 -1
  16. package/dist/chat/root/chat-root.d.ts +14 -1
  17. package/dist/chat/root/chat-root.js +14 -4
  18. package/dist/color-input/root/color-input-root.d.ts +9 -3
  19. package/dist/color-input/root/color-input-root.js +3 -1
  20. package/dist/color-picker/channels.d.ts +5 -3
  21. package/dist/color-picker/channels.js +79 -21
  22. package/dist/color-picker/root/color-picker-root.d.ts +58 -5
  23. package/dist/color-picker/root/color-picker-root.js +75 -13
  24. package/dist/combobox/root/combobox-root.d.ts +10 -0
  25. package/dist/combobox/root/combobox-root.js +18 -3
  26. package/dist/command/root/command-root.d.ts +116 -1
  27. package/dist/command/root/command-root.js +124 -15
  28. package/dist/data-table/root/data-table-root.d.ts +25 -0
  29. package/dist/data-table/root/data-table-root.js +13 -3
  30. package/dist/date-input/root/date-input-root.d.ts +33 -1
  31. package/dist/date-input/root/date-input-root.js +9 -2
  32. package/dist/date-picker/root/date-picker-root.d.ts +43 -13
  33. package/dist/date-picker/root/date-picker-root.js +54 -31
  34. package/dist/element-events.generated.js +3 -1
  35. package/dist/graph/root/graph-root.d.ts +13 -0
  36. package/dist/graph/root/graph-root.js +16 -0
  37. package/dist/list/root/list-root.d.ts +74 -1
  38. package/dist/list/root/list-root.js +92 -4
  39. package/dist/list/selection.js +3 -3
  40. package/dist/map/root/map-root.d.ts +6 -0
  41. package/dist/map/root/map-root.js +8 -1
  42. package/dist/navigation/root/navigation-root.d.ts +26 -0
  43. package/dist/navigation/root/navigation-root.js +58 -1
  44. package/dist/number-input/root/number-input-root.d.ts +63 -6
  45. package/dist/number-input/root/number-input-root.js +96 -11
  46. package/dist/object-inspector/root/object-inspector-root.d.ts +36 -1
  47. package/dist/object-inspector/root/object-inspector-root.js +26 -2
  48. package/dist/option/root/option-root.d.ts +32 -0
  49. package/dist/option/root/option-root.js +44 -5
  50. package/dist/outline/root/outline-root.d.ts +6 -2
  51. package/dist/outline/root/outline-root.js +7 -3
  52. package/dist/select/root/select-root.d.ts +29 -3
  53. package/dist/select/root/select-root.js +34 -9
  54. package/dist/slider/root/slider-root.d.ts +71 -9
  55. package/dist/slider/root/slider-root.js +129 -42
  56. package/dist/speed-dial/item/speed-dial-item.d.ts +29 -1
  57. package/dist/speed-dial/item/speed-dial-item.js +48 -7
  58. package/dist/speed-dial/root/speed-dial-root.d.ts +32 -6
  59. package/dist/speed-dial/root/speed-dial-root.js +50 -5
  60. package/dist/splitter/root/splitter-root.d.ts +22 -0
  61. package/dist/splitter/root/splitter-root.js +36 -7
  62. package/dist/steps/reach.d.ts +5 -1
  63. package/dist/steps/reach.js +2 -2
  64. package/dist/steps/root/steps-root.d.ts +37 -2
  65. package/dist/steps/root/steps-root.js +49 -7
  66. package/dist/steps/step/step.d.ts +6 -0
  67. package/dist/steps/step/step.js +4 -0
  68. package/dist/table/root/table-root.d.ts +20 -0
  69. package/dist/table/root/table-root.js +13 -3
  70. package/dist/tabs/root/tabs-root.d.ts +37 -0
  71. package/dist/tabs/root/tabs-root.js +92 -2
  72. package/dist/tabs/tab/tab.d.ts +38 -2
  73. package/dist/tabs/tab/tab.js +45 -4
  74. package/dist/tag/group/tag-group.d.ts +29 -3
  75. package/dist/tag/group/tag-group.js +42 -11
  76. package/dist/time-input/root/time-input-root.d.ts +32 -1
  77. package/dist/time-input/root/time-input-root.js +9 -2
  78. package/dist/time-picker/root/time-picker-root.d.ts +63 -1
  79. package/dist/time-picker/root/time-picker-root.js +58 -7
  80. package/dist/timeline/item/timeline-item.d.ts +25 -0
  81. package/dist/timeline/item/timeline-item.js +27 -2
  82. package/dist/toggle/root/toggle-root.d.ts +2 -0
  83. package/dist/toggle/root/toggle-root.js +1 -1
  84. package/dist/tree/item/tree-item.d.ts +10 -0
  85. package/dist/tree/item/tree-item.js +12 -0
  86. package/dist/tree/root/tree-root.d.ts +40 -1
  87. package/dist/tree/root/tree-root.js +120 -4
  88. package/dist/tree/selection.d.ts +6 -0
  89. package/dist/tree/selection.js +11 -0
  90. package/dist/video/root/keys.d.ts +6 -2
  91. package/dist/video/root/keys.js +18 -1
  92. package/dist/video/root/video-root.d.ts +60 -1
  93. package/dist/video/root/video-root.js +70 -2
  94. package/dist/video-playlist/item/video-playlist-item.d.ts +15 -1
  95. package/dist/video-playlist/item/video-playlist-item.js +7 -0
  96. package/dist/video-playlist/root/video-playlist-root.d.ts +43 -4
  97. package/dist/video-playlist/root/video-playlist-root.js +69 -2
  98. package/dist/virtual-list/root/virtual-list-root.d.ts +8 -0
  99. package/dist/virtual-list/root/virtual-list-root.js +3 -0
  100. package/lint.json +1 -1
  101. package/package.json +5 -5
  102. package/types/jsx.d.ts +3885 -383
  103. package/types/vue.d.ts +1045 -107
  104. package/types/words.d.ts +146 -1
@@ -35,6 +35,13 @@ declare const HelixHeadlessTree_base: (new (...args: any[]) => import("@fusion.d
35
35
  * or open, and the tree's one tab stop stays on the item last reached. In a
36
36
  * tree of check marks, Control+A (Command+A on an Apple keyboard) checks every
37
37
  * item or clears them all, and Shift with Up or Down Arrow moves and checks.
38
+ * Shift+Space checks every item from the one last checked to the focused one,
39
+ * and Control+Shift+Home or End checks every item from the focused one to the
40
+ * first or the last, then moves there, as the tree pattern's range keys do.
41
+ *
42
+ * A tree with no name of its own is announced as "Tree", which says nothing
43
+ * about what it holds, so a development build warns once with the kind
44
+ * `missing-name` where neither `label` nor an `aria-label` names it.
38
45
  *
39
46
  * Removing the item that has focus moves focus to the item after it among its
40
47
  * siblings, or the one before, or its parent, so focus stays in the tree.
@@ -50,7 +57,15 @@ declare const HelixHeadlessTree_base: (new (...args: any[]) => import("@fusion.d
50
57
  * `helix-before-reorder`, which you can refuse, and then `helix-reorder`, whose
51
58
  * `keys` hold the siblings in the new order. **You move the items**: the tree
52
59
  * moves no element of yours, and focus follows the moved item once you have.
53
- * Moving an item into another branch is not part of this.
60
+ *
61
+ * **Alt with the arrow along the line moves an item into another branch.**
62
+ * Alt with the arrow that opens a branch (Right on a left-to-right page) puts
63
+ * the focused item last inside the item before it, and opens that item; Alt
64
+ * with the other arrow puts it after its parent, a level out. The same
65
+ * `helix-before-reorder` and `helix-reorder` are sent, with `parent` naming
66
+ * the branch it moves into by its key, `''` for the top level, and `keys`
67
+ * holding that branch's items in their new order. The tree says the move with
68
+ * `moved-into-label`.
54
69
  *
55
70
  * @since 0.1.0
56
71
  * @status stable
@@ -92,6 +107,14 @@ export declare class HelixHeadlessTree extends HelixHeadlessTree_base {
92
107
  * wins over this.
93
108
  */
94
109
  label: string;
110
+ /**
111
+ * What screen readers hear when an item moves into another branch, in a
112
+ * `reorderable` tree. `{name}` is the item's words, `{parent}` the words of
113
+ * the branch it moved into, or the tree's `label` for the top level,
114
+ * `{position}` its place there, counting from 1, and `{count}` the number of
115
+ * items there.
116
+ */
117
+ movedIntoLabel: string;
95
118
  /**
96
119
  * What screen readers hear each time an item moves, in a `reorderable`
97
120
  * tree. `{name}` is the item's words, `{position}` its place among its
@@ -188,6 +211,8 @@ export declare class HelixHeadlessTree extends HelixHeadlessTree_base {
188
211
  private watcher;
189
212
  /** The item that last had focus, so its removal can hand focus on. */
190
213
  private focused;
214
+ /** The item last checked or unchecked, where a range Shift+Space checks starts. */
215
+ private anchor;
191
216
  private readonly handleFocusIn;
192
217
  /**
193
218
  * Puts focus back in the tree where the item that had it was removed and
@@ -253,6 +278,20 @@ export declare class HelixHeadlessTree extends HelixHeadlessTree_base {
253
278
  * Returns whether the press was one of those.
254
279
  */
255
280
  private handleMultiple;
281
+ /** Checks every item of a range, and keeps the ones already checked. */
282
+ private checkRange;
283
+ /**
284
+ * Moves the focused item into another branch, in a `reorderable` tree: last
285
+ * inside the item before it, `toward` the line's end, or after its parent,
286
+ * toward its start. Asks the page, which moves the item, and says where it
287
+ * went or that the move was refused. Returns whether the key moved anything.
288
+ */
289
+ private nest;
290
+ /**
291
+ * Gives focus back to a moved item once the page has moved it, where moving
292
+ * it sent focus to the body, as a move among siblings does.
293
+ */
294
+ private follow;
256
295
  private move;
257
296
  private focusOn;
258
297
  private readonly handleKeydown;
@@ -4,12 +4,12 @@ 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, closestInstance, commaList, customElement, deepActiveElement, HelixElement, inlineStep, keyClaimed, MixinCollection, MixinControllerActive, ReorderController, scrollItemIntoView, selectsAll, slottedWords, Typeahead, treeArrow, typesCharacter, whenHydrated, words, } from '@fusion.dev/helix-core';
7
+ import { AriaForwardController, auditName, closestInstance, commaList, customElement, deepActiveElement, formatWords, HelixElement, inlineStep, keyClaimed, MixinCollection, MixinControllerActive, ReorderController, requestReorder, scrollItemIntoView, selectsAll, slottedWords, Typeahead, treeArrow, typesCharacter, whenHydrated, words, } from '@fusion.dev/helix-core';
8
8
  import { html, nothing } from 'lit';
9
9
  import { property } from 'lit/decorators.js';
10
10
  import { fromGrip } from '../grip.js';
11
11
  import { HelixHeadlessTreeItem } from '../item/tree-item.js';
12
- import { checkedState, pressRow } from '../selection.js';
12
+ import { checkedState, checkRows, pressRow } from '../selection.js';
13
13
  const KEYS = new Set([
14
14
  'ArrowDown',
15
15
  'ArrowUp',
@@ -26,6 +26,7 @@ let HelixHeadlessTree = class HelixHeadlessTree extends MixinCollection(MixinCon
26
26
  this.canceledLabel = 'Canceled';
27
27
  this.droppedLabel = 'Dropped';
28
28
  this.label = 'Tree';
29
+ this.movedIntoLabel = '{name} moved into {parent}, position {position} of {count}';
29
30
  this.movedLabel = '{name} moved to position {position} of {count}';
30
31
  this.aria = new AriaForwardController(this, {
31
32
  label: true,
@@ -66,6 +67,7 @@ let HelixHeadlessTree = class HelixHeadlessTree extends MixinCollection(MixinCon
66
67
  this.requestActive(this.multiple
67
68
  ? pressRow(this.rowOf(origin), this.values, this.order).join(',')
68
69
  : event.detail.value);
70
+ this.anchor = origin;
69
71
  }
70
72
  this.syncItems();
71
73
  };
@@ -87,8 +89,15 @@ let HelixHeadlessTree = class HelixHeadlessTree extends MixinCollection(MixinCon
87
89
  }
88
90
  this.typeahead.reset();
89
91
  const along = inlineStep(this, event.key);
92
+ if (event.altKey && along !== 0 && this.reorderable) {
93
+ if (!current.disabled && this.nest(current, along)) {
94
+ event.preventDefault();
95
+ }
96
+ return;
97
+ }
90
98
  if (this.multiple && event.key === ' ') {
91
99
  current.select();
100
+ this.anchor = current;
92
101
  event.preventDefault();
93
102
  return;
94
103
  }
@@ -240,8 +249,18 @@ let HelixHeadlessTree = class HelixHeadlessTree extends MixinCollection(MixinCon
240
249
  const heads = this.nesting === 'heading' || this.selectionMode === 'leaf';
241
250
  const multiple = this.multiple;
242
251
  const checked = new Set(this.values);
252
+ const places = new Map();
253
+ for (const item of this.items) {
254
+ if (!places.has(item)) {
255
+ const siblings = this.siblingsOf(item);
256
+ siblings.forEach((one, at) => {
257
+ places.set(one, { position: at + 1, size: siblings.length });
258
+ });
259
+ }
260
+ }
243
261
  for (const item of this.items) {
244
262
  const key = this.keyOf(item);
263
+ const place = places.get(item);
245
264
  const selected = !multiple && item.value !== '' && item.value === this.active;
246
265
  const state = multiple ? checkedState(this.rowOf(item), checked) : false;
247
266
  const focusable = item === stop;
@@ -259,6 +278,8 @@ let HelixHeadlessTree = class HelixHeadlessTree extends MixinCollection(MixinCon
259
278
  item.checked = state;
260
279
  item.focusable = focusable;
261
280
  item.level = level;
281
+ item.position = place?.position;
282
+ item.setSize = place?.size;
262
283
  item.heads = heads;
263
284
  item.grip = this.reorderable;
264
285
  item.move = this.reorder.moveOf(key);
@@ -308,9 +329,24 @@ let HelixHeadlessTree = class HelixHeadlessTree extends MixinCollection(MixinCon
308
329
  this.syncItems();
309
330
  return true;
310
331
  }
332
+ const visible = this.visibleItems;
333
+ const at = visible.indexOf(current);
334
+ if (event.shiftKey && event.key === ' ') {
335
+ const from = this.anchor ? visible.indexOf(this.anchor) : -1;
336
+ const range = from < 0 ? [current] : visible.slice(Math.min(from, at), Math.max(from, at) + 1);
337
+ this.checkRange(range);
338
+ return true;
339
+ }
340
+ if (event.shiftKey &&
341
+ (event.ctrlKey || event.metaKey) &&
342
+ (event.key === 'Home' || event.key === 'End')) {
343
+ const range = event.key === 'Home' ? visible.slice(0, at + 1) : visible.slice(at);
344
+ this.checkRange(range);
345
+ this.focusOn(event.key === 'Home' ? range[0] : range.at(-1));
346
+ return true;
347
+ }
311
348
  if (event.shiftKey && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
312
- const visible = this.visibleItems;
313
- const next = visible[visible.indexOf(current) + (event.key === 'ArrowDown' ? 1 : -1)];
349
+ const next = visible[at + (event.key === 'ArrowDown' ? 1 : -1)];
314
350
  if (next) {
315
351
  this.focusOn(next);
316
352
  next.select();
@@ -319,6 +355,81 @@ let HelixHeadlessTree = class HelixHeadlessTree extends MixinCollection(MixinCon
319
355
  }
320
356
  return false;
321
357
  }
358
+ checkRange(range) {
359
+ this.requestActive(checkRows(range.map((item) => this.rowOf(item)), this.values, this.order).join(','));
360
+ this.syncItems();
361
+ }
362
+ nest(current, toward) {
363
+ const siblings = this.siblingsOf(current);
364
+ const from = siblings.indexOf(current);
365
+ const key = this.keyOf(current);
366
+ let into;
367
+ let keys;
368
+ if (toward > 0) {
369
+ const before = siblings[from - 1];
370
+ if (!before || before.disabled) {
371
+ return false;
372
+ }
373
+ into = before;
374
+ const inside = [...before.children].filter((node) => node instanceof HelixHeadlessTreeItem);
375
+ keys = [...inside.map((one) => this.keyOf(one)), key];
376
+ }
377
+ else {
378
+ const parent = closestInstance(current.parentElement, HelixHeadlessTreeItem);
379
+ if (!(parent instanceof HelixHeadlessTreeItem) || !this.items.includes(parent)) {
380
+ return false;
381
+ }
382
+ const above = closestInstance(parent.parentElement, HelixHeadlessTreeItem);
383
+ into =
384
+ above instanceof HelixHeadlessTreeItem && this.items.includes(above) ? above : undefined;
385
+ keys = this.siblingsOf(parent).map((one) => this.keyOf(one));
386
+ keys.splice(keys.indexOf(this.keyOf(parent)) + 1, 0, key);
387
+ }
388
+ const to = keys.indexOf(key);
389
+ const stood = requestReorder(this, {
390
+ from,
391
+ key,
392
+ keys,
393
+ kind: 'item',
394
+ parent: into ? this.keyOf(into) : '',
395
+ to,
396
+ value: keys.join(','),
397
+ });
398
+ const name = slottedWords(current, ['']) || key;
399
+ this.announced = stood
400
+ ? formatWords(this, 'movedIntoLabel', {
401
+ count: keys.length,
402
+ name,
403
+ parent: into ? slottedWords(into, ['']) || this.keyOf(into) : this.label,
404
+ position: to + 1,
405
+ })
406
+ : this.canceledLabel;
407
+ this.requestUpdate();
408
+ if (stood) {
409
+ if (into && !into.open) {
410
+ into.requestOpen(true);
411
+ }
412
+ this.follow(key);
413
+ }
414
+ return true;
415
+ }
416
+ follow(key) {
417
+ const lost = () => {
418
+ const active = deepActiveElement();
419
+ return !active || active === document.body;
420
+ };
421
+ if (lost()) {
422
+ this.focusOn(this.itemFor(key));
423
+ return;
424
+ }
425
+ const watch = new MutationObserver(() => {
426
+ watch.disconnect();
427
+ if (lost()) {
428
+ this.focusOn(this.itemFor(key));
429
+ }
430
+ });
431
+ watch.observe(this, { childList: true, subtree: true });
432
+ }
322
433
  move(from, delta) {
323
434
  const visible = this.visibleItems;
324
435
  const index = visible.indexOf(from);
@@ -359,6 +470,7 @@ let HelixHeadlessTree = class HelixHeadlessTree extends MixinCollection(MixinCon
359
470
  firstUpdated(changed) {
360
471
  super.firstUpdated(changed);
361
472
  this.syncItems();
473
+ auditName(this, () => null, 'Give it a `label` that says what it holds, such as "Files".', () => this.hasAttribute('label') || this.label !== 'Tree');
362
474
  }
363
475
  updated(changed) {
364
476
  super.updated(changed);
@@ -400,6 +512,10 @@ __decorate([
400
512
  words,
401
513
  property()
402
514
  ], HelixHeadlessTree.prototype, "label", void 0);
515
+ __decorate([
516
+ words,
517
+ property({ attribute: 'moved-into-label' })
518
+ ], HelixHeadlessTree.prototype, "movedIntoLabel", void 0);
403
519
  __decorate([
404
520
  words,
405
521
  property({ attribute: 'moved-label' })
@@ -14,6 +14,12 @@ export interface CheckableRow {
14
14
  * a branch by its leaves, `mixed` where some of them are.
15
15
  */
16
16
  export declare const checkedState: (row: CheckableRow, chosen: ReadonlySet<string>) => boolean | 'mixed';
17
+ /**
18
+ * The checked values after checking every row in `rows`, in `order`: a range
19
+ * the reader extends with Shift and Space, or with Control, Shift and Home or
20
+ * End. Rows already checked stay checked, and disabled rows are passed over.
21
+ */
22
+ export declare const checkRows: (rows: readonly CheckableRow[], chosen: readonly string[], order: readonly string[]) => string[];
17
23
  /**
18
24
  * The checked values after a press on `row`, in `order`.
19
25
  *
@@ -8,6 +8,17 @@ export const checkedState = (row, chosen) => {
8
8
  }
9
9
  return count === values.length ? true : 'mixed';
10
10
  };
11
+ export const checkRows = (rows, chosen, order) => {
12
+ const added = new Set(chosen);
13
+ for (const row of rows) {
14
+ if (!row.disabled) {
15
+ for (const value of valuesOf(row)) {
16
+ added.add(value);
17
+ }
18
+ }
19
+ }
20
+ return order.filter((value) => added.has(value));
21
+ };
11
22
  export const pressRow = (row, chosen, order) => {
12
23
  return row.disabled ? [...chosen] : toggleEvery(chosen, valuesOf(row), order);
13
24
  };
@@ -3,18 +3,22 @@
3
3
  *
4
4
  * Separable because the table is a convention rather than a fact about this
5
5
  * element: Space and `k` play, the arrows scrub and change the volume, `m`
6
- * mutes, `f` fills the screen, and `j` and `l` jump the distance the bar's own
7
- * buttons jump. What it needs is the media element and four actions, which is
6
+ * mutes, `f` fills the screen, `c` turns captions on and off, Home, End and
7
+ * the digits go to the start, the end and each tenth, and `j` and `l` jump the
8
+ * distance the bar's own buttons jump. What it needs is the media element and four actions, which is
8
9
  * the same handful the media session hands to a headset. Whether the keys are
9
10
  * bound at all, and what a taken press does to the event, stay with the element.
10
11
  */
11
12
  /** What a press asks the player for, which is all a key can do. */
12
13
  export interface KeyActions {
14
+ captions: () => void;
13
15
  fullscreen: () => void;
14
16
  seek: (seconds: number) => void;
15
17
  skipBy: (seconds: number) => void;
16
18
  toggle: () => void;
17
19
  }
20
+ /** The keys `takeKey` answers, as `aria-keyshortcuts` names them. */
21
+ export declare const PLAYER_KEYS = "Space K ArrowLeft ArrowRight Shift+ArrowLeft Shift+ArrowRight J L ArrowUp ArrowDown M F C Home End 0 1 2 3 4 5 6 7 8 9";
18
22
  /**
19
23
  * Answers one press, and says whether it was one of these.
20
24
  *
@@ -1,8 +1,9 @@
1
1
  import { SKIP_SECONDS } from '@fusion.dev/helix-core';
2
2
  const PRESSED_BY_SPACE = new Set(['a', 'button', 'select', 'summary', 'textarea']);
3
3
  const PRESSED_ROLES = /^(button|checkbox|link|menuitem\w*|option|radio|switch|tab)$/;
4
+ export const PLAYER_KEYS = 'Space K ArrowLeft ArrowRight Shift+ArrowLeft Shift+ArrowRight J L ArrowUp ArrowDown M F C Home End 0 1 2 3 4 5 6 7 8 9';
4
5
  export const takeKey = (event, video, actions) => {
5
- if (event.defaultPrevented) {
6
+ if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) {
6
7
  return false;
7
8
  }
8
9
  const origin = (event.composedPath?.()[0] ?? event.target);
@@ -54,7 +55,23 @@ export const takeKey = (event, video, actions) => {
54
55
  actions.fullscreen();
55
56
  break;
56
57
  }
58
+ case 'c': {
59
+ actions.captions();
60
+ break;
61
+ }
62
+ case 'Home': {
63
+ actions.seek(0);
64
+ break;
65
+ }
66
+ case 'End': {
67
+ actions.seek(video.duration);
68
+ break;
69
+ }
57
70
  default: {
71
+ if (/^[0-9]$/.test(event.key) && Number.isFinite(video.duration)) {
72
+ actions.seek((video.duration * Number(event.key)) / 10);
73
+ break;
74
+ }
58
75
  return false;
59
76
  }
60
77
  }
@@ -29,8 +29,16 @@ import '../../menu/index.js';
29
29
  * | Up, Down | Raises or lowers the volume by a tenth. |
30
30
  * | `m` | Mutes or unmutes. |
31
31
  * | `f` | Enters or leaves fullscreen. |
32
+ * | `c` | Turns captions on or off. |
33
+ * | Home, End | Goes to the start or the end. |
34
+ * | `0` to `9` | Goes to that tenth of the video: `5` is the middle. |
32
35
  * | Tab | Moves through the controls on the bar. |
33
36
  *
37
+ * A key with Control, Command, or Alt held is left to the browser. The
38
+ * player lists its keys in `aria-keyshortcuts`, and a live region says what a
39
+ * key changed that is not on the focused control: playing or paused, muted,
40
+ * the volume, captions on or off, and where the video is after a seek.
41
+ *
34
42
  * On the scrubber and the volume slider, the arrow keys, Page Up, Page Down,
35
43
  * Home, and End move the slider, as on any range input.
36
44
  *
@@ -125,7 +133,7 @@ import '../../menu/index.js';
125
133
  * @csspart forward - The button that skips forward ten seconds.
126
134
  * @csspart fullscreen - The button that enters and leaves fullscreen.
127
135
  * @csspart header - The region at the top left that holds the `header` slot.
128
- * @csspart live - A live region that repeats the loading text, so screen readers announce it when it changes. Visually hidden in the styled tier.
136
+ * @csspart live - A live region that says the loading text, and what a key changed, such as the volume. Visually hidden in the styled tier.
129
137
  * @csspart loading - The text shown while the video waits for more data. Draw the spinner
130
138
  * with `::part(loading)::before` in your stylesheet. This tier ships no CSS and renders
131
139
  * no extra box for it.
@@ -187,6 +195,10 @@ export declare class HelixHeadlessVideo extends HelixElement {
187
195
  * looking at.
188
196
  */
189
197
  autoplay: boolean;
198
+ /**
199
+ * What the live region says when `c` turns captions off.
200
+ */
201
+ captionsHiddenLabel: string;
190
202
  /**
191
203
  * The name of the captions control when the video has one track. The control
192
204
  * turns that track on and off.
@@ -201,6 +213,10 @@ export declare class HelixHeadlessVideo extends HelixElement {
201
213
  * tracks.
202
214
  */
203
215
  captionsOffLabel: string;
216
+ /**
217
+ * What the live region says when `c` turns captions on.
218
+ */
219
+ captionsShownLabel: string;
204
220
  /**
205
221
  * Whether the browser draws its native controls. When set, this element draws
206
222
  * no controls of its own.
@@ -326,6 +342,10 @@ export declare class HelixHeadlessVideo extends HelixElement {
326
342
  * What the button that opens `overflow-controls` is called.
327
343
  */
328
344
  moreLabel: string;
345
+ /**
346
+ * What the live region says when a key mutes the video.
347
+ */
348
+ mutedLabel: string;
329
349
  /**
330
350
  * The name of the mute control. The control shows as pressed while the video
331
351
  * is muted.
@@ -353,6 +373,10 @@ export declare class HelixHeadlessVideo extends HelixElement {
353
373
  * bar: two of the same control is worse than either placement.
354
374
  */
355
375
  overflowControls: string;
376
+ /**
377
+ * What the live region says when a key pauses the video.
378
+ */
379
+ pausedLabel: string;
356
380
  /**
357
381
  * The name of the play control while pressing it would pause the video.
358
382
  */
@@ -372,6 +396,10 @@ export declare class HelixHeadlessVideo extends HelixElement {
372
396
  * this is what a screen reader calls it on arriving.
373
397
  */
374
398
  playerLabel: string;
399
+ /**
400
+ * What the live region says when a key plays the video.
401
+ */
402
+ playingLabel: string;
375
403
  /**
376
404
  * The name of the play control while pressing it would start the video.
377
405
  */
@@ -453,6 +481,15 @@ export declare class HelixHeadlessVideo extends HelixElement {
453
481
  * `{duration}` filled in.
454
482
  */
455
483
  timeLabel: string;
484
+ /**
485
+ * What the live region says when a key unmutes the video.
486
+ */
487
+ unmutedLabel: string;
488
+ /**
489
+ * What the live region says when a key changes the volume. `{percent}` is the
490
+ * volume from 0 to 100, written in the locale.
491
+ */
492
+ volumeChangedLabel: string;
456
493
  /**
457
494
  * What the volume slider is called.
458
495
  */
@@ -773,6 +810,28 @@ export declare class HelixHeadlessVideo extends HelixElement {
773
810
  * key means and this says that nothing else gets it.
774
811
  */
775
812
  private readonly handleKeydown;
813
+ /**
814
+ * What a reader hears after a key, which changed something they may not be
815
+ * looking at: the focus is on the player or a control, and the bar may be
816
+ * hidden.
817
+ *
818
+ * **Said from the video's state after the press**, except for playing,
819
+ * which starts on a promise: whether the video was paused before the press
820
+ * says which way it went. Nothing for fullscreen, which a reader sees and a
821
+ * screen reader reports as the window changing, nor for `c` where there are
822
+ * no captions to turn on.
823
+ */
824
+ private sayKey;
825
+ /**
826
+ * What the live region says after a key, until the video waits for data,
827
+ * which it says over this.
828
+ */
829
+ private said;
830
+ /**
831
+ * Puts words in the live region. The same words twice differ by a space no
832
+ * reader hears, since a region whose text does not change is not read again.
833
+ */
834
+ private say;
776
835
  /** The caption tracks among the copies, named as the menu names them. */
777
836
  private captions;
778
837
  /**
@@ -13,7 +13,7 @@ import '../../menu/index.js';
13
13
  import { HelixHeadlessMenu } from '../../menu/root/menu-root.js';
14
14
  import { relayMedia } from '../media-events.js';
15
15
  import { barControls, menuControls, ownFile } from './extra-controls.js';
16
- import { takeKey } from './keys.js';
16
+ import { PLAYER_KEYS, takeKey } from './keys.js';
17
17
  const clock = (seconds) => {
18
18
  if (!Number.isFinite(seconds) || seconds < 0) {
19
19
  return '0:00';
@@ -31,8 +31,10 @@ let HelixHeadlessVideo = class HelixHeadlessVideo extends HelixElement {
31
31
  constructor() {
32
32
  super(...arguments);
33
33
  this.autoplay = false;
34
+ this.captionsHiddenLabel = 'Captions off';
34
35
  this.captionsLabel = 'Captions';
35
36
  this.captionsOffLabel = 'Off';
37
+ this.captionsShownLabel = 'Captions on';
36
38
  this.controls = false;
37
39
  this.crossOrigin = null;
38
40
  this.defaultMuted = false;
@@ -49,13 +51,16 @@ let HelixHeadlessVideo = class HelixHeadlessVideo extends HelixElement {
49
51
  this.loop = false;
50
52
  this.mediaTitle = '';
51
53
  this.moreLabel = 'More controls';
54
+ this.mutedLabel = 'Muted';
52
55
  this.muteLabel = 'Mute';
53
56
  this.nextLabel = 'Next';
54
57
  this.overflowControls = '';
58
+ this.pausedLabel = 'Paused';
55
59
  this.pauseLabel = 'Pause';
56
60
  this.percentLabel = '{percent} percent';
57
61
  this.pipLabel = 'Picture in picture';
58
62
  this.playerLabel = 'Video';
63
+ this.playingLabel = 'Playing';
59
64
  this.playLabel = 'Play';
60
65
  this.playsInline = true;
61
66
  this.poster = '';
@@ -69,6 +74,8 @@ let HelixHeadlessVideo = class HelixHeadlessVideo extends HelixElement {
69
74
  this.src = '';
70
75
  this.subtitlesLabel = 'Subtitles';
71
76
  this.timeLabel = '{position} of {duration}';
77
+ this.unmutedLabel = 'Unmuted';
78
+ this.volumeChangedLabel = 'Volume {percent} percent';
72
79
  this.volumeLabel = 'Volume';
73
80
  this.buffering = false;
74
81
  this.failed = false;
@@ -199,7 +206,9 @@ let HelixHeadlessVideo = class HelixHeadlessVideo extends HelixElement {
199
206
  if (event.composedPath().some((node) => node instanceof HelixHeadlessMenu)) {
200
207
  return;
201
208
  }
209
+ const paused = video.paused;
202
210
  const taken = takeKey(event, video, {
211
+ captions: () => this.toggleCaptions(),
203
212
  fullscreen: () => void this.fullscreen.toggle(),
204
213
  seek: (seconds) => this.seek(seconds),
205
214
  skipBy: (seconds) => this.skipBy(seconds),
@@ -207,8 +216,10 @@ let HelixHeadlessVideo = class HelixHeadlessVideo extends HelixElement {
207
216
  });
208
217
  if (taken) {
209
218
  event.preventDefault();
219
+ this.sayKey(event.key, paused, video);
210
220
  }
211
221
  };
222
+ this.said = '';
212
223
  this.captions = [];
213
224
  }
214
225
  static { this.words = 'helix-video'; }
@@ -414,6 +425,31 @@ let HelixHeadlessVideo = class HelixHeadlessVideo extends HelixElement {
414
425
  });
415
426
  }
416
427
  }
428
+ sayKey(key, paused, video) {
429
+ if (key === ' ' || key === 'k') {
430
+ this.say(paused ? this.playingLabel : this.pausedLabel);
431
+ }
432
+ else if (key === 'm') {
433
+ this.say(video.muted ? this.mutedLabel : this.unmutedLabel);
434
+ }
435
+ else if (key === 'ArrowUp' || key === 'ArrowDown') {
436
+ this.say(formatWords(this, 'volumeChangedLabel', { percent: Math.round(video.volume * 100) }));
437
+ }
438
+ else if (key === 'c') {
439
+ if (this.captions.length > 0) {
440
+ this.say(this.captioned ? this.captionsShownLabel : this.captionsHiddenLabel);
441
+ }
442
+ }
443
+ else if (key !== 'f') {
444
+ this.say(formatWords(this, 'timeLabel', {
445
+ duration: clock(video.duration),
446
+ position: clock(video.currentTime),
447
+ }));
448
+ }
449
+ }
450
+ say(words) {
451
+ this.said = words !== '' && words === this.said ? `${words}\u00a0` : words;
452
+ }
417
453
  willUpdate(changed) {
418
454
  super.willUpdate(changed);
419
455
  if (!this.mediaRead && this.childrenKnown) {
@@ -820,6 +856,7 @@ let HelixHeadlessVideo = class HelixHeadlessVideo extends HelixElement {
820
856
  tabindex=${this.controls ? nothing : '0'}
821
857
  role=${this.controls ? nothing : 'group'}
822
858
  aria-label=${this.controls ? nothing : this.label || this.playerLabel}
859
+ aria-keyshortcuts=${this.controls ? nothing : PLAYER_KEYS}
823
860
  @keydown=${this.handleKeydown}
824
861
  @pointermove=${this.idler.stir}
825
862
  @pointerleave=${this.idler.leave}
@@ -883,7 +920,7 @@ let HelixHeadlessVideo = class HelixHeadlessVideo extends HelixElement {
883
920
  ? html `<div part="loading">${this.loadingLabel}</div>`
884
921
  : nothing}
885
922
  ${''}
886
- <span part="live" role="status">${this.buffering && !this.failed ? this.loadingLabel : ''}</span>
923
+ <span part="live" role="status">${this.buffering && !this.failed ? this.loadingLabel : this.said}</span>
887
924
  ${this.failed
888
925
  ? html `<div
889
926
  part="failure"
@@ -900,6 +937,10 @@ let HelixHeadlessVideo = class HelixHeadlessVideo extends HelixElement {
900
937
  __decorate([
901
938
  property({ reflect: true, type: Boolean })
902
939
  ], HelixHeadlessVideo.prototype, "autoplay", void 0);
940
+ __decorate([
941
+ words,
942
+ property({ attribute: 'captions-hidden-label' })
943
+ ], HelixHeadlessVideo.prototype, "captionsHiddenLabel", void 0);
903
944
  __decorate([
904
945
  words,
905
946
  property({ attribute: 'captions-label' })
@@ -908,6 +949,10 @@ __decorate([
908
949
  words,
909
950
  property({ attribute: 'captions-off-label' })
910
951
  ], HelixHeadlessVideo.prototype, "captionsOffLabel", void 0);
952
+ __decorate([
953
+ words,
954
+ property({ attribute: 'captions-shown-label' })
955
+ ], HelixHeadlessVideo.prototype, "captionsShownLabel", void 0);
911
956
  __decorate([
912
957
  property({ reflect: true, type: Boolean })
913
958
  ], HelixHeadlessVideo.prototype, "controls", void 0);
@@ -962,6 +1007,10 @@ __decorate([
962
1007
  words,
963
1008
  property({ attribute: 'more-label' })
964
1009
  ], HelixHeadlessVideo.prototype, "moreLabel", void 0);
1010
+ __decorate([
1011
+ words,
1012
+ property({ attribute: 'muted-label' })
1013
+ ], HelixHeadlessVideo.prototype, "mutedLabel", void 0);
965
1014
  __decorate([
966
1015
  words,
967
1016
  property({ attribute: 'mute-label' })
@@ -973,6 +1022,10 @@ __decorate([
973
1022
  __decorate([
974
1023
  property({ attribute: 'overflow-controls' })
975
1024
  ], HelixHeadlessVideo.prototype, "overflowControls", void 0);
1025
+ __decorate([
1026
+ words,
1027
+ property({ attribute: 'paused-label' })
1028
+ ], HelixHeadlessVideo.prototype, "pausedLabel", void 0);
976
1029
  __decorate([
977
1030
  words,
978
1031
  property({ attribute: 'pause-label' })
@@ -989,6 +1042,10 @@ __decorate([
989
1042
  words,
990
1043
  property({ attribute: 'player-label' })
991
1044
  ], HelixHeadlessVideo.prototype, "playerLabel", void 0);
1045
+ __decorate([
1046
+ words,
1047
+ property({ attribute: 'playing-label' })
1048
+ ], HelixHeadlessVideo.prototype, "playingLabel", void 0);
992
1049
  __decorate([
993
1050
  words,
994
1051
  property({ attribute: 'play-label' })
@@ -1039,6 +1096,14 @@ __decorate([
1039
1096
  words,
1040
1097
  property({ attribute: 'time-label' })
1041
1098
  ], HelixHeadlessVideo.prototype, "timeLabel", void 0);
1099
+ __decorate([
1100
+ words,
1101
+ property({ attribute: 'unmuted-label' })
1102
+ ], HelixHeadlessVideo.prototype, "unmutedLabel", void 0);
1103
+ __decorate([
1104
+ words,
1105
+ property({ attribute: 'volume-changed-label' })
1106
+ ], HelixHeadlessVideo.prototype, "volumeChangedLabel", void 0);
1042
1107
  __decorate([
1043
1108
  words,
1044
1109
  property({ attribute: 'volume-label' })
@@ -1100,6 +1165,9 @@ __decorate([
1100
1165
  __decorate([
1101
1166
  state()
1102
1167
  ], HelixHeadlessVideo.prototype, "media", void 0);
1168
+ __decorate([
1169
+ state()
1170
+ ], HelixHeadlessVideo.prototype, "said", void 0);
1103
1171
  HelixHeadlessVideo = __decorate([
1104
1172
  customElement('helix-headless-video')
1105
1173
  ], HelixHeadlessVideo);