@hidemikimura/chit-ui 0.2.0 → 0.3.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.
@@ -152,6 +152,7 @@ export class ComposerController {
152
152
  if (!allowed) return false;
153
153
 
154
154
  this.clear();
155
+ this.#host.handleSubmitted();
155
156
  return true;
156
157
  }
157
158
 
@@ -212,4 +213,5 @@ export class ComposerController {
212
213
  * @property {boolean} sendOnEnter
213
214
  * @property {number | undefined} maxLength
214
215
  * @property {import('../i18n/labels.js').Labels} currentLabels
216
+ * @property {() => void} handleSubmitted Called after a submit that no listener cancelled.
215
217
  */
@@ -0,0 +1,319 @@
1
+ // @ts-check
2
+
3
+ /** @import { ReactiveController, LitElement } from 'lit' */
4
+ /** @import { Position } from '../types.js' */
5
+
6
+ /** How far a pointer may travel before the gesture counts as a drag, not a click. */
7
+ const SLOP = 4;
8
+
9
+ /** How close to the viewport edge the dragged thing may come. */
10
+ const MARGIN = 8;
11
+
12
+ /** Arrow-key step, and the bigger step Shift asks for. */
13
+ const STEP = 8;
14
+ const BIG_STEP = 32;
15
+
16
+ /**
17
+ * Moves one thing — the launcher or the panel — around the viewport.
18
+ *
19
+ * What a gesture produces is not a position but a displacement: how far the
20
+ * reader has dragged the widget from wherever the theme put it, in screen
21
+ * pixels. The host keeps one such displacement for the whole widget and hands
22
+ * it to both controllers, which is what makes the two states travel together:
23
+ * drag the launcher into a corner and the panel opens in that same corner,
24
+ * because both are the theme's position plus the same shift.
25
+ *
26
+ * Each controller then turns the displacement into the terms its own corner
27
+ * uses — at a right-hand corner, moving right means a smaller offset — and
28
+ * writes it into the two custom properties the stylesheet already reads. The
29
+ * offset lives on the host as an inline property, which outranks the theme's
30
+ * sheet and the page's CSS: a reader who dragged the widget somewhere means
31
+ * it, and until the drag is cleared theirs is the most specific word on where
32
+ * it goes.
33
+ *
34
+ * @implements {ReactiveController}
35
+ */
36
+ export class DragController {
37
+ /** @type {LitElement} */
38
+ #host;
39
+
40
+ /** @type {DragOptions} */
41
+ #options;
42
+
43
+ /**
44
+ * Pointer state, only while a gesture is in flight.
45
+ *
46
+ * @type {{
47
+ * pointer: { x: number, y: number },
48
+ * displacement: { x: number, y: number },
49
+ * size: { width: number, height: number },
50
+ * corner: Position,
51
+ * base: { x: number, y: number },
52
+ * moved: boolean,
53
+ * pointerId: number,
54
+ * target: HTMLElement,
55
+ * } | undefined}
56
+ */
57
+ #from;
58
+
59
+ /** True once a gesture has passed the slop, until the click that follows it. */
60
+ #dragged = false;
61
+
62
+ /**
63
+ * @param {LitElement} host
64
+ * @param {DragOptions} options
65
+ */
66
+ constructor(host, options) {
67
+ this.#host = host;
68
+ this.#options = options;
69
+ host.addController(this);
70
+ }
71
+
72
+ hostConnected() {
73
+ // A smaller window can leave the widget hanging off the edge; placing it
74
+ // again runs it back through the clamp.
75
+ this.#onResize = () => {
76
+ const now = this.#options.current();
77
+ if (now) this.#options.onMove(now);
78
+ };
79
+ window.addEventListener('resize', this.#onResize);
80
+ }
81
+
82
+ hostDisconnected() {
83
+ if (this.#onResize) window.removeEventListener('resize', this.#onResize);
84
+ this.#onResize = undefined;
85
+ }
86
+
87
+ /** @type {(() => void) | undefined} */
88
+ #onResize;
89
+
90
+ /**
91
+ * True when the gesture that just ended was a drag. Reading it clears it, so
92
+ * the click a pointer-up fires can be skipped exactly once.
93
+ *
94
+ * @returns {boolean}
95
+ */
96
+ consumeDrag() {
97
+ const dragged = this.#dragged;
98
+ this.#dragged = false;
99
+ return dragged;
100
+ }
101
+
102
+ /**
103
+ * Put this thing where the displacement says, within the viewport.
104
+ *
105
+ * @param {{ x: number, y: number } | null} displacement
106
+ * @returns {{ x: number, y: number } | undefined} Where it ended up, when it is on screen.
107
+ */
108
+ place(displacement) {
109
+ if (!displacement) {
110
+ for (const name of [this.#options.vars.x, this.#options.vars.y]) {
111
+ this.#host.style.removeProperty(name);
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ const element = this.#options.element();
117
+ const offset = this.#toOffset(displacement);
118
+ // Nothing on screen yet: write it anyway, so the thing is already in
119
+ // place the moment it is rendered, and let the next pass clamp it.
120
+ const placed = element ? this.#clamp(offset, this.#options.size()) : offset;
121
+
122
+ this.#host.style.setProperty(this.#options.vars.x, `${placed.x}px`);
123
+ this.#host.style.setProperty(this.#options.vars.y, `${placed.y}px`);
124
+ return element ? placed : undefined;
125
+ }
126
+
127
+ /**
128
+ * Begin a drag.
129
+ *
130
+ * @param {PointerEvent} event
131
+ * @returns {void}
132
+ */
133
+ start(event) {
134
+ if (!this.#options.enabled() || event.button !== 0) return;
135
+ const element = this.#options.element();
136
+ if (!element) return;
137
+
138
+ this.#from = {
139
+ pointer: { x: event.clientX, y: event.clientY },
140
+ displacement: this.#options.current() ?? { x: 0, y: 0 },
141
+ size: this.#options.size(),
142
+ corner: this.#options.corner(),
143
+ base: this.#options.base(),
144
+ moved: false,
145
+ pointerId: event.pointerId,
146
+ target: /** @type {HTMLElement} */ (event.currentTarget),
147
+ };
148
+
149
+ // Capture keeps the moves coming even when the pointer leaves the handle.
150
+ // It throws if the pointer has already gone; the drag still works without
151
+ // it, so there is nothing to do about that but carry on.
152
+ try {
153
+ this.#from.target.setPointerCapture(event.pointerId);
154
+ } catch {
155
+ /* no capture available */
156
+ }
157
+
158
+ this.#from.target.addEventListener('pointermove', this.#onPointerMove);
159
+ this.#from.target.addEventListener('pointerup', this.#onPointerEnd);
160
+ this.#from.target.addEventListener('pointercancel', this.#onPointerEnd);
161
+ }
162
+
163
+ /** @param {PointerEvent} event */
164
+ #onPointerMove = (event) => {
165
+ const from = this.#from;
166
+ if (!from || event.pointerId !== from.pointerId) return;
167
+
168
+ const dx = event.clientX - from.pointer.x;
169
+ const dy = event.clientY - from.pointer.y;
170
+ if (!from.moved && Math.hypot(dx, dy) < SLOP) return;
171
+
172
+ from.moved = true;
173
+ // A drag must not also select text or scroll the page under the finger.
174
+ event.preventDefault();
175
+ this.#options.onMove(
176
+ this.#limit(
177
+ { x: from.displacement.x + dx, y: from.displacement.y + dy },
178
+ from.base,
179
+ from.corner,
180
+ from.size,
181
+ ),
182
+ );
183
+ };
184
+
185
+ /** @param {PointerEvent} event */
186
+ #onPointerEnd = (event) => {
187
+ const from = this.#from;
188
+ if (!from || event.pointerId !== from.pointerId) return;
189
+
190
+ from.target.removeEventListener('pointermove', this.#onPointerMove);
191
+ from.target.removeEventListener('pointerup', this.#onPointerEnd);
192
+ from.target.removeEventListener('pointercancel', this.#onPointerEnd);
193
+ try {
194
+ if (from.target.hasPointerCapture(event.pointerId)) {
195
+ from.target.releasePointerCapture(event.pointerId);
196
+ }
197
+ } catch {
198
+ /* never had it */
199
+ }
200
+
201
+ this.#from = undefined;
202
+ if (!from.moved) return;
203
+ this.#dragged = true;
204
+ this.#options.onSettle(this.#options.name);
205
+ };
206
+
207
+ /**
208
+ * Move with the arrow keys, so the same thing can be done without a pointer.
209
+ *
210
+ * @param {KeyboardEvent} event
211
+ * @returns {boolean} True when the key was used.
212
+ */
213
+ nudge(event) {
214
+ if (!this.#options.enabled()) return false;
215
+ if (event.altKey || event.ctrlKey || event.metaKey) return false;
216
+
217
+ /** @type {Record<string, [number, number]>} */
218
+ const directions = {
219
+ ArrowLeft: [-1, 0],
220
+ ArrowRight: [1, 0],
221
+ ArrowUp: [0, -1],
222
+ ArrowDown: [0, 1],
223
+ };
224
+ const direction = directions[event.key];
225
+ if (!direction) return false;
226
+
227
+ const element = this.#options.element();
228
+ if (!element) return false;
229
+
230
+ const step = event.shiftKey ? BIG_STEP : STEP;
231
+ const now = this.#options.current() ?? { x: 0, y: 0 };
232
+ event.preventDefault();
233
+ this.#options.onMove(
234
+ this.#limit(
235
+ { x: now.x + direction[0] * step, y: now.y + direction[1] * step },
236
+ this.#options.base(),
237
+ this.#options.corner(),
238
+ this.#options.size(),
239
+ ),
240
+ );
241
+ this.#options.onSettle(this.#options.name);
242
+ return true;
243
+ }
244
+
245
+ /**
246
+ * The displacement in the terms this corner counts in.
247
+ *
248
+ * @param {{ x: number, y: number }} displacement
249
+ * @param {{ x: number, y: number }} [base]
250
+ * @param {Position} [corner]
251
+ * @returns {{ x: number, y: number }}
252
+ */
253
+ #toOffset(displacement, base = this.#options.base(), corner = this.#options.corner()) {
254
+ return {
255
+ x: base.x + (corner.endsWith('right') ? -displacement.x : displacement.x),
256
+ y: base.y + (corner.startsWith('bottom') ? -displacement.y : displacement.y),
257
+ };
258
+ }
259
+
260
+ /**
261
+ * As much of a displacement as this thing can take without leaving the
262
+ * viewport. The dragged thing sets the shared displacement, so the limit
263
+ * has to be expressed there rather than only in the offset it writes.
264
+ *
265
+ * The size is the size the thing means to be, not the one it happens to
266
+ * have: the panel's own stylesheet caps it against the space between its
267
+ * corner and the far edge, so measuring it while it is being pushed into
268
+ * that corner would read back a smaller box and let it be pushed further,
269
+ * squeezing it flat instead of stopping it.
270
+ *
271
+ * @param {{ x: number, y: number }} displacement
272
+ * @param {{ x: number, y: number }} base
273
+ * @param {Position} corner
274
+ * @param {{ width: number, height: number }} size
275
+ * @returns {{ x: number, y: number }}
276
+ */
277
+ #limit(displacement, base, corner, size) {
278
+ const offset = this.#clamp(this.#toOffset(displacement, base, corner), size);
279
+ return {
280
+ x: corner.endsWith('right') ? base.x - offset.x : offset.x - base.x,
281
+ y: corner.startsWith('bottom') ? base.y - offset.y : offset.y - base.y,
282
+ };
283
+ }
284
+
285
+ /**
286
+ * @param {{ x: number, y: number }} offset
287
+ * @param {{ width: number, height: number }} size
288
+ * @returns {{ x: number, y: number }}
289
+ */
290
+ #clamp(offset, size) {
291
+ return {
292
+ x: clamp(offset.x, window.innerWidth - size.width),
293
+ y: clamp(offset.y, window.innerHeight - size.height),
294
+ };
295
+ }
296
+ }
297
+
298
+ /**
299
+ * @param {number} value
300
+ * @param {number} max The offset at which the far edge touches the viewport.
301
+ * @returns {number}
302
+ */
303
+ function clamp(value, max) {
304
+ return Math.round(Math.min(Math.max(value, MARGIN), Math.max(MARGIN, max - MARGIN)));
305
+ }
306
+
307
+ /**
308
+ * @typedef {Object} DragOptions
309
+ * @property {'launcher' | 'panel'} name
310
+ * @property {() => boolean} enabled
311
+ * @property {() => HTMLElement | null} element What moves.
312
+ * @property {() => { width: number, height: number }} size How big it means to be.
313
+ * @property {() => Position} corner Which corner the offset counts from.
314
+ * @property {() => { x: number, y: number }} base The theme's offset, before any drag.
315
+ * @property {() => { x: number, y: number } | null} current The widget's displacement now.
316
+ * @property {(displacement: { x: number, y: number }) => void} onMove A new displacement.
317
+ * @property {(name: 'launcher' | 'panel') => void} onSettle The gesture ended here.
318
+ * @property {{ x: string, y: string }} vars The custom properties to write.
319
+ */
package/src/events.js CHANGED
@@ -17,6 +17,7 @@ export const Events = Object.freeze({
17
17
  BREAKPOINT_CHANGE: 'chat-breakpoint-change',
18
18
  HOME: 'chat-home',
19
19
  ATTACH: 'chat-attach',
20
+ MOVE: 'chat-move',
20
21
  });
