@fusion.dev/helix-headless 0.170.0 → 0.172.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 (57) hide show
  1. package/custom-elements.json +1045 -154
  2. package/dist/chat/composer/chat-composer.d.ts +30 -2
  3. package/dist/chat/composer/chat-composer.js +29 -4
  4. package/dist/chat/root/chat-root.d.ts +73 -7
  5. package/dist/chat/root/chat-root.js +90 -5
  6. package/dist/code-block/root/code-block-root.d.ts +20 -2
  7. package/dist/code-block/root/code-block-root.js +29 -8
  8. package/dist/color-input/root/color-input-root.d.ts +15 -1
  9. package/dist/color-input/root/color-input-root.js +19 -1
  10. package/dist/color-picker/root/color-picker-root.d.ts +54 -7
  11. package/dist/color-picker/root/color-picker-root.js +62 -1
  12. package/dist/combobox/root/combobox-root.d.ts +16 -6
  13. package/dist/combobox/root/combobox-root.js +9 -2
  14. package/dist/command/root/command-root.d.ts +16 -1
  15. package/dist/command/root/command-root.js +8 -0
  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 +1 -0
  22. package/dist/filter-input/chip/filter-chip.d.ts +13 -3
  23. package/dist/filter-input/chip/filter-chip.js +5 -0
  24. package/dist/filter-input/menu/filter-menu.js +0 -1
  25. package/dist/filter-input/root/filter-input-root.d.ts +71 -4
  26. package/dist/filter-input/root/filter-input-root.js +87 -2
  27. package/dist/format-date/root/format-date-root.d.ts +3 -1
  28. package/dist/input/root/input-root.d.ts +6 -0
  29. package/dist/input/root/input-root.js +13 -0
  30. package/dist/lightbox/root/lightbox-root.js +1 -0
  31. package/dist/listbox/root/listbox-root.d.ts +8 -1
  32. package/dist/listbox/root/listbox-root.js +9 -3
  33. package/dist/menu/item/menu-item.d.ts +2 -0
  34. package/dist/menu/item/menu-item.js +2 -0
  35. package/dist/menu/root/menu-root.d.ts +73 -12
  36. package/dist/menu/root/menu-root.js +103 -7
  37. package/dist/number-input/root/number-input-root.d.ts +16 -6
  38. package/dist/number-input/root/number-input-root.js +15 -4
  39. package/dist/popover/root/popover-root.d.ts +59 -6
  40. package/dist/popover/root/popover-root.js +51 -3
  41. package/dist/select/root/select-root.d.ts +2 -2
  42. package/dist/select/root/select-root.js +4 -2
  43. package/dist/steps/root/steps-root.d.ts +32 -8
  44. package/dist/steps/root/steps-root.js +70 -9
  45. package/dist/steps/step/step.d.ts +9 -1
  46. package/dist/steps/step/step.js +5 -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/video-playlist/root/video-playlist-root.d.ts +19 -1
  50. package/dist/video-playlist/root/video-playlist-root.js +14 -3
  51. package/dist/virtual-list/root/virtual-list-root.d.ts +8 -0
  52. package/dist/virtual-list/root/virtual-list-root.js +7 -2
  53. package/lint.json +1 -1
  54. package/package.json +5 -5
  55. package/types/jsx.d.ts +800 -144
  56. package/types/vue.d.ts +165 -31
  57. package/types/words.d.ts +20 -2
@@ -25,7 +25,13 @@ import { type PropertyValues, type TemplateResult } from 'lit';
25
25
  * field taking more. A draft that fails them does not send, and the composer
26
26
  * takes `:state(invalid)` and `aria-invalid` once the reader has tried to send
27
27
  * it, the form was submitted, or the reader edited the field and left it.
28
- * Tabbing past an empty composer marks nothing.
28
+ * Sending an empty required composer counts as trying. Tabbing past an empty
29
+ * composer marks nothing.
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.
29
35
  *
30
36
  * **`typing` says whether the user is writing**, and `helix-typing` is sent
