@fusion.dev/helix-headless 0.215.3 → 0.216.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 (113) hide show
  1. package/api.json +1 -1
  2. package/custom-elements.json +1 -1
  3. package/dist/area-chart/root/area-chart-root.d.ts +1 -0
  4. package/dist/area-chart/root/area-chart-root.js +1 -1
  5. package/dist/bar-chart/root/bar-chart-root.d.ts +1 -0
  6. package/dist/bar-chart/root/bar-chart-root.js +1 -1
  7. package/dist/button/root/button-root.d.ts +3 -1
  8. package/dist/button/root/button-root.js +2 -1
  9. package/dist/chat/composer/chat-composer.d.ts +18 -1
  10. package/dist/chat/composer/chat-composer.js +8 -3
  11. package/dist/checkbox/root/checkbox-root.d.ts +3 -1
  12. package/dist/checkbox/root/checkbox-root.js +2 -1
  13. package/dist/code-block/highlight.d.ts +9 -0
  14. package/dist/code-block/highlight.js +39 -0
  15. package/dist/code-block/highlighter.d.ts +44 -0
  16. package/dist/code-block/highlighter.js +44 -0
  17. package/dist/code-block/index.d.ts +1 -0
  18. package/dist/code-block/index.js +1 -0
  19. package/dist/code-block/keyed-lines.d.ts +23 -0
  20. package/dist/code-block/keyed-lines.js +75 -0
  21. package/dist/code-block/root/code-block-root.d.ts +6 -23
  22. package/dist/code-block/root/code-block-root.js +8 -25
  23. package/dist/code-editor/index.d.ts +1 -0
  24. package/dist/code-editor/index.js +1 -0
  25. package/dist/code-editor/root/code-editor-root.d.ts +367 -0
  26. package/dist/code-editor/root/code-editor-root.js +746 -0
  27. package/dist/code-input/index.d.ts +1 -0
  28. package/dist/code-input/index.js +1 -0
  29. package/dist/code-input/root/code-input-root.d.ts +119 -0
  30. package/dist/code-input/root/code-input-root.js +142 -0
  31. package/dist/combobox/root/combobox-root.d.ts +6 -1
  32. package/dist/combobox/root/combobox-root.js +4 -2
  33. package/dist/command/root/command-root.d.ts +61 -11
  34. package/dist/command/root/command-root.js +88 -3
  35. package/dist/donut-chart/root/donut-chart-root.d.ts +1 -0
  36. package/dist/donut-chart/root/donut-chart-root.js +1 -1
  37. package/dist/element-events.generated.js +36 -3
  38. package/dist/filter-input/root/filter-input-root.d.ts +9 -1
  39. package/dist/filter-input/root/filter-input-root.js +7 -3
  40. package/dist/heatmap/root/heatmap-root.d.ts +1 -0
  41. package/dist/heatmap/root/heatmap-root.js +1 -1
  42. package/dist/index.d.ts +4 -0
  43. package/dist/index.js +4 -0
  44. package/dist/input/root/input-root.d.ts +14 -2
  45. package/dist/input/root/input-root.js +11 -3
  46. package/dist/kbd/root/kbd-root.js +9 -2
  47. package/dist/line-chart/root/line-chart-root.d.ts +1 -0
  48. package/dist/line-chart/root/line-chart-root.js +1 -1
  49. package/dist/listbox/root/listbox-root.d.ts +8 -1
  50. package/dist/listbox/root/listbox-root.js +7 -4
  51. package/dist/markdown/blocks.d.ts +66 -0
  52. package/dist/markdown/blocks.js +326 -0
  53. package/dist/markdown/index.d.ts +6 -0
  54. package/dist/markdown/index.js +5 -0
  55. package/dist/markdown/inline.d.ts +57 -0
  56. package/dist/markdown/inline.js +483 -0
  57. package/dist/markdown/root/markdown-root.d.ts +99 -0
  58. package/dist/markdown/root/markdown-root.js +93 -0
  59. package/dist/markdown/stream.d.ts +47 -0
  60. package/dist/markdown/stream.js +62 -0
  61. package/dist/markdown/tree.d.ts +57 -0
  62. package/dist/markdown/tree.js +144 -0
  63. package/dist/markdown/writer.d.ts +33 -0
  64. package/dist/markdown/writer.js +139 -0
  65. package/dist/menu/root/menu-root.d.ts +53 -0
  66. package/dist/menu/root/menu-root.js +92 -15
  67. package/dist/pagination/size/pagination-size.d.ts +3 -0
  68. package/dist/pagination/size/pagination-size.js +2 -1
  69. package/dist/scatter-chart/root/scatter-chart-root.d.ts +1 -0
  70. package/dist/scatter-chart/root/scatter-chart-root.js +1 -1
  71. package/dist/select/root/select-root.d.ts +6 -1
  72. package/dist/select/root/select-root.js +4 -2
  73. package/dist/shortcut-overlay/index.d.ts +1 -0
  74. package/dist/shortcut-overlay/index.js +1 -0
  75. package/dist/shortcut-overlay/root/shortcut-overlay-root.d.ts +128 -0
  76. package/dist/shortcut-overlay/root/shortcut-overlay-root.js +275 -0
  77. package/dist/slider/root/slider-root.d.ts +3 -1
  78. package/dist/slider/root/slider-root.js +2 -1
  79. package/dist/spatial-bar-chart/root/spatial-bar-chart-root.d.ts +1 -0
  80. package/dist/spatial-bar-chart/root/spatial-bar-chart-root.js +1 -1
  81. package/dist/spatial-heatmap/root/spatial-heatmap-root.d.ts +1 -0
  82. package/dist/spatial-heatmap/root/spatial-heatmap-root.js +1 -1
  83. package/dist/spatial-ribbon-chart/root/spatial-ribbon-chart-root.d.ts +1 -0
  84. package/dist/spatial-ribbon-chart/root/spatial-ribbon-chart-root.js +1 -1
  85. package/dist/spatial-scatter-chart/root/spatial-scatter-chart-root.d.ts +1 -0
  86. package/dist/spatial-scatter-chart/root/spatial-scatter-chart-root.js +1 -1
  87. package/dist/spatial-surface-chart/root/spatial-surface-chart-root.d.ts +1 -0
  88. package/dist/spatial-surface-chart/root/spatial-surface-chart-root.js +1 -1
  89. package/dist/spatial-treemap/root/spatial-treemap-root.d.ts +1 -0
  90. package/dist/spatial-treemap/root/spatial-treemap-root.js +1 -1
  91. package/dist/switch/root/switch-root.d.ts +3 -1
  92. package/dist/switch/root/switch-root.js +2 -1
  93. package/dist/tabs/tab/tab.d.ts +3 -1
  94. package/dist/tabs/tab/tab.js +2 -1
  95. package/dist/textarea/root/textarea-root.d.ts +20 -4
  96. package/dist/textarea/root/textarea-root.js +31 -23
  97. package/dist/time-input/root/time-input-root.d.ts +6 -0
  98. package/dist/time-input/root/time-input-root.js +4 -2
  99. package/dist/time-picker/root/time-picker-root.d.ts +6 -0
  100. package/dist/time-picker/root/time-picker-root.js +4 -2
  101. package/dist/toast/root/toast-root.d.ts +64 -0
  102. package/dist/toast/root/toast-root.js +128 -3
  103. package/dist/toggle/root/toggle-root.d.ts +3 -1
  104. package/dist/toggle/root/toggle-root.js +2 -1
  105. package/dist/virtual-list/root/virtual-list-root.d.ts +18 -2
  106. package/dist/virtual-list/root/virtual-list-root.js +103 -5
  107. package/examples.json +1 -1
  108. package/lint.json +1 -1
  109. package/package.json +6 -6
  110. package/types/jsx.d.ts +5315 -596
  111. package/types/svelte.d.ts +4 -0
  112. package/types/vue.d.ts +1085 -4
  113. package/types/words.d.ts +491 -0