21
22
 
22
23
  /**
package/src/global.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ChitUI } from './chit-ui.js';
2
- import type { Message, ChatState, Trigger, Device } from './types.js';
2
+ import type { Message, ChatState, Trigger, Device, Position } from './types.js';
3
3
 
4
4
  export interface ChitUIEventMap {
5
5
  'chat-submit': CustomEvent<{ text: string }>;
@@ -17,6 +17,12 @@ export interface ChitUIEventMap {
17
17
  'chat-breakpoint-change': CustomEvent<{ device: Device }>;
18
18
  'chat-home': CustomEvent<{ trigger: Trigger }>;
19
19
  'chat-attach': CustomEvent<{ files: File[] }>;
20
+ 'chat-move': CustomEvent<{
21
+ target: 'launcher' | 'panel';
22
+ position: Position;
23
+ offset: { x: number; y: number };
24
+ displacement: { x: number; y: number };
25
+ }>;
20
26
  }
21
27
 
22
28
  declare global {
@@ -11,6 +11,8 @@
11
11
  * @property {string} attach Attach button beside the composer.
12
12
  * @property {string} input Composer textarea.
13
13
  * @property {string} typing Announced while the other side is typing.
14
+ * @property {string} loading Announced while waiting for an answer.
15
+ * @property {string} move The panel's drag handle.
14
16
  * @property {string} toLatest "Jump to newest" button.
15
17
  * @property {{ sending: string, sent: string, error: string }} status Delivery state of one's own message.
16
18
  * @property {string} charactersLeft Counter, with {n} for the number remaining.
@@ -29,6 +31,8 @@ const BUILT_IN = {
29
31
  attach: '画像や動画を添付',
30
32
  input: 'メッセージを入力',
31
33
  typing: '入力中',
34
+ loading: '応答を待っています',
35
+ move: 'チャットの位置を移動(矢印キー)',
32
36
  toLatest: '最新へ',
33
37
  status: { sending: '送信中', sent: '送信済み', error: '送信できませんでした' },
34
38
  charactersLeft: '残り {n} 文字',
@@ -44,6 +48,8 @@ const BUILT_IN = {
44
48
  attach: 'Attach an image or video',
45
49
  input: 'Type a message',
46
50
  typing: 'Typing',
51
+ loading: 'Waiting for a reply',
52
+ move: 'Move the chat (arrow keys)',
47
53
  toLatest: 'Jump to latest',
48
54
  status: { sending: 'Sending', sent: 'Sent', error: 'Not delivered' },
49
55
  charactersLeft: '{n} characters left',
@@ -34,7 +34,15 @@ export function renderLauncher(host) {
34
34
  aria-expanded=${host.state === 'open'}
35
35
  ?data-has-image=${!custom && !!closed.image}
36
36
  ?data-custom=${!!custom}
37
- @click=${() => host.toggleFromUser()}
37
+ ?data-draggable=${closed.draggable}
38
+ @pointerdown=${(/** @type {PointerEvent} */ event) => host.launcherDrag.start(event)}
39
+ @keydown=${(/** @type {KeyboardEvent} */ event) => host.launcherDrag.nudge(event)}
40
+ @click=${() => {
41
+ // The pointer-up that ends a drag is followed by a click; opening the
42
+ // panel then would punish the reader for having moved the button.
43
+ if (host.launcherDrag.consumeDrag()) return;
44
+ host.toggleFromUser();
45
+ }}
38
46
  >
