@fusion.dev/helix-headless 0.171.0 → 0.173.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 (67) hide show
  1. package/custom-elements.json +1350 -124
  2. package/dist/button-group/root/button-group-root.d.ts +30 -0
  3. package/dist/button-group/root/button-group-root.js +50 -4
  4. package/dist/canvas/root/canvas-root.d.ts +7 -0
  5. package/dist/canvas/root/canvas-root.js +7 -0
  6. package/dist/chat/composer/chat-composer.d.ts +28 -1
  7. package/dist/chat/composer/chat-composer.js +22 -3
  8. package/dist/chat/root/chat-root.d.ts +38 -2
  9. package/dist/chat/root/chat-root.js +38 -1
  10. package/dist/color-input/root/color-input-root.d.ts +15 -1
  11. package/dist/color-input/root/color-input-root.js +19 -1
  12. package/dist/color-picker/root/color-picker-root.d.ts +5 -1
  13. package/dist/color-picker/root/color-picker-root.js +1 -1
  14. package/dist/copy-button/root/copy-button-root.d.ts +43 -3
  15. package/dist/copy-button/root/copy-button-root.js +49 -3
  16. package/dist/data-table/root/data-table-root.d.ts +5 -1
  17. package/dist/data-table/root/data-table-root.js +1 -0
  18. package/dist/dialog/root/dialog-root.js +1 -0
  19. package/dist/drawer/root/drawer-root.d.ts +4 -0
  20. package/dist/drawer/root/drawer-root.js +1 -0
  21. package/dist/element-events.generated.js +3 -0
  22. package/dist/graph/root/graph-root.d.ts +22 -0
  23. package/dist/graph/root/graph-root.js +21 -5
  24. package/dist/lightbox/root/lightbox-root.js +1 -0
  25. package/dist/list/root/list-root.d.ts +6 -1
  26. package/dist/list/root/list-root.js +3 -2
  27. package/dist/listbox/root/listbox-root.d.ts +2 -1
  28. package/dist/listbox/root/listbox-root.js +3 -2
  29. package/dist/menu/item/menu-item.d.ts +2 -0
  30. package/dist/menu/item/menu-item.js +2 -0
  31. package/dist/menu/root/menu-root.d.ts +73 -12
  32. package/dist/menu/root/menu-root.js +103 -7
  33. package/dist/outline/root/outline-root.d.ts +57 -0
  34. package/dist/outline/root/outline-root.js +98 -29
  35. package/dist/popover/root/popover-root.d.ts +59 -6
  36. package/dist/popover/root/popover-root.js +51 -3
  37. package/dist/select/root/select-root.d.ts +2 -2
  38. package/dist/select/root/select-root.js +2 -2
  39. package/dist/speed-dial/root/speed-dial-root.d.ts +14 -0
  40. package/dist/speed-dial/root/speed-dial-root.js +34 -2
  41. package/dist/splitter/root/splitter-root.d.ts +25 -2
  42. package/dist/splitter/root/splitter-root.js +19 -4
  43. package/dist/tag/group/tag-group.d.ts +8 -1
  44. package/dist/tag/group/tag-group.js +8 -0
  45. package/dist/tag/root/tag-root.d.ts +12 -0
  46. package/dist/tag/root/tag-root.js +12 -0
  47. package/dist/textarea/root/textarea-root.d.ts +8 -0
  48. package/dist/textarea/root/textarea-root.js +16 -0
  49. package/dist/time-picker/root/time-picker-root.d.ts +14 -0
  50. package/dist/time-picker/root/time-picker-root.js +8 -1
  51. package/dist/timeline/item/timeline-item.d.ts +5 -1
  52. package/dist/timeline/item/timeline-item.js +9 -0
  53. package/dist/timeline/root/timeline-root.d.ts +23 -0
  54. package/dist/timeline/root/timeline-root.js +17 -3
  55. package/dist/tooltip/root/tooltip-root.d.ts +10 -0
  56. package/dist/tooltip/root/tooltip-root.js +14 -2
  57. package/dist/virtual-list/reorder/index.d.ts +1 -0
  58. package/dist/virtual-list/reorder/index.js +4 -0
  59. package/dist/virtual-list/reorder/virtual-list-reorder.d.ts +86 -0
  60. package/dist/virtual-list/reorder/virtual-list-reorder.js +193 -0
  61. package/dist/virtual-list/root/virtual-list-root.d.ts +49 -11
  62. package/dist/virtual-list/root/virtual-list-root.js +10 -1
  63. package/lint.json +1 -1
  64. package/package.json +5 -5
  65. package/types/jsx.d.ts +880 -48
  66. package/types/vue.d.ts +174 -19
  67. package/types/words.d.ts +22 -0
@@ -57,6 +57,12 @@ import { type PropertyValues, type TemplateResult } from 'lit';
57
57
  * a named `group`, and the toolbar's own arrow keys walk its buttons, so a
58
58
  * reader meets one toolbar. Under `single` it is a `radiogroup` either way.