@@ -106,6 +106,9 @@ declare const HelixHeadlessTimeInput_base: (new (...args: any[]) => import("@fus
106
106
  * @csspart picker-column-loading - The text shown while the options load.
107
107
  * @csspart picker-column-search - The region that holds the filter field.
108
108
  * @csspart picker-column-search-field - The filter field.
109
+ * @csspart picker-column-voice - The dictation button in the filter field.
110
+ * @csspart picker-column-voice-listening - The dictation button while it listens.
111
+ * @csspart picker-column-voice-status - What a screen reader hears as dictation starts and stops.
109
112
  * @csspart picker-columns - The row of columns, in `wheels`.
110
113
  * @csspart picker-feedback - The picker's own message, under the times, while the picker's `feedback` is set.
111
114
  * @csspart picker-hint - The picker's hint, which Time Input never sets: help under the field is the field's own `hint`.
@@ -123,6 +126,9 @@ declare const HelixHeadlessTimeInput_base: (new (...args: any[]) => import("@fus
123
126
  * @csspart picker-list-loading - The text shown while the options load.
124
127
  * @csspart picker-list-search - The region that holds the filter field.
125
128
  * @csspart picker-list-search-field - The filter field.
129
+ * @csspart picker-list-voice - The dictation button in the filter field.
130
+ * @csspart picker-list-voice-listening - The dictation button while it listens.
131
+ * @csspart picker-list-voice-status - What a screen reader hears as dictation starts and stops.
126
132
  * @csspart picker-now - The picker's button that chooses the current time, which Time Input does not draw.
127
133
  * @csspart picker-option - One time in the picker. Carries `data-between` inside a range.
128
134
  * @csspart picker-option-between - A time inside a chosen range, between its two ends. Set together with `picker-option`.
@@ -227,13 +227,15 @@ let HelixHeadlessTimeInput = class HelixHeadlessTimeInput extends MixinSegmented
227
227
  column-filter-mark: picker-column-filter-mark, column-hint: picker-column-hint,
228
228
  column-items: picker-column-items, column-label: picker-column-label,
229
229
  column-live: picker-column-live, column-loading: picker-column-loading, column-search: picker-column-search,
230
- column-search-field: picker-column-search-field, columns: picker-columns, feedback: picker-feedback, hint: picker-hint,
230
+ column-search-field: picker-column-search-field, column-voice: picker-column-voice,
231
+ column-voice-listening: picker-column-voice-listening, column-voice-status: picker-column-voice-status, columns: picker-columns, feedback: picker-feedback, hint: picker-hint,
231
232
  label: picker-label, list: picker-list,
232
233
  list-empty: picker-list-empty, list-error: picker-list-error, list-feedback: picker-list-feedback,
233
234
  list-filter-icon: picker-list-filter-icon, list-filter-mark: picker-list-filter-mark,
234
235
  list-hint: picker-list-hint, list-items: picker-list-items, list-label: picker-list-label,
235
236
  list-live: picker-list-live, list-loading: picker-list-loading, list-search: picker-list-search,
236
- list-search-field: picker-list-search-field, now: picker-now, option: picker-option,
237
+ list-search-field: picker-list-search-field, list-voice: picker-list-voice,
238
+ list-voice-listening: picker-list-voice-listening, list-voice-status: picker-list-voice-status, now: picker-now, option: picker-option,
237
239
  option-between: picker-option-between, option-item-active: picker-option-item-active,
238
240
  option-item-selected: picker-option-item-selected,
239
241
  option-description: picker-option-description, option-item: picker-option-item,
@@ -88,6 +88,9 @@ declare const HelixHeadlessTimePicker_base: (new (...args: any[]) => import("@fu
88
88
  * @csspart column-loading - The text shown while the options load.
89
89
  * @csspart column-search - The region that holds the filter field.
90
90
  * @csspart column-search-field - The filter field.
91
+ * @csspart column-voice - The dictation button in the filter field.
92
+ * @csspart column-voice-listening - The dictation button while it listens.
93
+ * @csspart column-voice-status - What a screen reader hears as dictation starts and stops.
91
94
  * @csspart columns - The row of columns, in `wheels`.
92
95
  * @csspart feedback - The message under the times, while `feedback` is set.
93
96
  * @csspart hint - Help under the times, shown while there is no message in `feedback`.
@@ -105,6 +108,9 @@ declare const HelixHeadlessTimePicker_base: (new (...args: any[]) => import("@fu
105
108
  * @csspart list-loading - The text shown while the options load.
106
109
  * @csspart list-search - The region that holds the filter field.
107
110
  * @csspart list-search-field - The filter field.
111
+ * @csspart list-voice - The dictation button in the filter field.
112
+ * @csspart list-voice-listening - The dictation button while it listens.
113
+ * @csspart list-voice-status - What a screen reader hears as dictation starts and stops.
108
114
  * @csspart now - The button that chooses the current time, drawn with `show-now`.
109
115
  * @csspart option - One time in the list. It carries `data-between` inside a range, and inside the run to the time under the pointer while a range waits for its second end.
110
116
  * @csspart option-between - A time inside a chosen range, excluding its ends, beside `option`.
@@ -406,7 +406,8 @@ let HelixHeadlessTimePicker = class HelixHeadlessTimePicker extends MixinPickerF
406
406
  aria-required=${this.required ? 'true' : nothing}
407
407
  exportparts="empty: column-empty, error: column-error, feedback: column-feedback, filter-icon: column-filter-icon,
408
408
  filter-mark: column-filter-mark, hint: column-hint, items: column-items, label: column-label,
409
- live: column-live, loading: column-loading, search: column-search, search-field: column-search-field"
409
+ live: column-live, loading: column-loading, search: column-search, search-field: column-search-field,
410
+ voice: column-voice, voice-listening: column-voice-listening, voice-status: column-voice-status"
410
411
  aria-label=${label}
411
412
  ?disabled=${this.isDisabled}
412
413
  .value=${value}
@@ -459,7 +460,8 @@ let HelixHeadlessTimePicker = class HelixHeadlessTimePicker extends MixinPickerF
459
460
  aria-required=${this.required ? 'true' : nothing}
460
461
  exportparts="empty: list-empty, error: list-error, feedback: list-feedback, filter-icon: list-filter-icon,
461
462
  filter-mark: list-filter-mark, hint: list-hint, items: list-items, label: list-label,
462
- live: list-live, loading: list-loading, search: list-search, search-field: list-search-field"
463
+ live: list-live, loading: list-loading, search: list-search, search-field: list-search-field,
464
+ voice: list-voice, voice-listening: list-voice-listening, voice-status: list-voice-status"
463
465
  aria-label=${this.listName}
464
466
  data-unset=${this.value === '' ? '' : nothing}
465
467
  ?disabled=${this.isDisabled}
@@ -1,5 +1,6 @@
1
1
  import { HelixElement } from '@fusion.dev/helix-core';
2
2
  import { type PropertyValues, type TemplateResult } from 'lit';
3
+ import '../../button/index.js';
3
4
  /**
4
5
  * @summary The region that holds toast messages, and the only element that announces them.
5
6
  *
@@ -37,6 +38,13 @@ import { type PropertyValues, type TemplateResult } from 'lit';
37
38
  * or a pen swipes a message away toward the end of the line, or toward the
38
39
  * edge `swipe-direction` names.
39
40
  *
41
+ * **`undoable()` offers to take an action back**, which replaces a
42
+ * confirmation dialog for an action that can be undone: do the action, then
43
+ * call it. The message holds an Undo button and shows for 8 seconds, Command+Z
44
+ * (Control+Z elsewhere) undoes it while it shows, and a screen reader hears
45
+ * which keys do. Keep a confirmation for an action that cannot be taken back or
46
+ * that reaches someone else, such as sending or paying.
47
+ *
40
48
  * **Every message's timer waits while a pointer or focus is in the stack**, as
41
49
  * React Aria's and Radix's regions do, so a reader moving from one message to
42
50
  * the next does not lose the one behind them.
@@ -63,6 +71,7 @@ import { type PropertyValues, type TemplateResult } from 'lit';
63
71
  *
64
72
  * | Key | What it does |
65
73
  * |---|---|
74
+ * | Command+Z, or Control+Z elsewhere, or your `undo-keys` | Undoes the newest message `undoable()` made, while it shows, while focus is not in a text field, and where the app has not handled the key. |
66
75
  * | F8, or your `hotkey` | Moves focus to the first control in the stack, and back to where it was when pressed in the stack. |
67
76
  * | Tab, Shift+Tab | Moves between the controls of the messages. |
68
77
  * | Escape | Dismisses the message holding focus. Focus moves to the next message, then the one before it, then back to where it was. |
@@ -94,6 +103,8 @@ export declare class HelixHeadlessToast extends HelixElement {
94
103
  * Empty turns it off.
95
104
  */
96
105
  hotkey: string;
106
+ /** What the hotkey does, as a list of the page's shortcuts says it. */
107
+ hotkeyLabel: string;
97
108
  /**
98
109
  * Accessible name for the region, with `{hotkey}` filled in. Translate it with
99
110
  * the rest of your interface.
@@ -173,6 +184,57 @@ export declare class HelixHeadlessToast extends HelixElement {
173
184
  private readonly scope;
174
185
  /** Takes focus to the stack and back, on `hotkey`. */
175
186
  private readonly handleHotkey;
187
+ /**
188
+ * What a message `undoable()` made says after its own words, with `{keys}`
189
+ * for the chord that undoes it, spoken as words.
190
+ */
191
+ undoHint: string;
192
+ /**
193
+ * The keys that undo the newest message `undoable()` made, as `hotkey`
194
+ * reads them. Empty turns them off, for an app whose own undo holds them,
195
+ * and leaves the Undo button. A key the app already handled, by calling
196
+ * `preventDefault()`, is left to it.
197
+ */
198
+ undoKeys: string;
199
+ /** The words on the Undo button of a message `undoable()` made. */
200
+ undoLabel: string;
201
+ /** The words said after each message `undoable()` made: the key that undoes it. */
202
+ private readonly hints;
203
+ /** The undoable messages showing, oldest first, each with what undoes it and what keeps it. */
204
+ private readonly undoables;
205
+ /**
206
+ * Shows that an action happened and offers to undo it, which is the pattern
207
+ * that replaces a confirmation dialog for an action that can be taken back.
208
+ *
209
+ * Do the action first, then call this. The message holds an Undo button,
210
+ * shows for `duration` (8 seconds by default, and paused while a pointer or
211
+ * focus is in the stack), and `undo-keys`, Command+Z or Control+Z by
212
+ * default, does what the button does while it shows, unless a text field
213
+ * has focus. A screen reader hears the message and then which keys undo it.
214
+ *
215
+ * `undo` runs if the reader undoes, and `commit` runs if the message closes
216
+ * any other way, for an app that holds the real deletion back until then.
217
+ * The promise settles to `undone` or `kept` accordingly.
218
+ *
219
+ * **A message that is never closed is kept.** `commit` runs as the region
220
+ * leaves the page, as on a route change, and as the page itself is hidden
221
+ * to be closed or reloaded. A browser can end a page without either, so a
222
+ * server holding a deletion back finishes it on a timeout of its own too.
223
+ */
224
+ undoable(message: string, options: {
225
+ commit?: () => void;
226
+ duration?: number;
227
+ heading?: string;
228
+ tone?: 'danger' | 'info' | 'success' | 'warning';
229
+ undo: () => void;
230
+ }): Promise<'kept' | 'undone'>;
231
+ /** Keeps every undoable message still showing, as the region or the page goes. */
232
+ private keepAll;
233
+ private readonly handlePageHide;
234
+ /** Undoes the newest undoable message on `undo-keys`, unless a field or the app has the keystroke. */
235
+ private readonly handleUndo;
236
+ /** What a message says as it arrives, with the key that undoes it where it has one. */
237
+ private wordsOf;
176
238
  /** Messages `add()` made, which are the region's to remove. */
177
239
  private readonly made;
178
240
  /**
@@ -267,6 +329,8 @@ export declare class HelixHeadlessToast extends HelixElement {
267
329
  */
268
330
  private readonly handleDismiss;
269
331
  connectedCallback(): void;
332
+ /** Takes the hotkey off the page's list of shortcuts. */
333
+ private unregister;
270
334
  disconnectedCallback(): void;
271
335
  protected firstUpdated(changed: PropertyValues): void;
272
336
  protected updated(changed: PropertyValues): void;
@@ -1,12 +1,14 @@
1
1
  import { __decorate } from "tslib";
2
- import { animationsFinished, customElement, DelayController, deepActiveElement, deepContains, FocusScopeController, focusableElements, formatWords, HelixElement, hostAria, matchesChord, numberConverter, onApple, scrollHolders, words, } from '@fusion.dev/helix-core';
2
+ import { animationsFinished, ariaChord, chordParts, customElement, DelayController, deepActiveElement, deepContains, FocusScopeController, focusableElements, formatWords, HelixElement, hostAria, keyClaimed, keyNames, matchesChord, numberConverter, onApple, registerShortcut, scrollHolders, words, } from '@fusion.dev/helix-core';
3
3
  import { html } from 'lit';
4
4
  import { property, state } from 'lit/decorators.js';
5
+ import '../../button/index.js';
5
6
  import { joinSaid, messageWords } from '../words.js';
6
7
  let HelixHeadlessToast = class HelixHeadlessToast extends HelixElement {
7
8
  constructor() {
8
9
  super(...arguments);
9
10
  this.hotkey = 'F8';
11
+ this.hotkeyLabel = 'Go to notifications';
10
12
  this.label = 'Notifications ({hotkey})';
11
13
  this.said = '';
12
14
  this.urgent = '';
@@ -34,6 +36,26 @@ let HelixHeadlessToast = class HelixHeadlessToast extends HelixElement {
34
36
  this.scope.enter();
35
37
  }
36
38
  };
39
+ this.undoHint = 'Press {keys} to undo.';
40
+ this.undoKeys = 'mod+z';
41
+ this.undoLabel = 'Undo';
42
+ this.hints = new WeakMap();
43
+ this.undoables = [];
44
+ this.handlePageHide = () => {
45
+ this.keepAll();
46
+ };
47
+ this.handleUndo = (event) => {
48
+ const newest = this.undoables.at(-1);
49
+ if (!newest ||
50
+ this.undoKeys === '' ||
51
+ event.defaultPrevented ||
52
+ keyClaimed(event) ||
53
+ !matchesChord(event, this.undoKeys, onApple())) {
54
+ return;
55
+ }
56
+ event.preventDefault();
57
+ newest.undo();
58
+ };
37
59
  this.made = new WeakSet();
38
60
  this.pointerIn = false;
39
61
  this.focusIn = false;
@@ -112,7 +134,7 @@ let HelixHeadlessToast = class HelixHeadlessToast extends HelixElement {
112
134
  }
113
135
  announce(arrived) {
114
136
  for (const one of arrived) {
115
- const words = messageWords(one);
137
+ const words = this.wordsOf(one);
116
138
  if (words !== '') {
117
139
  ;
118
140
  (isDanger(one) ? this.pending.urgent : this.pending.said).push(words);
@@ -155,6 +177,83 @@ let HelixHeadlessToast = class HelixHeadlessToast extends HelixElement {
155
177
  this.announce(arrived);
156
178
  }
157
179
  }
180
+ undoable(message, options) {
181
+ const tier = this.constructor.tier;
182
+ const apple = onApple();
183
+ const keys = this.undoKeys;
184
+ const button = document.createElement(`${tier}button`);
185
+ button.textContent = this.undoLabel;
186
+ if (keys !== '') {
187
+ button.setAttribute('aria-keyshortcuts', ariaChord(keys, apple));
188
+ }
189
+ const item = this.add(message, {
190
+ action: button,
191
+ duration: options.duration ?? 8000,
192
+ ...(options.heading === undefined ? {} : { heading: options.heading }),
193
+ ...(options.tone === undefined ? {} : { tone: options.tone }),
194
+ });
195
+ if (keys !== '') {
196
+ const names = keyNames(chordParts(keys), apple);
197
+ this.hints.set(item, formatWords(this, 'undoHint', { keys: names.spoken || names.keys.join(names.separator) }));
198
+ }
199
+ return new Promise((resolve) => {
200
+ let settled = false;
201
+ const unregister = keys === ''
202
+ ? () => { }
203
+ : registerShortcut({
204
+ keys: () => keys,
205
+ label: () => this.undoLabel,
206
+ owner: item,
207
+ });
208
+ const finish = (how) => {
209
+ settled = true;
210
+ unregister();
211
+ button.removeEventListener('click', undo);
212
+ item.removeEventListener('helix-dismiss', closed);
213
+ const at = this.undoables.findIndex((one) => one.item === item);
214
+ if (at >= 0) {
215
+ this.undoables.splice(at, 1);
216
+ }
217
+ resolve(how);
218
+ };
219
+ const undo = () => {
220
+ if (settled) {
221
+ return;
222
+ }
223
+ options.undo();
224
+ finish('undone');
225
+ item.dismiss?.();
226
+ };
227
+ const keep = () => {
228
+ if (!settled) {
229
+ options.commit?.();
230
+ finish('kept');
231
+ }
232
+ };
233
+ const closed = (event) => {
234
+ if (event.defaultPrevented) {
235
+ return;
236
+ }
237
+ queueMicrotask(() => {
238
+ if (!event.defaultPrevented) {
239
+ keep();
240
+ }
241
+ });
242
+ };
243
+ button.addEventListener('click', undo);
244
+ item.addEventListener('helix-dismiss', closed);
245
+ this.undoables.push({ item, keep, undo });
246
+ });
247
+ }
248
+ keepAll() {
249
+ for (const one of [...this.undoables]) {
250
+ one.keep();
251
+ }
252
+ }
253
+ wordsOf(item) {
254
+ const hint = this.hints.get(item);
255
+ return hint ? joinSaid([messageWords(item), hint]) : messageWords(item);
256
+ }
158
257
  add(message, options = {}) {
159
258
  const tier = this.constructor.tier;
160
259
  const item = document.createElement(`${tier}toast-item`);
@@ -291,7 +390,7 @@ let HelixHeadlessToast = class HelixHeadlessToast extends HelixElement {
291
390
  return;
292
391
  }
293
392
  for (const one of arrived) {
294
- const words = messageWords(one);
393
+ const words = this.wordsOf(one);
295
394
  if (words !== '') {
296
395
  speaker.ariaNotify(words, { priority: isDanger(one) ? 'high' : 'normal' });
297
396
  }
@@ -305,6 +404,13 @@ let HelixHeadlessToast = class HelixHeadlessToast extends HelixElement {
305
404
  this.addEventListener('pointerleave', this.handlePointerLeave);
306
405
  this.addEventListener('helix-dismiss', this.handleDismiss);
307
406
  document.addEventListener('keydown', this.handleHotkey);
407
+ document.addEventListener('keydown', this.handleUndo);
408
+ window.addEventListener('pagehide', this.handlePageHide);
409
+ this.unregister = registerShortcut({
410
+ keys: () => this.hotkey,
411
+ label: () => this.hotkeyLabel,
412
+ owner: this,
413
+ });
308
414
  this.watcher = new MutationObserver((records) => {
309
415
  const added = records.flatMap((record) => [...record.addedNodes]);
310
416
  this.restack();
@@ -325,6 +431,10 @@ let HelixHeadlessToast = class HelixHeadlessToast extends HelixElement {
325
431
  }
326
432
  disconnectedCallback() {
327
433
  super.disconnectedCallback();
434
+ this.unregister?.();
435
+ this.keepAll();
436
+ document.removeEventListener('keydown', this.handleUndo);
437
+ window.removeEventListener('pagehide', this.handlePageHide);
328
438
  this.removeEventListener('focusin', this.handleFocusIn);
329
439
  this.removeEventListener('focusout', this.handleFocusOut);
330
440
  this.removeEventListener('pointerenter', this.handlePointerEnter);
@@ -359,6 +469,10 @@ let HelixHeadlessToast = class HelixHeadlessToast extends HelixElement {
359
469
  __decorate([
360
470
  property()
361
471
  ], HelixHeadlessToast.prototype, "hotkey", void 0);
472
+ __decorate([
473
+ words,
474
+ property({ attribute: 'hotkey-label' })
475
+ ], HelixHeadlessToast.prototype, "hotkeyLabel", void 0);
362
476
  __decorate([
363
477
  words,
364
478
  property()
@@ -378,6 +492,17 @@ __decorate([
378
492
  __decorate([
379
493
  state()
380
494
  ], HelixHeadlessToast.prototype, "urgent", void 0);
495
+ __decorate([
496
+ words,
497
+ property({ attribute: 'undo-hint' })
498
+ ], HelixHeadlessToast.prototype, "undoHint", void 0);
499
+ __decorate([
500
+ property({ attribute: 'undo-keys' })
501
+ ], HelixHeadlessToast.prototype, "undoKeys", void 0);
502
+ __decorate([
503
+ words,
504
+ property({ attribute: 'undo-label' })
505
+ ], HelixHeadlessToast.prototype, "undoLabel", void 0);
381
506
  HelixHeadlessToast = __decorate([
382
507
  customElement('helix-headless-toast')
383
508
  ], HelixHeadlessToast);
@@ -1,4 +1,4 @@
1
- import { HelixElement, HostClickController } from '@fusion.dev/helix-core';
1
+ import { HelixElement, HostClickController, ListedController } from '@fusion.dev/helix-core';
2
2
  import { type PropertyValues, type TemplateResult } from 'lit';
3
3
  declare const HelixHeadlessToggle_base: (new (...args: any[]) => import("@fusion.dev/helix-core").MixinSelectableItemInterface) & typeof HelixElement;
4
4
  /**
@@ -39,6 +39,8 @@ declare const HelixHeadlessToggle_base: (new (...args: any[]) => import("@fusion
39
39
  * @interactive button
40
40
  */
41
41
  export declare class HelixHeadlessToggle extends HelixHeadlessToggle_base {
42
+ /** On the page's list of controls, where a command palette and a shortcut overlay find it. */
43
+ protected readonly listed: ListedController;
42
44
  /** A click on the element itself, `click()` included, chooses it, as on `Radio`. */
43
45
  protected readonly clicks: HostClickController;
44
46
  /**
@@ -1,10 +1,11 @@
1
1
  import { __decorate } from "tslib";
2
- import { auditName, customElement, HelixElement, HostClickController, MixinSelectableItem, } from '@fusion.dev/helix-core';
2
+ import { auditName, customElement, HelixElement, HostClickController, ListedController, MixinSelectableItem, } from '@fusion.dev/helix-core';
3
3
  import { html, nothing } from 'lit';
4
4
  import { property } from 'lit/decorators.js';
5
5
  let HelixHeadlessToggle = class HelixHeadlessToggle extends MixinSelectableItem(HelixElement) {
6
6
  constructor() {
7
7
  super(...arguments);
8
+ this.listed = new ListedController(this, 'press', () => this.internals?.ariaKeyShortcuts);
8
9
  this.clicks = new HostClickController(this, () => this.partNamed('item'));
9
10
  }
10
11
  get defaultSelected() {
@@ -409,6 +409,8 @@ export declare class HelixHeadlessVirtualList extends HelixElement {
409
409
  private filledFrom;
410
410
  /** The function the drawn rows were filled by, so a new one draws them again. */
411
411
  private filledBy;
412
+ /** Whether the drawn rows were cloned from the template or drawn for `renderItem`. */
413
+ private drawnBy;
412
414
  /** The script the rows were read from, which the watcher follows. */
413
415
  private source;
414
416
  /**
@@ -702,8 +704,13 @@ export declare class HelixHeadlessVirtualList extends HelixElement {
702
704
  * **A row that stays in the window keeps its element.** Clearing and
703
705
  * cloning every row on every move replaced the node a reader had focused,
704
706
  * so a link in a row lost focus to the body on any scroll, even with its row
705
- * still on screen. Rows are keyed by item: the ones leaving go, the ones
706
- * arriving are drawn in their place, and the ones staying are not moved.
707
+ * still on screen. Rows are keyed by item: a row that stays keeps its
708
+ * element, and a row that leaves gives its element to a row that arrives.
709
+ *
710
+ * **A slotted row added or taken out while the box is moving ends the
711
+ * touch on iOS.** Safari reads a change to what a slot holds as a change to
712
+ * the box. The same nodes, filled with the arriving items, leave the slot
713
+ * as it was. Extra nodes stay in the tree hidden until the box is still.
707
714
  *
708
715
  * **The row holding focus is drawn outside the window too**, beside it on the
709
716
  * side it belongs, and the spacer on that side gives up its room.
@@ -718,6 +725,15 @@ export declare class HelixHeadlessVirtualList extends HelixElement {
718
725
  private placedSections;
719
726
  /** Whether the rows were last placed in a waterfall. */
720
727
  private placedWaterfall;
728
+ /**
729
+ * Row elements taken out of the window while the box is still moving, so
730
+ * the slot keeps the same nodes. Filled again when a row arrives.
731
+ */
732
+ private pooled;
733
+ /** Hides a row group that has left the window, and leaves it in the tree. */
734
+ private parkRows;
735
+ /** Takes a row group out of the tree. */
736
+ private dropRows;
721
737
  /** The lines of a grid drawn on the last pass, each a `row` the render draws a slot in. */
722
738
  private drawnLines;
723
739
  /** The function the drawn headers were drawn by, so a new one draws them again. */
@@ -188,6 +188,7 @@ let HelixHeadlessVirtualList = class HelixHeadlessVirtualList extends HelixEleme
188
188
  };
189
189
  this.visited = false;
190
190
  this.placedWaterfall = false;
191
+ this.pooled = [];
191
192
  this.drawnLines = [];
192
193
  this.sectionId = nextSectionId();
193
194
  }
@@ -575,12 +576,16 @@ let HelixHeadlessVirtualList = class HelixHeadlessVirtualList extends HelixEleme
575
576
  const lined = this.lined;
576
577
  element.setAttribute('role', this.selectionMode ? 'option' : lined ? 'gridcell' : 'listitem');
577
578
  const across = this.across;
579
+ const keepSlot = lined && this.virtualize.holdsSlot();
578
580
  for (const [name, value] of [
579
581
  ['aria-colindex', lined ? (at % across) + 1 : undefined],
580
582
  ['aria-posinset', lined ? undefined : at + 1],
581
583
  ['aria-setsize', lined ? undefined : this.data.length],
582
- ['slot', lined ? `${LINE}${Math.floor(at / across)}` : undefined],
584
+ ['slot', lined && !keepSlot ? `${LINE}${Math.floor(at / across)}` : undefined],
583
585
  ]) {
586
+ if (name === 'slot' && keepSlot) {
587
+ continue;
588
+ }
584
589
  if (value === undefined) {
585
590
  element.removeAttribute(name);
586
591
  }
@@ -656,10 +661,13 @@ let HelixHeadlessVirtualList = class HelixHeadlessVirtualList extends HelixEleme
656
661
  const children = [
657
662
  ...[...this.children].filter((child) => child !== template &&
658
663
  child instanceof HTMLElement &&
664
+ child.dataset.virtualPool === undefined &&
659
665
  (child.dataset.index !== undefined || !child.hasAttribute('slot')) &&
660
666
  !child.hasAttribute('data-section')),
661
667
  ...this.querySelectorAll(':scope > [data-section] > [data-index]'),
662
668
  ];
669
+ const redraw = regroup ||
670
+ (this.drawnBy !== undefined && this.drawnBy !== (this.renderItem ? 'function' : 'template'));
663
671
  const plan = drawPlan({
664
672
  across: this.across,
665
673
  count,
@@ -667,24 +675,38 @@ let HelixHeadlessVirtualList = class HelixHeadlessVirtualList extends HelixEleme
667
675
  first,
668
676
  kept: this.keptIndex(),
669
677
  last,
670
- redraw: regroup || (this.filledBy === undefined) !== (this.renderItem === undefined),
678
+ redraw,
671
679
  });
672
680
  const drawn = new Map();
681
+ const reusable = [...this.pooled];
682
+ this.pooled = [];
683
+ const leaving = new Map();
673
684
  for (const child of children) {
674
685
  const index = Number(child.dataset.index);
675
686
  if (plan.remove.has(index)) {
676
687
  this.sizes?.unobserve(child);
677
- child.remove();
688
+ leaving.set(index, [...(leaving.get(index) ?? []), child]);
678
689
  continue;
679
690
  }
680
691
  drawn.set(index, [...(drawn.get(index) ?? []), child]);
681
692
  }
693
+ if (redraw) {
694
+ for (const group of [...reusable, ...leaving.values()]) {
695
+ this.dropRows(group);
696
+ }
697
+ reusable.length = 0;
698
+ leaving.clear();
699
+ }
700
+ else {
701
+ reusable.push(...leaving.values());
702
+ }
682
703
  const refill = this.filledFrom !== this.data ||
683
704
  this.filledBy !== this.renderItem ||
684
705
  this.headedBy !== this.renderSection;
685
706
  this.filledFrom = this.data;
686
707
  this.filledBy = this.renderItem;
687
708
  this.headedBy = this.renderSection;
709
+ this.drawnBy = this.renderItem ? 'function' : 'template';
688
710
  const groups = this.groups(sections, plan.wanted, regroup, refill);
689
711
  const fresh = new Set();
690
712
  const anchors = new Map();
@@ -699,15 +721,67 @@ let HelixHeadlessVirtualList = class HelixHeadlessVirtualList extends HelixEleme
699
721
  this.fill(group, at);
700
722
  fresh.add(at);
701
723
  }
724
+ if (!this.waterfall && this.virtualize.holdsSlot()) {
725
+ for (const element of group) {
726
+ ;
727
+ element.style.order = String(at);
728
+ }
729
+ }
702
730
  anchors.set(parent, group.at(-1).nextElementSibling);
703
731
  continue;
704
732
  }
733
+ const width = this.renderItem ? 1 : (template?.content.childElementCount ?? 1);
734
+ const recycledAt = reusable.findIndex((one) => one.length === width);
735
+ const recycled = recycledAt === -1 ? undefined : reusable.splice(recycledAt, 1)[0];
736
+ if (recycled) {
737
+ for (const element of recycled) {
738
+ element.hidden = false;
739
+ element.removeAttribute('aria-hidden');
740
+ element.removeAttribute('data-virtual-pool');
741
+ }
742
+ this.fill(recycled, at);
743
+ this.watchSize(recycled);
744
+ drawn.set(at, recycled);
745
+ fresh.add(at);
746
+ if (!this.waterfall && this.virtualize.holdsSlot()) {
747
+ for (const element of recycled) {
748
+ element.style.order = String(at);
749
+ }
750
+ }
751
+ if (recycled[0]?.parentElement !== parent || !this.virtualize.holdsSlot()) {
752
+ for (const element of recycled) {
753
+ parent.insertBefore(element, anchors.get(parent) ?? null);
754
+ }
755
+ }
756
+ anchors.set(parent, recycled.at(-1).nextElementSibling);
757
+ continue;
758
+ }
759
+ if (this.virtualize.holdsSlot()) {
760
+ continue;
761
+ }
705
762
  const fragment = this.draw(template, at);
706
763
  drawn.set(at, [...fragment.children]);
707
764
  this.watchSize([...fragment.children]);
708
765
  fresh.add(at);
766
+ if (!this.waterfall && this.virtualize.holdsSlot()) {
767
+ for (const element of fragment.children) {
768
+ ;
769
+ element.style.order = String(at);
770
+ }
771
+ }
709
772
  parent.insertBefore(fragment, anchors.get(parent) ?? null);
710
773
  }
774
+ if (this.virtualize.holdsSlot()) {
775
+ for (const group of reusable) {
776
+ this.parkRows(group);
777
+ this.pooled.push(group);
778
+ }
779
+ }
780
+ else {
781
+ for (const group of reusable) {
782
+ this.dropRows(group);
783
+ }
784
+ }
711
785
  this.active = stopAfter(this.active, plan.wanted, {
712
786
  count,
713
787
  first,
@@ -717,7 +791,7 @@ let HelixHeadlessVirtualList = class HelixHeadlessVirtualList extends HelixEleme
717
791
  const chosen = this.chosen;
718
792
  const off = this.disabled;
719
793
  const lines = this.lined ? [...new Set(plan.wanted.map((at) => this.lineOf(at)))] : [];
720
- if (lines.join() !== this.drawnLines.join()) {
794
+ if (lines.join() !== this.drawnLines.join() && !this.virtualize.holdsSlot()) {
721
795
  this.drawnLines = lines;
722
796
  this.requestUpdate();
723
797
  }
@@ -726,13 +800,22 @@ let HelixHeadlessVirtualList = class HelixHeadlessVirtualList extends HelixEleme
726
800
  for (const [at, group] of drawn) {
727
801
  for (const element of group) {
728
802
  this.mark(element, at, chosen, off);
803
+ const style = element.style;
804
+ if (!this.waterfall && !this.virtualize.holdsSlot()) {
805
+ style.removeProperty('order');
806
+ }
729
807
  if (this.waterfall) {
730
- const style = element.style;
731
808
  style.setProperty('position', 'absolute');
732
809
  style.setProperty('inset-block-start', `${this.virtualize.offsetOf(at)}px`);
733
810
  style.setProperty('inset-inline-start', `calc((100% + ${gap}px) * ${this.lanes.lane(at)} / ${columns})`);
734
811
  style.setProperty('inline-size', `calc((100% - ${gap * (columns - 1)}px) / ${columns})`);
735
812
  }
813
+ else {
814
+ style.removeProperty('position');
815
+ style.removeProperty('inset-block-start');
816
+ style.removeProperty('inset-inline-start');
817
+ style.removeProperty('inline-size');
818
+ }
736
819
  }
737
820
  const stop = at === this.active;
738
821
  group[0]?.setAttribute('tabindex', stop ? '0' : '-1');
@@ -755,6 +838,21 @@ let HelixHeadlessVirtualList = class HelixHeadlessVirtualList extends HelixEleme
755
838
  }, 0);
756
839
  }
757
840
  }
841
+ parkRows(group) {
842
+ for (const element of group) {
843
+ element.hidden = true;
844
+ element.setAttribute('aria-hidden', 'true');
845
+ element.setAttribute('data-virtual-pool', '');
846
+ element.removeAttribute('data-index');
847
+ element.style.removeProperty('order');
848
+ }
849
+ }
850
+ dropRows(group) {
851
+ for (const element of group) {
852
+ this.sizes?.unobserve(element);
853
+ element.remove();
854
+ }
855
+ }
758
856
  groups(sections, wanted, regroup, refill) {
759
857
  const keep = new Set(sections ? wanted.map((at) => sectionAt(sections, at)) : []);
760
858
  const groups = new Map();