39
47
  ${custom ?? html`<slot name="launcher">${defaultContent(closed)}</slot>`}
40
48
  </button>
@@ -5,6 +5,34 @@ import { renderMessage } from './message.js';
5
5
 
6
6
  /** @import { ChitUI } from '../chit-ui.js' */
7
7
 
8
+ /**
9
+ * The wait for an answer, at the end of the conversation.
10
+ *
11
+ * It stands where the typing bubble stands and replaces it while it is up:
12
+ * "typing" and "waiting for the server" are the same moment of the
13
+ * conversation, and two indicators for one wait would only ask the reader to
14
+ * work out the difference.
15
+ *
16
+ * @param {ChitUI} host
17
+ * @returns {import('lit').TemplateResult}
18
+ */
19
+ function renderLoading(host) {
20
+ const { style, text } = host.currentTheme.open.loading;
21
+ const label = text ?? host.currentLabels.loading;
22
+
23
+ return html`
24
+ <div part="loading" data-style=${style} role="status" aria-label=${label}>
25
+ ${style === 'dots'
26
+ ? html`<span class="dots" aria-hidden="true"><i></i><i></i><i></i></span>`
27
+ : nothing}
28
+ ${style === 'spinner' ? html`<span part="spinner" aria-hidden="true"></span>` : nothing}
29
+ ${style === 'text' || text
30
+ ? html`<span part="loading-text">${label}</span>`
31
+ : nothing}
32
+ </div>
33
+ `;
34
+ }
35
+
8
36
  /**
9
37
  * The scrolling conversation.
10
38
  *
@@ -22,7 +50,7 @@ import { renderMessage } from './message.js';
22
50
  export function renderMessageList(host) {
23
51
  const labels = host.currentLabels;
24
52
  const typing = host.typing;
25
- const empty = host.messages.length === 0 && !typing;
53
+ const empty = host.messages.length === 0 && !typing && !host.loading;
26
54
 
27
55
  return html`
28
56
  <div
@@ -40,15 +68,17 @@ export function renderMessageList(host) {
40
68
  (message) => message.id,
41
69
  (message) => renderMessage(host, message),
42
70
  )}
43
- ${typing
44
- ? html`
45
- <div part="typing" role="status" aria-label=${labels.typing}>
46
- ${typeof typing === 'object' && typing.html
47
- ? host.renderTypingHtml(typing.html)
48
- : html`<span class="dots" aria-hidden="true"><i></i><i></i><i></i></span>`}
49
- </div>
50
- `
51
- : nothing}
71
+ ${host.loading
72
+ ? renderLoading(host)
73
+ : typing
74
+ ? html`
75
+ <div part="typing" role="status" aria-label=${labels.typing}>
76
+ ${typeof typing === 'object' && typing.html
77
+ ? host.renderTypingHtml(typing.html)
78
+ : html`<span class="dots" aria-hidden="true"><i></i><i></i><i></i></span>`}
79
+ </div>
80
+ `
81
+ : nothing}
52
82
  </div>
53
83
  </div>
54
84
 
@@ -53,9 +53,25 @@ export function renderPanel(host) {
53
53
  function renderHeader(host, title) {
54
54
  const open = host.currentTheme.open;
55
55
  const labels = host.currentLabels;
56
+ const draggable = open.draggable && host.device === 'pc';
56
57
 
57
58
  return html`
58
- <header part="header">
59
+ <header
60
+ part="header"
61
+ ?data-draggable=${draggable}
62
+ tabindex=${draggable ? '0' : nothing}
63
+ aria-label=${draggable ? labels.move : nothing}
64
+ @pointerdown=${(/** @type {PointerEvent} */ event) => {
65
+ // Only the bar itself is a handle: a drag that starts on the close
66
+ // button would swallow the click that closes the panel.
67
+ if (/** @type {HTMLElement} */ (event.target).closest('button')) return;
68
+ host.panelDrag.start(event);
69
+ }}
70
+ @keydown=${(/** @type {KeyboardEvent} */ event) => {
71
+ if (event.target !== event.currentTarget) return;
72
+ host.panelDrag.nudge(event);
73
+ }}
74
+ >
59
75
  <slot name="header">