59
59
  *
60
+ * **Disabled.** `disabled` on the group disables every button in it, Helix
61
+ * and native alike, and says so on the group with `aria-disabled`, as React
62
+ * Aria's `isDisabled` does. A button disabled on its own stays disabled when
63
+ * the group is enabled again, and a button that leaves the group gets back
64
+ * what it said for itself.
65
+ *
60
66
  * @since 0.1.0
61
67
  * @status stable
62
68
  * @category actions
@@ -73,6 +79,11 @@ import { type PropertyValues, type TemplateResult } from 'lit';
73
79
  * @cssstate pressed - While a group with `selection-mode` has a button pressed.
74
80
  */
75
81
  export declare class HelixHeadlessButtonGroup extends HelixElement {
82
+ /**
83
+ * Disables every button in the group, and says so on the group with
84
+ * `aria-disabled`. Reflected, so a stylesheet can select on it.
85
+ */
86
+ disabled: boolean;
76
87
  /**
77
88
  * Keeps at least one button pressed once one is, as React Aria's
78
89
  * `disallowEmptySelection`: a press that would leave none pressed is
@@ -122,6 +133,12 @@ export declare class HelixHeadlessButtonGroup extends HelixElement {
122
133
  * not reflect, as a native control's `value` does not.
123
134
  */
124
135
  value: string;
136
+ /**
137
+ * The pressed values as a list, in a selection mode, and an empty list
138
+ * otherwise. Written, it presses exactly those buttons.
139
+ */
140
+ get values(): string[];
141
+ set values(next: string[]);
125
142
  /**
126
143
  * The Helix buttons of a group in a selection mode, in the order they are
127
144
  * written, and none otherwise. Empty on a server, which has no tree.
@@ -146,6 +163,19 @@ export declare class HelixHeadlessButtonGroup extends HelixElement {
146
163
  */
147
164
  private readonly told;
148
165
  private watcher;
166
+ /** The children that can be disabled: Helix buttons and native ones alike. Empty on a server. */
167
+ private get members();
168
+ /** The members this group has disabled, so one that leaves is given back its own answer. */
169
+ private readonly held;
170
+ /**
171
+ * Passes `disabled` down to the members, and gives a member that left
172
+ * what it said for itself.
173
+ *
174
+ * A member already disabled is not written to again: the watcher hears
175
+ * every write to a native button's `disabled`, changed or not, and would
176
+ * answer it with this.
177
+ */
178
+ private cascade;
149
179
  /**
150
180
  * Tells every button whether it is pressed and whether it holds the stop,
151
181
  * and puts back the ones that left.
@@ -4,7 +4,7 @@ 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 { auditName, commaList, customElement, HelixElement, hostAria, inlineStep, pressedValue, reportRuntime, requestValue, rovingStop, toolbarAround, whenHydrated, } from '@fusion.dev/helix-core';
7
+ import { auditName, commaList, customElement, HelixElement, hostAria, inheritDisabled, inlineStep, pressedValue, reportRuntime, requestValue, rovingStop, toolbarAround, whenHydrated, } from '@fusion.dev/helix-core';
8
8
  import { html } from 'lit';
9
9
  import { property } from 'lit/decorators.js';
10
10
  import { arrowTarget } from '../arrows.js';
@@ -12,11 +12,13 @@ const isGroupButton = (child) => child instanceof HTMLElement && 'focusable' in
12
12
  let HelixHeadlessButtonGroup = class HelixHeadlessButtonGroup extends HelixElement {
13
13
  constructor() {
14
14
  super(...arguments);
15
+ this.disabled = false;
15
16
  this.disallowEmptySelection = false;
16
17
  this.label = '';
17
18
  this.value = '';
18
19
  this.lastFocused = undefined;
19
20
  this.told = new Map();
21
+ this.held = new Set();
20
22
  this.warned = false;
21
23
  this.handlePress = (event) => {
22
24
  const mode = this.selectionMode;
@@ -48,6 +50,12 @@ let HelixHeadlessButtonGroup = class HelixHeadlessButtonGroup extends HelixEleme
48
50
  }
49
51
  };
50
52
  }
53
+ get values() {
54
+ return this.selectionMode ? commaList(this.value) : [];
55
+ }
56
+ set values(next) {
57
+ this.value = next.join(',');
58
+ }
51
59
  get buttons() {
52
60
  return this.selectionMode && this.children ? [...this.children].filter(isGroupButton) : [];
53
61
  }
@@ -57,7 +65,33 @@ let HelixHeadlessButtonGroup = class HelixHeadlessButtonGroup extends HelixEleme
57
65
  pressed(button) {
58
66
  return button.value !== '' && commaList(this.value).includes(button.value);
59
67
  }
68
+ get members() {
69
+ return this.children
70
+ ? [...this.children].filter((child) => child instanceof HTMLElement && 'disabled' in child)
71
+ : [];
72
+ }
73
+ cascade() {
74
+ const members = this.members;
75
+ for (const gone of this.held) {
76
+ if (!members.includes(gone)) {
77
+ inheritDisabled(false, [gone]);
78
+ this.held.delete(gone);
79
+ }
80
+ }
81
+ if (this.disabled) {
82
+ const live = members.filter((member) => !member.disabled);
83
+ inheritDisabled(true, live);
84
+ for (const member of live) {
85
+ this.held.add(member);
86
+ }
87
+ }
88
+ else if (this.held.size > 0) {
89
+ inheritDisabled(false, [...this.held]);
90
+ this.held.clear();
91
+ }
92
+ }
60
93
  sync() {
94
+ this.cascade();
61
95
  const buttons = this.buttons;
62
96
  const roving = this.roving;
63
97
  const stop = roving
@@ -86,11 +120,16 @@ let HelixHeadlessButtonGroup = class HelixHeadlessButtonGroup extends HelixEleme
86
120
  });
87
121
  }
88
122
  }
89
- for (const child of this.selectionMode && this.children ? this.children : []) {
90
- if (child.localName.includes('-') && !isGroupButton(child)) {
123
+ const reading = Boolean(this.selectionMode) || this.disabled;
124
+ for (const child of reading && this.children ? this.children : []) {
125
+ if (child.localName.includes('-') &&
126
+ !isGroupButton(child) &&
127
+ !customElements.get(child.localName)) {
91
128
  void customElements.whenDefined(child.localName).then(() => this.sync());
92
129
  }
93
- else if (child.localName === 'button' && !child.hasAttribute('aria-pressed')) {
130
+ else if (this.selectionMode &&
131
+ child.localName === 'button' &&
132
+ !child.hasAttribute('aria-pressed')) {
94
133
  this.warn(child);
95
134
  }
96
135
  }
@@ -167,6 +206,7 @@ let HelixHeadlessButtonGroup = class HelixHeadlessButtonGroup extends HelixEleme
167
206
  role: radios ? 'radiogroup' : toolbar ? 'toolbar' : 'group',
168
207
  });
169
208
  this.internals.ariaOrientation = toolbar ? (this.orientation ?? null) : null;
209
+ this.internals.ariaDisabled = this.disabled ? 'true' : null;
170
210
  this.toggleState('pressed', Boolean(this.selectionMode) && commaList(this.value).length > 0);
171
211
  }
172
212
  firstUpdated(changed) {
@@ -181,6 +221,9 @@ let HelixHeadlessButtonGroup = class HelixHeadlessButtonGroup extends HelixEleme
181
221
  return html `<slot></slot>`;
182
222
  }
183
223
  };
224
+ __decorate([
225
+ property({ reflect: true, type: Boolean })
226
+ ], HelixHeadlessButtonGroup.prototype, "disabled", void 0);
184
227
  __decorate([
185
228
  property({ attribute: 'disallow-empty-selection', type: Boolean })
186
229
  ], HelixHeadlessButtonGroup.prototype, "disallowEmptySelection", void 0);
@@ -196,6 +239,9 @@ __decorate([
196
239
  __decorate([
197
240
  property()
198
241
  ], HelixHeadlessButtonGroup.prototype, "value", void 0);
242
+ __decorate([
243
+ property({ attribute: false })
244
+ ], HelixHeadlessButtonGroup.prototype, "values", null);
199
245
  HelixHeadlessButtonGroup = __decorate([
200
246
  customElement('helix-headless-button-group')
201
247
  ], HelixHeadlessButtonGroup);
@@ -132,6 +132,7 @@ declare const HelixHeadlessCanvas_base: (new (...args: any[]) => import("@fusion
132
132
  * @slot footer - Content below the board.
133
133
  * @slot fullscreen-icon - Replaces the glyph inside the fullscreen button.
134
134
  * @slot header - Content above the board.
135
+ * @slot loading - Content shown while `loading` is set, such as a progress bar while the document arrives.
135
136
  * @slot pan-down-icon - Replaces the glyph inside the pan down button.
136
137
  * @slot pan-end-icon - Replaces the glyph inside the button that pans toward the end of a line of text.
137
138
  * @slot pan-start-icon - Replaces the glyph inside the button that pans toward the start of a line of text.
@@ -192,6 +193,7 @@ declare const HelixHeadlessCanvas_base: (new (...args: any[]) => import("@fusion
192
193
  * @cssstate fullscreen - While the board fills the screen.
193
194
  * @cssstate holding - An item picked up with a click follows the pointer until the next press puts it down.
194
195
  * @cssstate idle - The user has been still for `idle-delay` milliseconds. The board's own controls stay; select on it to hide chrome of your own.
196
+ * @cssstate loading - While `loading` is set.
195
197
  * @cssstate pressing - While a press on the board, its cards or its connections is under way.
196
198
  * @cssstate sharing - The page keeps the plain wheel and one finger, with `gestures="cooperative"` or while a board with `gestures="activate"` waits to be pressed.
197
199
  * @cssstate using - A board with `gestures="activate"` has been pressed and has the gestures.
@@ -275,6 +277,11 @@ export declare class HelixHeadlessCanvas extends HelixHeadlessCanvas_base {
275
277
  * `leads-to-label` and `{comesFrom}` is `comes-from-label`, each filled in.
276
278
  */
277
279
  linkedLabel: string;
280
+ /**
281
+ * Marks the board as busy while its document arrives: the board is
282
+ * `aria-busy`, and the `loading` slot is shown.
283
+ */
284
+ loading: boolean;
278
285
  /**
279
286
  * What screen readers hear after a key moves a card, with `{name}` for the
280
287
  * card and `{x}` and `{y}` for where it now is, in the document's units.
@@ -57,6 +57,7 @@ let HelixHeadlessCanvas = class HelixHeadlessCanvas extends MixinViewport(HelixE
57
57
  this.labelEditorLabel = 'Label for {name}';
58
58
  this.leadsToLabel = 'leads to {cards}';
59
59
  this.linkedLabel = '{leadsTo}; {comesFrom}';
60
+ this.loading = false;
60
61
  this.movedLabel = '{name} moved to {x}, {y}';
61
62
  this.pastedLabel = '{count, plural, one {# card pasted} other {# cards pasted}}';
62
63
  this.readOnly = false;
@@ -2133,6 +2134,7 @@ let HelixHeadlessCanvas = class HelixHeadlessCanvas extends MixinViewport(HelixE
2133
2134
  this.toggleState('empty', this.nodes.length === 0);
2134
2135
  this.toggleState('fullscreen', this.filled);
2135
2136
  this.toggleState('idle', this.resting);
2137
+ this.toggleState('loading', this.loading);
2136
2138
  }
2137
2139
  place(origin) {
2138
2140
  const all = this.nodes;
@@ -2411,6 +2413,7 @@ let HelixHeadlessCanvas = class HelixHeadlessCanvas extends MixinViewport(HelixE
2411
2413
  role="group"
2412
2414
  aria-label=${this.label || this.getAttribute?.('aria-label') || this.fallbackLabel}
2413
2415
  aria-roledescription=${(this.label || this.getAttribute?.('aria-label')) && this.roleLabel ? this.roleLabel : nothing}
2416
+ aria-busy=${this.loading ? 'true' : nothing}
2414
2417
  aria-keyshortcuts="ArrowUp ArrowDown ArrowLeft ArrowRight Plus Minus"
2415
2418
  tabindex="0"
2416
2419
  @focusin=${this.idle.stir}
@@ -2461,6 +2464,7 @@ let HelixHeadlessCanvas = class HelixHeadlessCanvas extends MixinViewport(HelixE
2461
2464
  ${this.renderMarquee(origin)}
2462
2465
  </div>
2463
2466
  <slot name="empty"></slot>
2467
+ ${this.loading ? html `<slot name="loading"></slot>` : nothing}
2464
2468
  ${this.renderGestures()}
2465
2469
  </div>
2466
2470
  ${this.renderControls()} ${this.renderMinimap()}
@@ -2525,6 +2529,9 @@ __decorate([
2525
2529
  words,
2526
2530
  property({ attribute: 'linked-label' })
2527
2531
  ], HelixHeadlessCanvas.prototype, "linkedLabel", void 0);
2532
+ __decorate([
2533
+ property({ reflect: true, type: Boolean })
2534
+ ], HelixHeadlessCanvas.prototype, "loading", void 0);
2528
2535
  __decorate([
2529
2536
  words,
2530
2537
  property({ attribute: 'moved-label' })
@@ -28,6 +28,11 @@ import { type PropertyValues, type TemplateResult } from 'lit';
28
28
  * Sending an empty required composer counts as trying. Tabbing past an empty
29
29
  * composer marks nothing.
30
30
  *
31
+ * **`feedback` is a message of your own about the draft**, such as one your
32
+ * server sent back. While it is set, the composer is invalid at once, does
33
+ * not send or submit, shows the message under the field, and a screen reader
34
+ * hears it as it appears. Clear it to let the draft go.
35
+ *
31
36
  * **`typing` says whether the user is writing**, and `helix-typing` is sent
32
37
  * each time it changes: when they start, and when they send or stop for
33
38
  * `typing-timeout` milliseconds. Pass it on to show "is typing" to the other
@@ -48,13 +53,14 @@ import { type PropertyValues, type TemplateResult } from 'lit';
48
53
  *
49
54
  * @csspart composer - The whole composer.
50
55
  * @csspart end - The box around the `end` slot.
56
+ * @csspart feedback - The `feedback` message, under the field while it is set.
51
57
  * @csspart field - The text field.
52
58
  * @csspart mark - The icon inside the send button.
53
59
  * @csspart send - The send button.
54
60
  * @csspart start - The box around the `start` slot.
55
61
  * @csspart tools - The row under the field.
56
62
  *
57
- * @cssstate invalid - The draft fails `required`, `minlength`, `maxlength`, or a custom error, and the reader has tried to send or submit it, or edited the field and left it.
63
+ * @cssstate invalid - The draft fails `required`, `minlength`, `maxlength`, or a custom error, and the reader has tried to send or submit it, or edited the field and left it. Or `feedback` is set.
58
64
  */
59
65
  export declare class HelixHeadlessChatComposer extends HelixFormElement {
60
66
  /**
@@ -62,6 +68,12 @@ export declare class HelixHeadlessChatComposer extends HelixFormElement {
62
68
  * `registerWords('de', { 'helix-chat-composer': { sendLabel: 'Senden' } })`.
63
69
  */
64
70
  static words: string;
71
+ /**
72
+ * A message about the draft, shown under the field. While set, the composer
73
+ * is invalid, does not send, and does not submit, as a field's `feedback`
74
+ * does, and the message is announced as it appears.
75
+ */
76
+ feedback: string;
65
77
  /**
66
78
  * The field's name, read by screen readers.
67
79
  */
@@ -123,6 +135,17 @@ export declare class HelixHeadlessChatComposer extends HelixFormElement {
123
135
  private writing;
124
136
  /** Whether the reader has tried to send or submit the draft, or edited it and left. */
125
137
  private touched;
138
+ /**
139
+ * Whether the reader has typed in the field since it last held a fresh
140
+ * draft: since it rendered, since a send cleared it, or since a reset.
141
+ *
142
+ * **A send clears the field, and the field still sends `change` as it is
143
+ * left**, because the platform compares the text with what it held when it
144
+ * was focused. Measured in Chromium before this: a required composer sent
145
+ * "hi", cleared, and on Tab showed itself invalid over an empty draft
146
+ * nobody had written.
147
+ */
148
+ private edited;
126
149
  /** Whether the composer shows that its draft is wrong. */
127
150
  private shownInvalid;
128
151
  /** The pause after which the user has stopped writing. */
@@ -147,10 +170,14 @@ export declare class HelixHeadlessChatComposer extends HelixFormElement {
147
170
  * back to. `value` does not reflect, so its attribute stays as written.
148
171
  */
149
172
  private get declared();
173
+ /** @internal `feedback` marks the composer invalid, as a field's does. */
174
+ protected get validityFeedback(): string;
150
175
  /** Puts back the draft the markup declared when the form is reset. */
151
176
  formResetCallback(): void;
152
177
  /** The reader has had a go at the draft, so whether it is wrong can be shown. */
153
178
  private readonly touch;
179
+ /** The field's `change`, which counts only after the reader typed in it. */
180
+ private readonly handleChange;
154
181
  /** Brings back the draft when the browser restores the form. */
155
182
  formStateRestoreCallback(state: string | File | FormData | null): void;
156
183
  /**
@@ -13,6 +13,7 @@ import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
13
13
  let HelixHeadlessChatComposer = class HelixHeadlessChatComposer extends HelixFormElement {
14
14
  constructor() {
15
15
  super(...arguments);
16
+ this.feedback = '';
16
17
  this.label = 'Message';
17
18
  this.placeholder = '';
18
19
  this.required = false;
@@ -24,15 +25,22 @@ let HelixHeadlessChatComposer = class HelixHeadlessChatComposer extends HelixFor
24
25
  this.isChatComposer = true;
25
26
  this.writing = false;
26
27
  this.touched = false;
28
+ this.edited = false;
27
29
  this.shownInvalid = false;
28
30
  this.pause = new DelayController(this, { renders: false });
29
31
  this.touch = () => {
30
32
  this.touched = true;
31
33
  this.requestUpdate();
32
34
  };
35
+ this.handleChange = () => {
36
+ if (this.edited) {
37
+ this.touch();
38
+ }
39
+ };
33
40
  this.handleInput = (event) => {
34
41
  event.stopPropagation();
35
42
  this.value = event.target.value;
43
+ this.edited = true;
36
44
  this.dispatchEvent(new Event('input'));
37
45
  this.markTyping(this.value !== '');
38
46
  };
@@ -87,16 +95,21 @@ let HelixHeadlessChatComposer = class HelixHeadlessChatComposer extends HelixFor
87
95
  }
88
96
  this.value = '';
89
97
  this.touched = false;
98
+ this.edited = false;
90
99
  this.markTyping(false);
91
100
  return true;
92
101
  }
93
102
  get declared() {
94
103
  return this.getAttribute('value') ?? '';
95
104
  }
105
+ get validityFeedback() {
106
+ return this.feedback;
107
+ }
96
108
  formResetCallback() {
97
109
  super.formResetCallback();
98
110
  this.value = this.declared;
99
111
  this.touched = false;
112
+ this.edited = false;
100
113
  this.requestUpdate();
101
114
  }
102
115
  formStateRestoreCallback(state) {
@@ -117,7 +130,7 @@ let HelixHeadlessChatComposer = class HelixHeadlessChatComposer extends HelixFor
117
130
  if (field?.validity) {
118
131
  this.internals.setValidity(field.validity, field.validationMessage, field);
119
132
  }
120
- const invalid = this.touched && !this.internals.validity.valid;
133
+ const invalid = this.feedback !== '' || (this.touched && !this.internals.validity.valid);
121
134
  this.toggleState('invalid', invalid);
122
135
  this.internals.ariaInvalid = invalid ? 'true' : 'false';
123
136
  if (invalid !== this.shownInvalid) {
@@ -166,8 +179,9 @@ let HelixHeadlessChatComposer = class HelixHeadlessChatComposer extends HelixFor
166
179
  minlength=${this.minLength ?? nothing}
167
180
  maxlength=${this.maxLength ?? nothing}
168
181
  aria-invalid=${this.shownInvalid ? 'true' : nothing}
182
+ aria-describedby=${this.feedback === '' ? nothing : 'feedback'}
169
183
  ?disabled=${this.isDisabled}
170
- @change=${this.touch}
184
+ @change=${this.handleChange}
171
185
  @input=${this.handleInput}
172
186
  @keydown=${this.handleKey}
173
187
  ></textarea>
@@ -176,9 +190,14 @@ let HelixHeadlessChatComposer = class HelixHeadlessChatComposer extends HelixFor
176
190
  <span part="end"><slot name="end"></slot></span>
177
191
  ${this.renderSend()}
178
192
  </div>
179
- </div>`;
193
+ </div>${this.feedback === ''
194
+ ? nothing
195
+ : html `<p part="feedback" id="feedback" role="alert">${this.feedback}</p>`}`;
180
196
  }
181
197
  };
198
+ __decorate([
199
+ property()
200
+ ], HelixHeadlessChatComposer.prototype, "feedback", void 0);
182
201
  __decorate([
183
202
  words,
184
203
  property()
@@ -37,6 +37,13 @@ declare const HelixHeadlessChat_base: (new (...args: any[]) => import("@fusion.d
37
37
  * `loading` while you fetch, then prepend the older turns. The turn the user
38
38
  * was reading stays in place on screen.
39
39
  *
40
+ * The transcript is a `log`, the role for content added in order where the
41
+ * newest matters most, and it answers the APG feed's keys. The `feed` role
42
+ * reads its articles as a list from the top, with no newest end to stay
43
+ * pinned to, so the log keeps the role a conversation has. While `loading` is
44
+ * set or older turns load, the log carries `aria-busy`, as the APG asks of a
45
+ * feed.
46
+ *
40
47
  * The log is `aria-live="off"`. A separate status region announces each new
41
48
  * turn once, when it has finished arriving. It skips the user's own turns
42
49
  * unless they failed to send, turns loaded as history, and the turns already
@@ -49,6 +56,12 @@ declare const HelixHeadlessChat_base: (new (...args: any[]) => import("@fusion.d
49
56
  * conversations. `turns` draws a long
50
57
  * transcript one window at a time, so the log needs a block size of its own.
51
58
  *
59
+ * The log scrolls on this tier. It carries `data-scroller`, and `core` ships
60
+ * `[data-scroller] { overflow: auto }` into every shadow root, so pinning,
61
+ * history and the window work with no stylesheet. You still need to give the
62
+ * log a block size, such as `helix-headless-chat::part(log) { block-size:
63
+ * 20rem }`.
64
+ *
52
65
  * **A conversation is built from three elements.** The chat holds the
53
66
  * transcript, a `Chat.Message` stands for each turn, and a `Chat.Composer` sits
54
67
  * in the `composer` slot. When the user sends, `helix-send` reaches the chat:
@@ -65,7 +78,9 @@ declare const HelixHeadlessChat_base: (new (...args: any[]) => import("@fusion.d
65
78
  * turn says its place, such as "3 of 40", windowed or not. The transcript is
66
79
  * a tab stop while it holds a turn, and the turn a reader moved to is one
67
80
  * too. Moving to an older turn unpins a transcript that scrolls and scrolls
68
- * the turn into view, and moving to the newest pins it again.
81
+ * the turn into view, and moving to the newest pins it again. When the turn
82
+ * holding focus is removed, focus moves to the turn that took its place, or to
83
+ * the one before it where it was the last.
69
84
  *
70
85
  * | Key | What it does |
71
86
  * |---|---|
@@ -120,7 +135,7 @@ declare const HelixHeadlessChat_base: (new (...args: any[]) => import("@fusion.d
120
135
  * @csspart indicator - The three dots before the words that say who is typing, which the styled tier animates as a message's are. Hidden from screen readers.
121
136
  * @csspart live - The region where screen readers announce each turn once it has arrived.
122
137
  * @csspart loading - Shown while the history loads.
123
- * @csspart log - The transcript, which scrolls.
138
+ * @csspart log - The transcript, which scrolls. On this tier, it needs only a block size.
124
139
  * @csspart message - The message drawn for a turn from `turns`, forwarded whole.
125
140
  * @csspart message-actions - The controls on a turn from `turns`, beside its time.
126
141
  * @csspart message-avatar - The avatar box of a turn from `turns`.
@@ -518,6 +533,27 @@ export declare class HelixHeadlessChat extends HelixHeadlessChat_base {
518
533
  */
519
534
  private follow;
520
535
  protected updated(changed: PropertyValues): void;
536
+ /** The turn focus is in, by its place and its identity, once a reader is in one. */
537
+ private held;
538
+ /** Notes the turn focus went into, or that it went somewhere else. */
539
+ private readonly handleFocusIn;
540
+ /**
541
+ * Forgets the turn once focus moves to something outside the transcript.
542
+ * A move to nothing is kept, since removing the turn that held focus is
543
+ * one.
544
+ */
545
+ private readonly handleFocusOut;
546
+ /**
547
+ * Moves focus to a neighbor where removing the turn that held it dropped
548
+ * focus to the body: the turn that took its place, or the one before it
549
+ * where it was the last.
550
+ *
551
+ * **Focus went to the body**, and Page Down did nothing until the reader
552
+ * tabbed back into the transcript. List and Virtual List move focus to a
553
+ * neighbor the same way, as React Aria's GridList does. Focus anywhere but
554
+ * the body is where you put it, and is left there.
555
+ */
556
+ private followRemoved;
521
557
  /**
522
558
  * Hands each turn written as a child the words it is named with, once it
523
559
  * has hydrated, so its first render is the server's.
@@ -5,7 +5,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
7
  var HelixHeadlessChat_1;
8
- import { ChildrenController, customElement, drawIcon, focusableElements, formatWords, HelixBeforePinEvent, HelixElement, HelixHistoryEvent, HelixPinEvent, hostAria, MixinCollection, onUnlessFalse, reportRuntime, requestChange, UpgradeController, VirtualizeController, whenHydrated, words, wordsConverter, } from '@fusion.dev/helix-core';
8
+ import { ChildrenController, customElement, deepActiveElement, drawIcon, focusableElements, formatWords, HelixBeforePinEvent, HelixElement, HelixHistoryEvent, HelixPinEvent, hostAria, MixinCollection, onUnlessFalse, reportRuntime, requestChange, UpgradeController, VirtualizeController, whenHydrated, words, wordsConverter, } from '@fusion.dev/helix-core';
9
9
  import { arrowDown } from '@fusion.dev/helix-icons';
10
10
  import { nothing } from 'lit';
11
11
  import { property, state } from 'lit/decorators.js';
@@ -98,6 +98,21 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
98
98
  this.resume();
99
99
  };
100
100
  this.warnedMixed = false;
101
+ this.handleFocusIn = (event) => {
102
+ const at = this.turnFrom(event.composedPath());
103
+ if (at === undefined || at < 0) {
104
+ this.held = undefined;
105
+ return;
106
+ }
107
+ const id = this.fed ? this.identity(this.turns[at]) : this.messages[at];
108
+ this.held = { at, id };
109
+ };
110
+ this.handleFocusOut = (event) => {
111
+ const to = event.relatedTarget;
112
+ if (to instanceof Node && !this.contains(to) && !this.renderRoot?.contains?.(to)) {
113
+ this.held = undefined;
114
+ }
115
+ };
101
116
  this.handleKeydown = (event) => {
102
117
  if (event.defaultPrevented || event.altKey || event.metaKey || event.shiftKey) {
103
118
  return;
@@ -423,6 +438,8 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
423
438
  connectedCallback() {
424
439
  super.connectedCallback();
425
440
  this.addEventListener('keydown', this.handleKeydown);
441
+ this.addEventListener('focusin', this.handleFocusIn);
442
+ this.addEventListener('focusout', this.handleFocusOut);
426
443
  this.settling = new MutationObserver(this.handleSettle);
427
444
  this.settling.observe(this, { attributeFilter: ['state'], subtree: true });
428
445
  this.opening = true;
@@ -436,6 +453,8 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
436
453
  disconnectedCallback() {
437
454
  super.disconnectedCallback();
438
455
  this.removeEventListener('keydown', this.handleKeydown);
456
+ this.removeEventListener('focusin', this.handleFocusIn);
457
+ this.removeEventListener('focusout', this.handleFocusOut);
439
458
  this.settling?.disconnect();
440
459
  this.settling = undefined;
441
460
  this.grower?.disconnect();
@@ -506,6 +525,23 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
506
525
  this.remember();
507
526
  this.reachHistory();
508
527
  this.announce();
528
+ this.followRemoved();
529
+ }
530
+ followRemoved() {
531
+ const held = this.held;
532
+ if (held === undefined) {
533
+ return;
534
+ }
535
+ const ids = this.fed ? this.turns.map((turn) => this.identity(turn)) : this.messages;
536
+ if (ids.includes(held.id)) {
537
+ return;
538
+ }
539
+ this.held = undefined;
540
+ const active = deepActiveElement();
541
+ if (ids.length === 0 || (active !== null && active !== document.body)) {
542
+ return;
543
+ }
544
+ void this.focusTurn(Math.min(held.at, ids.length - 1));
509
545
  }
510
546
  tellTurns() {
511
547
  const messages = this.messages;
@@ -670,6 +706,7 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
670
706
  part="log"
671
707
  role="log"
672
708
  aria-live="off"
709
+ data-scroller
673
710
  aria-label=${this.label || this.fallbackLabel}
674
711
  aria-busy=${busy ? 'true' : nothing}
675
712
  ?data-windowed=${fed}
@@ -31,7 +31,8 @@ declare const HelixHeadlessColorInput_base: (new (...args: any[]) => import("@fu
31
31
  *
32
32
  * The trigger and ArrowDown open the panel with focus in the square. Tab moves
33
33
  * between the panel's controls without leaving it, and Escape closes it and
34
- * returns focus to the field.
34
+ * returns focus to the control that opened it: the trigger after a press, and
35
+ * the field after ArrowDown.
35
36
  *
36
37
  * The component sets `--color-input-swatch`, so do not set it yourself. It
37
38
  * holds the resolved color, so the styled tier can paint the swatch without
@@ -305,6 +306,19 @@ export declare class HelixHeadlessColorInput extends HelixHeadlessColorInput_bas
305
306
  */
306
307
  protected sync(): void;
307
308
  protected updated(changed: PropertyValues): void;
309
+ /** Whether the trigger's press opened the panel that is open now. */
310
+ private pressedOpen;
311
+ /**
312
+ * The trigger's press, which notes that it opened the panel, so Escape hands
313
+ * focus back to the trigger.
314
+ */
315
+ private readonly handlePress;
316
+ /**
317
+ * Hands focus back to the control that opened the panel: the trigger after
318
+ * its press, which is the APG date picker dialog's rule, and the text after
319
+ * ArrowDown from it.
320
+ */
321
+ protected returnFocus(): void;
308
322
  /**
309
323
  * The picker the field opens, behind a seam the styled tier can move.
310
324
  *
@@ -55,6 +55,12 @@ let HelixHeadlessColorInput = class HelixHeadlessColorInput extends MixinPickerF
55
55
  this.handleSampleEnd = () => {
56
56
  this.requestOpen(true);
57
57
  };
58
+ this.pressedOpen = false;
59
+ this.handlePress = () => {
60
+ const opening = !this.open;
61
+ this.handleTriggerClick();
62
+ this.pressedOpen = opening && this.open;
63
+ };
58
64
  }
59
65
  static { this.words = 'helix-color-input'; }
60
66
  requestValue(next) {
@@ -117,6 +123,18 @@ let HelixHeadlessColorInput = class HelixHeadlessColorInput extends MixinPickerF
117
123
  if (changed.has('value') && parseColor(this.value)) {
118
124
  this.settled = this.value;
119
125
  }
126
+ if (changed.has('open') && !this.open) {
127
+ this.pressedOpen = false;
128
+ }
129
+ }
130
+ returnFocus() {
131
+ const trigger = this.pressedOpen ? this.partNamed('trigger') : null;
132
+ this.pressedOpen = false;
133
+ if (trigger) {
134
+ trigger.focus();
135
+ return;
136
+ }
137
+ super.returnFocus();
120
138
  }
121
139
  renderPicker() {
122
140
  return html `<${this.tag('color-picker')}
@@ -204,7 +222,7 @@ let HelixHeadlessColorInput = class HelixHeadlessColorInput extends MixinPickerF
204
222
  : 'swatch-value'
205
223
  : nothing}
206
224
  ?disabled=${this.isDisabled}
207
- @click=${this.handleTriggerClick}
225
+ @click=${this.handlePress}
208
226
  >
209
227
  <span
210
228
  part="preview"
@@ -445,7 +445,11 @@ export declare class HelixHeadlessColorPicker extends HelixHeadlessColorPicker_b
445
445
  * field shows the color the reset put back.
446
446
  */
447
447
  formResetCallback(): void;
448
- /** The line `said` holds, or nothing while it is empty. */
448
+ /**
449
+ * The line `said` holds, or nothing while it is empty. `role="alert"`, as
450
+ * every field's feedback line has, so the message is announced when it
451
+ * appears.
452
+ */
449
453
  protected renderPickerFeedback(): TemplateResult | typeof nothing;
450
454
  /** Whether a pointer is dragging the square's handle, said as `:state(dragging)`. */
451
455
  private dragging;
@@ -309,7 +309,7 @@ let HelixHeadlessColorPicker = class HelixHeadlessColorPicker extends MixinPicke
309
309
  }
310
310
  renderPickerFeedback() {
311
311
  const said = this.said;
312
- return said === '' ? nothing : html `<p part="feedback" id="feedback">${said}</p>`;
312
+ return said === '' ? nothing : html `<p part="feedback" id="feedback" role="alert">${said}</p>`;
313
313
  }
314
314
  settle() {
315
315
  if (this.unsettled) {