31
37
  * each time it changes: when they start, and when they send or stop for
@@ -47,13 +53,14 @@ import { type PropertyValues, type TemplateResult } from 'lit';
47
53
  *
48
54
  * @csspart composer - The whole composer.
49
55
  * @csspart end - The box around the `end` slot.
56
+ * @csspart feedback - The `feedback` message, under the field while it is set.
50
57
  * @csspart field - The text field.
51
58
  * @csspart mark - The icon inside the send button.
52
59
  * @csspart send - The send button.
53
60
  * @csspart start - The box around the `start` slot.
54
61
  * @csspart tools - The row under the field.
55
62
  *
56
- * @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.
57
64
  */
58
65
  export declare class HelixHeadlessChatComposer extends HelixFormElement {
59
66
  /**
@@ -61,6 +68,12 @@ export declare class HelixHeadlessChatComposer extends HelixFormElement {
61
68
  * `registerWords('de', { 'helix-chat-composer': { sendLabel: 'Senden' } })`.
62
69
  */
63
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;
64
77
  /**
65
78
  * The field's name, read by screen readers.
66
79
  */
@@ -122,6 +135,17 @@ export declare class HelixHeadlessChatComposer extends HelixFormElement {
122
135
  private writing;
123
136
  /** Whether the reader has tried to send or submit the draft, or edited it and left. */
124
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;
125
149
  /** Whether the composer shows that its draft is wrong. */
126
150
  private shownInvalid;
127
151
  /** The pause after which the user has stopped writing. */
@@ -146,10 +170,14 @@ export declare class HelixHeadlessChatComposer extends HelixFormElement {
146
170
  * back to. `value` does not reflect, so its attribute stays as written.
147
171
  */
148
172
  private get declared();
173
+ /** @internal `feedback` marks the composer invalid, as a field's does. */
174
+ protected get validityFeedback(): string;
149
175
  /** Puts back the draft the markup declared when the form is reset. */
150
176
  formResetCallback(): void;
151
177
  /** The reader has had a go at the draft, so whether it is wrong can be shown. */
152
178
  private readonly touch;
179
+ /** The field's `change`, which counts only after the reader typed in it. */
180
+ private readonly handleChange;
153
181
  /** Brings back the draft when the browser restores the form. */
154
182
  formStateRestoreCallback(state: string | File | FormData | null): void;
155
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
  };
@@ -69,7 +77,13 @@ let HelixHeadlessChatComposer = class HelixHeadlessChatComposer extends HelixFor
69
77
  }
70
78
  send() {
71
79
  const value = this.value.trim();
72
- if (value === '' || this.isDisabled) {
80
+ if (this.isDisabled) {
81
+ return false;
82
+ }
83
+ if (value === '') {
84
+ if (this.required) {
85
+ this.touch();
86
+ }
73
87
  return false;
74
88
  }
75
89
  if (!this.internals.validity.valid) {
@@ -81,16 +95,21 @@ let HelixHeadlessChatComposer = class HelixHeadlessChatComposer extends HelixFor
81
95
  }
82
96
  this.value = '';
83
97
  this.touched = false;
98
+ this.edited = false;
84
99
  this.markTyping(false);
85
100
  return true;
86
101
  }
87
102
  get declared() {
88
103
  return this.getAttribute('value') ?? '';
89
104
  }
105
+ get validityFeedback() {
106
+ return this.feedback;
107
+ }
90
108
  formResetCallback() {
91
109
  super.formResetCallback();
92
110
  this.value = this.declared;
93
111
  this.touched = false;
112
+ this.edited = false;
94
113
  this.requestUpdate();
95
114
  }
96
115
  formStateRestoreCallback(state) {
@@ -111,7 +130,7 @@ let HelixHeadlessChatComposer = class HelixHeadlessChatComposer extends HelixFor
111
130
  if (field?.validity) {
112
131
  this.internals.setValidity(field.validity, field.validationMessage, field);
113
132
  }
114
- const invalid = this.touched && !this.internals.validity.valid;
133
+ const invalid = this.feedback !== '' || (this.touched && !this.internals.validity.valid);
115
134
  this.toggleState('invalid', invalid);
116
135
  this.internals.ariaInvalid = invalid ? 'true' : 'false';
117
136
  if (invalid !== this.shownInvalid) {
@@ -160,8 +179,9 @@ let HelixHeadlessChatComposer = class HelixHeadlessChatComposer extends HelixFor
160
179
  minlength=${this.minLength ?? nothing}
161
180
  maxlength=${this.maxLength ?? nothing}
162
181
  aria-invalid=${this.shownInvalid ? 'true' : nothing}
182
+ aria-describedby=${this.feedback === '' ? nothing : 'feedback'}
163
183
  ?disabled=${this.isDisabled}
164
- @change=${this.touch}
184
+ @change=${this.handleChange}
165
185
  @input=${this.handleInput}
166
186
  @keydown=${this.handleKey}
167
187
  ></textarea>
@@ -170,9 +190,14 @@ let HelixHeadlessChatComposer = class HelixHeadlessChatComposer extends HelixFor
170
190
  <span part="end"><slot name="end"></slot></span>
171
191
  ${this.renderSend()}
172
192
  </div>
173
- </div>`;
193
+ </div>${this.feedback === ''
194
+ ? nothing
195
+ : html `<p part="feedback" id="feedback" role="alert">${this.feedback}</p>`}`;
174
196
  }
175
197
  };
198
+ __decorate([
199
+ property()
200
+ ], HelixHeadlessChatComposer.prototype, "feedback", void 0);
176
201
  __decorate([
177
202
  words,
178
203
  property()
@@ -37,16 +37,31 @@ 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
43
50
  * there when the transcript opened. A turn is said with its author first, such
44
51
  * as "Ada: It ships today" (`arrival-label`), where its author can be named.
45
52
  *
46
- * Pass the turns as children or as `turns` data. Do not use both. Children
47
- * render from the server and suit most conversations. `turns` draws a long
53
+ * Pass the turns as children or as `turns` data. Do not use both: a chat given
54
+ * both draws every turn from `turns` and then the children, and says so once
55
+ * in the console. Children render from the server and suit most
56
+ * conversations. `turns` draws a long
48
57
  * transcript one window at a time, so the log needs a block size of its own.
49
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
+ *
50
65
  * **A conversation is built from three elements.** The chat holds the
51
66
  * transcript, a `Chat.Message` stands for each turn, and a `Chat.Composer` sits
52
67
  * in the `composer` slot. When the user sends, `helix-send` reaches the chat:
@@ -60,7 +75,12 @@ declare const HelixHeadlessChat_base: (new (...args: any[]) => import("@fusion.d
60
75
  * it takes them away. Anything slotted into `status` is drawn first.
61
76
  *
62
77
  * **Keyboard.** The transcript moves between turns the way a feed does. Each
63
- * turn says its place, such as "3 of 40", windowed or not.
78
+ * turn says its place, such as "3 of 40", windowed or not. The transcript is
79
+ * a tab stop while it holds a turn, and the turn a reader moved to is one
80
+ * too. Moving to an older turn unpins a transcript that scrolls and scrolls
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.
64
84
  *
65
85
  * | Key | What it does |
66
86
  * |---|---|
@@ -115,7 +135,7 @@ declare const HelixHeadlessChat_base: (new (...args: any[]) => import("@fusion.d
115
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.
116
136
  * @csspart live - The region where screen readers announce each turn once it has arrived.
117
137
  * @csspart loading - Shown while the history loads.
118
- * @csspart log - The transcript, which scrolls.
138
+ * @csspart log - The transcript, which scrolls. On this tier, it needs only a block size.
119
139
  * @csspart message - The message drawn for a turn from `turns`, forwarded whole.
120
140
  * @csspart message-actions - The controls on a turn from `turns`, beside its time.
121
141
  * @csspart message-avatar - The avatar box of a turn from `turns`.
@@ -142,6 +162,11 @@ declare const HelixHeadlessChat_base: (new (...args: any[]) => import("@fusion.d
142
162
  * @csspart status - The status region, outside the log.
143
163
  * @csspart turn - The wrapper around the message drawn for one turn from `turns`.
144
164
  * @csspart typing - The words that say who is typing, in the status band, while `typing` names someone.
165
+ *
166
+ * @cssstate empty - The conversation has no turns.
167
+ * @cssstate loading - Older turns are loading: `history` is `loading`.
168
+ * @cssstate pinned - The transcript follows its newest turn.
169
+ * @cssstate typing - `typing` names someone on the other side.
145
170
  */
146
171
  export declare class HelixHeadlessChat extends HelixHeadlessChat_base {
147
172
  /**
@@ -362,13 +387,16 @@ export declare class HelixHeadlessChat extends HelixHeadlessChat_base {
362
387
  /** The element that scrolls, once there is one. */
363
388
  private get log();
364
389
  /**
365
- * Makes the log a tab stop while it scrolls, and takes the stop away when it
366
- * does not.
390
+ * Makes the log a tab stop while it holds a turn or scrolls, and takes the
391
+ * stop away from an empty log.
367
392
  *
368
393
  * **A reader on a keyboard scrolls the history from the log itself.**
369
394
  * Chromium and Firefox make a box that scrolls focusable on their own, and
370
395
  * Safari does not, so there the arrow keys could not reach a long history.
371
- * A log that fits is no stop at all, so Tab goes straight to the composer.
396
+ *
397
+ * **And it is where the turns are entered.** Page Down from the log goes to
398
+ * the turn at the top of the view. A log that fits was no stop at all, so a
399
+ * conversation whose turns held no control had no way into them by keyboard.
372
400
  */
373
401
  private markScroller;
374
402
  /** Whether the reader is within `threshold` of the bottom right now. */
@@ -491,6 +519,10 @@ export declare class HelixHeadlessChat extends HelixHeadlessChat_base {
491
519
  connectedCallback(): void;
492
520
  disconnectedCallback(): void;
493
521
  protected willUpdate(changed: PropertyValues): void;
522
+ /** Whether the chat has said it was given turns both ways. */
523
+ private warnedMixed;
524
+ /** Says once that turns arrived both as children and as `turns`. */
525
+ private warnMixed;
494
526
  /**
495
527
  * Keeps the window on the same turns when the set changes around them.
496
528
  *
@@ -501,6 +533,27 @@ export declare class HelixHeadlessChat extends HelixHeadlessChat_base {
501
533
  */
502
534
  private follow;
503
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;
504
557
  /**
505
558
  * Hands each turn written as a child the words it is named with, once it
506
559
  * has hydrated, so its first render is the server's.
@@ -522,6 +575,8 @@ export declare class HelixHeadlessChat extends HelixHeadlessChat_base {
522
575
  * and never on a turn nobody moved to.
523
576
  */
524
577
  private focusTurn;
578
+ /** Draws turn `at` where it is windowed and not drawn, and visits it. */
579
+ private showTurn;
525
580
  /** The turn that is a tab stop because a reader moved to it. */
526
581
  private stop;
527
582
  /**
@@ -534,6 +589,17 @@ export declare class HelixHeadlessChat extends HelixHeadlessChat_base {
534
589
  * `tabindex` you wrote on a turn is left as it is.
535
590
  */
536
591
  private visit;
592
+ /** Where this element last scrolled the transcript to show a turn, until the scroll that follows. */
593
+ private placed;
594
+ /**
595
+ * Scrolls the transcript, and nothing around it, until `turn` is in view,
596
+ * and notes the turn at the top as the reader's place. A pinned transcript
597
+ * is left at the bottom, which is where whoever holds the pin wants it.
598
+ *
599
+ * The edge nearest the turn moves the least, and a turn taller than the
600
+ * transcript is shown from its start.
601
+ */
602
+ private reveal;
537
603
  /** The first control in the regions `names` holds, in their order. */
538
604
  private firstControlIn;
539
605
  /**
@@ -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, 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';
@@ -97,6 +97,22 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
97
97
  this.handleResume = () => {
98
98
  this.resume();
99
99
  };
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
+ };
100
116
  this.handleKeydown = (event) => {
101
117
  if (event.defaultPrevented || event.altKey || event.metaKey || event.shiftKey) {
102
118
  return;
@@ -138,6 +154,13 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
138
154
  };
139
155
  this.handleScroll = () => {
140
156
  const log = this.log;
157
+ const placed = this.placed;
158
+ this.placed = undefined;
159
+ if (log && placed !== undefined && Math.abs(log.scrollTop - placed) < 1 && !this.pinned) {
160
+ this.remember();
161
+ this.reachHistory();
162
+ return;
163
+ }
141
164
  if (this.pinned && log && log.scrollTop >= this.stuckAt - 1 && !this.atBottom) {
142
165
  this.stick();
143
166
  }
@@ -183,7 +206,8 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
183
206
  if (!log) {
184
207
  return;
185
208
  }
186
- if (log.scrollHeight > log.clientHeight) {
209
+ const turns = this.fed ? this.turns.length : this.messages.length;
210
+ if (turns > 0 || log.scrollHeight > log.clientHeight) {
187
211
  log.tabIndex = 0;
188
212
  }
189
213
  else {
@@ -414,6 +438,8 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
414
438
  connectedCallback() {
415
439
  super.connectedCallback();
416
440
  this.addEventListener('keydown', this.handleKeydown);
441
+ this.addEventListener('focusin', this.handleFocusIn);
442
+ this.addEventListener('focusout', this.handleFocusOut);
417
443
  this.settling = new MutationObserver(this.handleSettle);
418
444
  this.settling.observe(this, { attributeFilter: ['state'], subtree: true });
419
445
  this.opening = true;
@@ -427,6 +453,8 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
427
453
  disconnectedCallback() {
428
454
  super.disconnectedCallback();
429
455
  this.removeEventListener('keydown', this.handleKeydown);
456
+ this.removeEventListener('focusin', this.handleFocusIn);
457
+ this.removeEventListener('focusout', this.handleFocusOut);
430
458
  this.settling?.disconnect();
431
459
  this.settling = undefined;
432
460
  this.grower?.disconnect();
@@ -449,6 +477,22 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
449
477
  if (changed.has('turns')) {
450
478
  this.follow(changed.get('turns'));
451
479
  }
480
+ this.warnMixed();
481
+ this.toggleState('pinned', this.pinned);
482
+ this.toggleState('typing', this.typing !== '');
483
+ this.toggleState('loading', this.history === 'loading');
484
+ this.toggleState('empty', this.childrenKnown && this.turns.length === 0 && this.messages.length === 0);
485
+ }
486
+ warnMixed() {
487
+ if (this.warnedMixed || this.turns.length === 0 || this.messages.length === 0) {
488
+ return;
489
+ }
490
+ this.warnedMixed = true;
491
+ reportRuntime({
492
+ kind: 'mixed-turns',
493
+ message: `A chat was given ${this.messages.length} turns as children and ${this.turns.length} as \`turns\`, and draws the children after every turn from \`turns\`. Pass one or the other.`,
494
+ tag: this.localName,
495
+ });
452
496
  }
453
497
  follow(previous) {
454
498
  if (!previous?.length || this.turns.length === 0) {
@@ -481,6 +525,23 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
481
525
  this.remember();
482
526
  this.reachHistory();
483
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));
484
545
  }
485
546
  tellTurns() {
486
547
  const messages = this.messages;
@@ -526,9 +587,15 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
526
587
  const count = this.fed ? this.turns.length : this.messages.length;
527
588
  const log = this.log;
528
589
  const scrolls = log !== null && log !== undefined && log.scrollHeight > log.clientHeight;
529
- if (at < count - 1 && this.pinned && scrolls) {
530
- this.requestPinned(false);
590
+ if (at < count - 1 && this.pinned && scrolls && this.requestPinned(false)) {
591
+ await this.updateComplete;
592
+ }
593
+ await this.showTurn(at);
594
+ if (at === count - 1 && !this.pinned && this.atBottom) {
595
+ this.requestPinned(true);
531
596
  }
597
+ }
598
+ async showTurn(at) {
532
599
  if (this.fed) {
533
600
  const drawn = () => this.renderRoot
534
601
  ?.querySelector(`[part="turn"][data-index="${at}"]`)
@@ -559,7 +626,24 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
559
626
  this.stop = turn;
560
627
  }
561
628
  }
562
- turn.focus();
629
+ turn.focus({ preventScroll: true });
630
+ this.reveal(turn);
631
+ }
632
+ reveal(turn) {
633
+ const log = this.log;
634
+ if (!log || this.pinned) {
635
+ return;
636
+ }
637
+ const box = log.getBoundingClientRect();
638
+ const rect = turn.getBoundingClientRect();
639
+ const by = rect.top < box.top || rect.height > box.height
640
+ ? rect.top - box.top
641
+ : Math.max(0, rect.bottom - box.bottom);
642
+ if (Math.abs(by) >= 1) {
643
+ log.scrollTop += by;
644
+ this.placed = log.scrollTop;
645
+ }
646
+ this.remember();
563
647
  }
564
648
  firstControlIn(names) {
565
649
  for (const name of names) {
@@ -622,6 +706,7 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
622
706
  part="log"
623
707
  role="log"
624
708
  aria-live="off"
709
+ data-scroller
625
710
  aria-label=${this.label || this.fallbackLabel}
626
711
  aria-busy=${busy ? 'true' : nothing}
627
712
  ?data-windowed=${fed}
@@ -18,7 +18,11 @@ import '../../copy-button/index.js';
18
18
  * from the accessibility tree and left out of a copy. Set `mark-lines` to
19
19
  * mark lines a reader should look at, such as `2, 4-6`: the block draws a
20
20
  * band behind each run, placed where the lines are laid out, so it follows a
21
- * highlighter's markup and lines that wrap. Set `wrap` to wrap long lines
21
+ * highlighter's markup and lines that wrap. `added-lines` and
22
+ * `removed-lines` mark lines the same way as inserted and deleted, as
23
+ * Expressive Code's `ins` and `del` do, in code that is not a diff. The bands
24
+ * are drawn and hidden from screen readers, so say in the text around the
25
+ * block what a marked line means. Set `wrap` to wrap long lines
22
26
  * rather than scroll them; a gutter then numbers each line where it starts.
23
27
  * Set `wrappable` for a button in the header that turns `wrap` on and off,
24
28
  * pressed while the lines wrap.
@@ -93,6 +97,8 @@ import '../../copy-button/index.js';
93
97
  * @slot actions - Controls for the block, in the header before the copy button.
94
98
  * @slot copied-icon - The copy button's icon after a copy. Defaults to a check mark.
95
99
  * @slot copy-icon - The copy button's icon before a copy. Defaults to a clipboard.
100
+ * @slot expand-icon - An icon on the button that shows every line of a capped block, before its words.
101
+ * @slot label - The block's name in the header, such as a file icon beside the file name. Replaces `label` and `language` there, and draws the header.
96
102
  *
97
103
  * @event {Event} change - A file tab showed its file, whose name `file` now holds.
98
104
  * @event {HelixBeforeChangeEvent} helix-before-change - A file tab is about to show its file, with the file's name in `detail.value`. Cancelable: cancel it to keep the file shown.
@@ -134,7 +140,9 @@ import '../../copy-button/index.js';
134
140
  * @csspart header - The bar that holds the name and the copy button, shown when there is a name.
135
141
  * @csspart hunk - In a diff, a `@@` line saying where the lines under it are.
136
142
  * @csspart label - The block's name: its `label`, or its `language` where it has no label.
137
- * @csspart mark - The band behind a run of lines `mark-lines` names. The block writes where the run is laid out into `--code-block-mark-start`, `--code-block-mark-size`, and `--code-block-mark-inline-size` on it. Hidden from the accessibility tree.
143
+ * @csspart mark - The band behind a run of lines `mark-lines`, `added-lines` or `removed-lines` names. The block writes where the run is laid out into `--code-block-mark-start`, `--code-block-mark-size`, and `--code-block-mark-inline-size` on it. Hidden from the accessibility tree.
144
+ * @csspart mark-added - The band behind a run of lines `added-lines` names. Applied with `mark`.
145
+ * @csspart mark-removed - The band behind a run of lines `removed-lines` names. Applied with `mark`.
138
146
  * @csspart marker - In a diff, the `+`, `-`, or space that starts a line.
139
147
  * @csspart new - In a split diff, the new version's `<pre>`.
140
148
  * @csspart number - In a diff, a line number. In a `wrap` block with `line-numbers`, one line's number in the gutter, which the block makes as tall as the line it numbers through `--code-block-line-size`.
@@ -158,6 +166,11 @@ export declare class HelixHeadlessCodeBlock extends HelixElement {
158
166
  * `registerWords('de', { 'helix-code-block': { copyLabel: 'Code kopieren' } })`.
159
167
  */
160
168
  static words: string;
169
+ /**
170
+ * The lines to mark as inserted, as numbers and ranges counted from 1, such
171
+ * as `3-4`. Drawn as `mark-lines` is, in the added color. Not for a diff.
172
+ */
173
+ addedLines: string;
161
174
  /**
162
175
  * The name of a block that scrolls, which is a tab stop while it does.
163
176
  *
@@ -312,6 +325,11 @@ export declare class HelixHeadlessCodeBlock extends HelixElement {
312
325
  * `false`.
313
326
  */
314
327
  noCopy: boolean;
328
+ /**
329
+ * The lines to mark as deleted, as numbers and ranges counted from 1. Drawn
330
+ * as `mark-lines` is, in the removed color. Not for a diff.
331
+ */
332
+ removedLines: string;
315
333
  /**
316
334
  * The words of a button that shows a folded run of unchanged lines, in a
317
335
  * diff with `context-lines`. `{count}` is how many.
@@ -27,6 +27,7 @@ const nameOf = (block) => {
27
27
  let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
28
28
  constructor() {
29
29
  super(...arguments);
30
+ this.addedLines = '';
30
31
  this.codeLabel = 'Code';
31
32
  this.codeNameLabel = '{code}, {name}';
32
33
  this.collapseLabel = 'Show fewer lines';
@@ -43,6 +44,7 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
43
44
  this.lineNumbers = false;
44
45
  this.markLines = '';
45
46
  this.noCopy = false;
47
+ this.removedLines = '';
46
48
  this.showUnchangedLabel = '{count, plural, one {Show # unchanged line} other {Show # unchanged lines}}';
47
49
  this.wrap = false;
48
50
  this.wrapLabel = 'Wrap lines';
@@ -223,7 +225,7 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
223
225
  }
224
226
  placeLines() {
225
227
  const code = this.renderRoot.querySelector('[part="code"]');
226
- const marks = [...this.renderRoot.querySelectorAll('[part="mark"]')];
228
+ const marks = [...this.renderRoot.querySelectorAll('[part~="mark"]')];
227
229
  const numbers = [
228
230
  ...this.renderRoot.querySelectorAll('[part="gutter"] > [part="number"]'),
229
231
  ];
@@ -356,8 +358,13 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
356
358
  catch (error) {
357
359
  globalThis.reportError?.(error);
358
360
  }
359
- const runs = this.childrenKnown ? lineRuns(this.markLines) : [];
360
- const marks = runs.map(([first, last]) => html `<span part="mark" aria-hidden="true" data-lines="${first}-${last}"></span>`);
361
+ const marks = this.childrenKnown
362
+ ? [
363
+ ...lineRuns(this.markLines).map(([first, last]) => html `<span part="mark" aria-hidden="true" data-lines="${first}-${last}"></span>`),
364
+ ...lineRuns(this.addedLines).map(([first, last]) => html `<span part="mark mark-added" aria-hidden="true" data-lines="${first}-${last}"></span>`),
365
+ ...lineRuns(this.removedLines).map(([first, last]) => html `<span part="mark mark-removed" aria-hidden="true" data-lines="${first}-${last}"></span>`),
366
+ ]
367
+ : [];
361
368
  const code = highlighted === undefined
362
369
  ? html `<code part="code">${marks}<slot @slotchange=${() => this.requestUpdate()}></slot></code>`
363
370
  : html `<code part="code">${marks}${unsafeHTML(highlighted)}</code><slot
@@ -376,7 +383,7 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
376
383
  : nothing;
377
384
  const name = this.label || this.language;
378
385
  const drawn = this.tabbed ? '' : name;
379
- return html `${this.renderHeader(name, undefined, drawn)}
386
+ return html `${this.renderHeader(name, undefined, drawn, true)}
380
387
  <div part="frame">
381
388
  <pre id="block" part="block" translate="no">${gutter}${code}</pre>
382
389
  ${this.headed(drawn) ? nothing : this.renderCopy()}
@@ -396,16 +403,24 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
396
403
  this.toggleState('expanded', this.expanded);
397
404
  }}
398
405
  >
399
- ${this.expanded ? this.collapseLabel : this.expandLabel}
406
+ ${this.slotted('expand-icon') ? html `<slot name="expand-icon"></slot>` : nothing}${this.expanded ? this.collapseLabel : this.expandLabel}
400
407
  </button>`;
401
408
  }
402
409
  headed(name) {
403
- return name !== '' || (this.wrappable && !this.diff) || this.slotted('actions');
410
+ return (name !== '' ||
411
+ (this.wrappable && !this.diff) ||
412
+ this.slotted('actions') ||
413
+ (!this.diff && !this.tabbed && this.slotted('label')));
404
414
  }
405
- renderHeader(name, value, drawn = name) {
415
+ renderHeader(name, value, drawn = name, own = false) {
406
416
  const acting = this.slotted('actions');
417
+ const label = own && !this.tabbed && this.slotted('label')
418
+ ? html `<span part="label"><slot name="label">${drawn}</slot></span>`
419
+ : drawn
420
+ ? html `<span part="label">${drawn}</span>`
421
+ : nothing;
407
422
  return this.headed(drawn)
408
- ? html `<div part="header">${drawn ? html `<span part="label">${drawn}</span>` : nothing}${acting ? html `<span part="actions"><slot name="actions"></slot></span>` : nothing}${this.renderWrapToggle()}${this.renderCopy(value, name)}</div>`
423
+ ? html `<div part="header">${label}${acting ? html `<span part="actions"><slot name="actions"></slot></span>` : nothing}${this.renderWrapToggle()}${this.renderCopy(value, name)}</div>`
409
424
  : nothing;
410
425
  }
411
426
  renderWrapToggle() {
@@ -501,6 +516,9 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
501
516
  })}<slot hidden @slotchange=${() => this.requestUpdate()}></slot>`;
502
517
  }
503
518
  };
519
+ __decorate([
520
+ property({ attribute: 'added-lines' })
521
+ ], HelixHeadlessCodeBlock.prototype, "addedLines", void 0);
504
522
  __decorate([
505
523
  words,
506
524
  property({ attribute: 'code-label' })
@@ -567,6 +585,9 @@ __decorate([
567
585
  __decorate([
568
586
  property({ attribute: 'no-copy', reflect: true, type: Boolean })
569
587
  ], HelixHeadlessCodeBlock.prototype, "noCopy", void 0);
588
+ __decorate([
589
+ property({ attribute: 'removed-lines' })
590
+ ], HelixHeadlessCodeBlock.prototype, "removedLines", void 0);
570
591
  __decorate([
571
592
  words,
572
593
  property({ attribute: 'show-unchanged-label' })