60
76
  <slot name="header-title">
61
77
  <span part="header-title">
@@ -23,6 +23,19 @@ export const launcherStyles = css`
23
23
  transition: filter 120ms ease;
24
24
  }
25
25
 
26
+ /*
27
+ * A draggable launcher takes the touch gestures for itself, so a finger on
28
+ * it moves the button instead of scrolling the page behind it.
29
+ */
30
+ [part~='launcher'][data-draggable] {
31
+ cursor: grab;
32
+ touch-action: none;
33
+ }
34
+
35
+ [part~='launcher'][data-draggable]:active {
36
+ cursor: grabbing;
37
+ }
38
+
26
39
  /* The image comes from the theme, so it is a background rather than an <img>. */
27
40
  [part~='launcher'][data-has-image] {
28
41
  background-image: var(--chit-launcher-image);
@@ -102,7 +102,8 @@ export const messageStyles = css`
102
102
  */
103
103
 
104
104
  [part~='bubble']::after,
105
- [part~='typing']::after {
105
+ [part~='typing']::after,
106
+ [part~='loading']::after {
106
107
  content: '';
107
108
  display: none;
108
109
  position: absolute;
@@ -118,9 +119,11 @@ export const messageStyles = css`
118
119
  */
119
120
  @supports (clip-path: path('M 0 0 Z')) {
120
121
  :host([data-bubble-tail='top']) [part~='bubble']::after,
121
- :host([data-bubble-tail='bottom']) [part~='bubble']::after,
122
122
  :host([data-bubble-tail='top']) [part~='typing']::after,
123
- :host([data-bubble-tail='bottom']) [part~='typing']::after {
123
+ :host([data-bubble-tail='top']) [part~='loading']::after,
124
+ :host([data-bubble-tail='bottom']) [part~='bubble']::after,
125
+ :host([data-bubble-tail='bottom']) [part~='typing']::after,
126
+ :host([data-bubble-tail='bottom']) [part~='loading']::after {
124
127
  display: block;
125
128
  }
126
129
 
@@ -129,15 +132,18 @@ export const messageStyles = css`
129
132
  * bubble carries one cue about who is talking, not two.
130
133
  */
131
134
  :host([data-bubble-tail='top']) [part~='bubble'],
132
- :host([data-bubble-tail='bottom']) [part~='bubble'],
133
135
  :host([data-bubble-tail='top']) [part~='typing'],
134
- :host([data-bubble-tail='bottom']) [part~='typing'] {
136
+ :host([data-bubble-tail='top']) [part~='loading'],
137
+ :host([data-bubble-tail='bottom']) [part~='bubble'],
138
+ :host([data-bubble-tail='bottom']) [part~='typing'],
139
+ :host([data-bubble-tail='bottom']) [part~='loading'] {
135
140
  border-radius: var(--chit-bubble-radius);
136
141
  }
137
142
 
138
143
  /* Their side: the leaf points left, its root inside the bubble. */
139
144
  :host([data-bubble-tail]) [part~='message-assistant'] [part~='bubble']::after,
140
- :host([data-bubble-tail]) [part~='typing']::after {
145
+ :host([data-bubble-tail]) [part~='typing']::after,
146
+ :host([data-bubble-tail]) [part~='loading']::after {
141
147
  right: calc(100% - var(--_chit-tail-root));
142
148
  background: var(--chit-color-assistant-bg);
143
149
  }
@@ -150,17 +156,20 @@ export const messageStyles = css`
150
156
  }
151
157
 
152
158
  :host([data-bubble-tail='top']) [part~='bubble']::after,
153
- :host([data-bubble-tail='top']) [part~='typing']::after {
159
+ :host([data-bubble-tail='top']) [part~='typing']::after,
160
+ :host([data-bubble-tail='top']) [part~='loading']::after {
154
161
  top: var(--_chit-tail-offset);
155
162
  }
156
163
 
157
164
  :host([data-bubble-tail='bottom']) [part~='bubble']::after,
158
- :host([data-bubble-tail='bottom']) [part~='typing']::after {
165
+ :host([data-bubble-tail='bottom']) [part~='typing']::after,
166
+ :host([data-bubble-tail='bottom']) [part~='loading']::after {
159
167
  bottom: var(--_chit-tail-offset);
160
168
  }
161
169
 
162
170
  :host([data-bubble-tail='bottom']) [part~='message-assistant'] [part~='bubble']::after,
163
- :host([data-bubble-tail='bottom']) [part~='typing']::after {
171
+ :host([data-bubble-tail='bottom']) [part~='typing']::after,
172
+ :host([data-bubble-tail='bottom']) [part~='loading']::after {
164
173
  transform: scaleY(-1);
165
174
  }
166
175
 
@@ -228,24 +237,57 @@ export const messageStyles = css`
228
237
  }
229
238
  }
230
239
 
231
- [part~='typing'] {
240
+ [part~='typing'],
241
+ [part~='loading'] {
232
242
  position: relative;
233
243
  align-self: flex-start;
234
244
  /* Indented past the speaker's icon, when the theme gives them one. */
235
245
  margin-inline-start: var(--_chit-speaker-gutter, 0px);
236
246
  padding: 0.7em 0.9em;
247
+ /*
248
+ * Stated rather than left to the font's idea of "normal", so that both
249
+ * indicators are the same height whatever the page's typeface does, and
250
+ * so the spinner has a line box it is known to fit inside.
251
+ */
252
+ line-height: 1.5;
237
253
  border-radius: var(--chit-bubble-radius);
238
254
  border-bottom-left-radius: 4px;
239
255
  background: var(--chit-color-assistant-bg);
240
256
  color: var(--chit-color-assistant-text);
241
257
  }
242
258
 
243
- [part~='typing'] .dots {
259
+ /*
260
+ * The wait is laid out inline, like the typing bubble it stands in for. A
261
+ * flex box has no line box of its own, so dots or a spinner alone would sit
262
+ * in a noticeably shorter bubble than one holding a line of text.
263
+ */
264
+ [part~='loading'] > * + * {
265
+ margin-inline-start: 0.5em;
266
+ }
267
+
268
+ [part~='loading'] [part~='spinner'] {
269
+ display: inline-block;
270
+ /*
271
+ * Centred on the line rather than sitting on the baseline: at 1.1em it
272
+ * then fits inside the line box the text would have made on its own, so
273
+ * a spinner bubble is exactly as tall as a typing bubble.
274
+ */
275
+ vertical-align: middle;
276
+ color: var(--chit-color-system-text);
277
+ }
278
+
279
+ [part~='loading-text'] {
280
+ font-size: 0.9em;
281
+ }
282
+
283
+ [part~='typing'] .dots,
284
+ [part~='loading'] .dots {
244
285
  display: inline-flex;
245
286
  gap: 0.25em;
246
287
  }
247
288
 
248
- [part~='typing'] .dots i {
289
+ [part~='typing'] .dots i,
290
+ [part~='loading'] .dots i {
249
291
  width: 0.4em;
250
292
  height: 0.4em;
251
293
  border-radius: 50%;
@@ -254,10 +296,12 @@ export const messageStyles = css`
254
296
  animation: chit-typing 1.2s ease-in-out infinite;
255
297
  }
256
298
 
257
- [part~='typing'] .dots i:nth-child(2) {
299
+ [part~='typing'] .dots i:nth-child(2),
300
+ [part~='loading'] .dots i:nth-child(2) {
258
301
  animation-delay: 0.15s;
259
302
  }
260
- [part~='typing'] .dots i:nth-child(3) {
303
+ [part~='typing'] .dots i:nth-child(3),
304
+ [part~='loading'] .dots i:nth-child(3) {
261
305
  animation-delay: 0.3s;
262
306
  }
263
307
 
@@ -299,7 +343,8 @@ export const messageStyles = css`
299
343
 
300
344
  @media (prefers-reduced-motion: reduce) {
301
345
  [part~='cursor'],
302
- [part~='typing'] .dots i {
346
+ [part~='typing'] .dots i,
347
+ [part~='loading'] .dots i {
303
348
  animation: none;
304
349
  }
305
350
  }