@hidemikimura/chit-ui 0.1.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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +440 -0
  3. package/dist/chit-ui.iife.min.js +964 -0
  4. package/dist/chit-ui.iife.min.js.map +1 -0
  5. package/dist/chit-ui.min.js +964 -0
  6. package/dist/chit-ui.min.js.map +1 -0
  7. package/dist/types/bundle.d.ts +7 -0
  8. package/dist/types/chit-ui.d.ts +251 -0
  9. package/dist/types/controllers/breakpoint-controller.d.ts +29 -0
  10. package/dist/types/controllers/composer-controller.d.ts +54 -0
  11. package/dist/types/controllers/scroll-controller.d.ts +44 -0
  12. package/dist/types/controllers/state-controller.d.ts +61 -0
  13. package/dist/types/controllers/theme-controller.d.ts +47 -0
  14. package/dist/types/element.d.ts +1 -0
  15. package/dist/types/events.d.ts +28 -0
  16. package/dist/types/global.d.ts +25 -0
  17. package/dist/types/i18n/labels.d.ts +68 -0
  18. package/dist/types/index.d.ts +4 -0
  19. package/dist/types/render/composer.d.ts +15 -0
  20. package/dist/types/render/content.d.ts +75 -0
  21. package/dist/types/render/launcher.d.ts +18 -0
  22. package/dist/types/render/message-list.d.ts +17 -0
  23. package/dist/types/render/message.d.ts +14 -0
  24. package/dist/types/render/panel.d.ts +10 -0
  25. package/dist/types/styles/adopted-sheet.d.ts +20 -0
  26. package/dist/types/styles/composer.css.d.ts +1 -0
  27. package/dist/types/styles/content.css.d.ts +9 -0
  28. package/dist/types/styles/host.css.d.ts +9 -0
  29. package/dist/types/styles/launcher.css.d.ts +1 -0
  30. package/dist/types/styles/message.css.d.ts +1 -0
  31. package/dist/types/styles/panel.css.d.ts +1 -0
  32. package/dist/types/theme/default-theme.d.ts +97 -0
  33. package/dist/types/theme/merge-theme.d.ts +33 -0
  34. package/dist/types/theme/theme-to-css.d.ts +24 -0
  35. package/dist/types/types.d.ts +268 -0
  36. package/package.json +71 -0
  37. package/src/bundle.js +11 -0
  38. package/src/chit-ui.js +548 -0
  39. package/src/controllers/breakpoint-controller.js +82 -0
  40. package/src/controllers/composer-controller.js +215 -0
  41. package/src/controllers/scroll-controller.js +252 -0
  42. package/src/controllers/state-controller.js +316 -0
  43. package/src/controllers/theme-controller.js +75 -0
  44. package/src/element.js +4 -0
  45. package/src/events.js +33 -0
  46. package/src/global.d.ts +25 -0
  47. package/src/i18n/labels.js +72 -0
  48. package/src/index.js +9 -0
  49. package/src/render/composer.js +72 -0
  50. package/src/render/content.js +211 -0
  51. package/src/render/launcher.js +64 -0
  52. package/src/render/message-list.js +67 -0
  53. package/src/render/message.js +82 -0
  54. package/src/render/panel.js +77 -0
  55. package/src/styles/adopted-sheet.js +72 -0
  56. package/src/styles/composer.css.js +108 -0
  57. package/src/styles/content.css.js +119 -0
  58. package/src/styles/host.css.js +160 -0
  59. package/src/styles/launcher.css.js +139 -0
  60. package/src/styles/message.css.js +192 -0
  61. package/src/styles/panel.css.js +126 -0
  62. package/src/theme/default-theme.js +111 -0
  63. package/src/theme/merge-theme.js +100 -0
  64. package/src/theme/theme-to-css.js +94 -0
  65. package/src/types.js +143 -0
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@hidemikimura/chit-ui",
3
+ "version": "0.1.0",
4
+ "description": "Lit-based chat widget UI: launcher, panel, themable, any HTML or Lit component as a message",
5
+ "license": "MIT",
6
+ "author": "Hidemi Kimura",
7
+ "type": "module",
8
+ "main": "./src/index.js",
9
+ "types": "./dist/types/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/types/index.d.ts",
13
+ "default": "./src/index.js"
14
+ },
15
+ "./element.js": {
16
+ "types": "./dist/types/element.d.ts",
17
+ "default": "./src/element.js"
18
+ },
19
+ "./dist/*": "./dist/*",
20
+ "./package.json": "./package.json"
21
+ },
22
+ "files": [
23
+ "src",
24
+ "dist",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "sideEffects": [
29
+ "./src/index.js",
30
+ "./src/bundle.js"
31
+ ],
32
+ "keywords": [
33
+ "chat",
34
+ "chat-ui",
35
+ "chat-widget",
36
+ "lit",
37
+ "web-components",
38
+ "custom-elements"
39
+ ],
40
+ "peerDependencies": {
41
+ "lit": "^3.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "@open-wc/testing": "^4.0.0",
45
+ "@rollup/plugin-node-resolve": "^15.3.0",
46
+ "@rollup/plugin-terser": "^0.4.4",
47
+ "@types/mocha": "^10.0.9",
48
+ "@web/dev-server": "^0.4.6",
49
+ "@web/test-runner": "^0.19.0",
50
+ "@web/test-runner-playwright": "^0.11.0",
51
+ "lit": "^3.2.0",
52
+ "playwright": "^1.48.0",
53
+ "rollup": "^4.24.0",
54
+ "typescript": "^5.6.0"
55
+ },
56
+ "scripts": {
57
+ "dev": "web-dev-server --node-resolve --open /demo/ --watch",
58
+ "typecheck": "tsc --noEmit",
59
+ "types": "tsc -p tsconfig.build.json && node scripts/finish-types.mjs",
60
+ "bundle": "rollup -c && node scripts/check-size.js",
61
+ "build": "npm run typecheck && npm run types && npm run bundle",
62
+ "test": "web-test-runner \"test/**/*.test.js\" --node-resolve",
63
+ "prepublishOnly": "npm run build && npm test",
64
+ "audit:a11y": "node scripts/audit-a11y.mjs",
65
+ "check": "npm run typecheck && npm test && npm run audit:a11y"
66
+ },
67
+ "publishConfig": {
68
+ "access": "public",
69
+ "registry": "https://registry.npmjs.org/"
70
+ }
71
+ }
package/src/bundle.js ADDED
@@ -0,0 +1,11 @@
1
+ // @ts-check
2
+ // Entry for the single-file builds: the widget plus the Lit API it was built
3
+ // against, so consumers of the bundle define their message components with the
4
+ // very same Lit instance.
5
+ export * from './index.js';
6
+ export { LitElement, html, css, svg, nothing, render } from 'lit';
7
+ export { repeat } from 'lit/directives/repeat.js';
8
+ export { unsafeHTML } from 'lit/directives/unsafe-html.js';
9
+ export { classMap } from 'lit/directives/class-map.js';
10
+ export { styleMap } from 'lit/directives/style-map.js';
11
+ export { live } from 'lit/directives/live.js';
package/src/chit-ui.js ADDED
@@ -0,0 +1,548 @@
1
+ // @ts-check
2
+ import { LitElement, html, nothing } from 'lit';
3
+
4
+ import { unsafeHTML } from 'lit/directives/unsafe-html.js';
5
+
6
+ import { StateController } from './controllers/state-controller.js';
7
+ import { BreakpointController } from './controllers/breakpoint-controller.js';
8
+ import { ThemeController } from './controllers/theme-controller.js';
9
+ import { ScrollController } from './controllers/scroll-controller.js';
10
+ import { ComposerController } from './controllers/composer-controller.js';
11
+ import { renderLauncher } from './render/launcher.js';
12
+ import { renderPanel } from './render/panel.js';
13
+ import { resolveLabels, resolveLocale } from './i18n/labels.js';
14
+ import { Events, emit } from './events.js';
15
+ import { componentInstance, contentKey, pruneComponents } from './render/content.js';
16
+ import { AdoptedSheet } from './styles/adopted-sheet.js';
17
+ import { hostStyles } from './styles/host.css.js';
18
+ import { launcherStyles } from './styles/launcher.css.js';
19
+ import { panelStyles } from './styles/panel.css.js';
20
+ import { messageStyles } from './styles/message.css.js';
21
+ import { contentStyles } from './styles/content.css.js';
22
+ import { composerStyles } from './styles/composer.css.js';
23
+
24
+ /** @import { Message, Theme, ChatState, Trigger, Effect, Device, ResolvedTheme } from './types.js' */
25
+ /** @import { Labels } from './i18n/labels.js' */
26
+
27
+ /**
28
+ * The chat widget.
29
+ *
30
+ * One custom element, one shadow root. Everything inside is drawn by plain
31
+ * functions in `render/` and driven by the reactive controllers in
32
+ * `controllers/`; no nested custom elements, so `::part()` and the theme's
33
+ * custom properties reach every corner without being forwarded.
34
+ *
35
+ * @element chit-ui
36
+ *
37
+ * @slot launcher - Replaces the whole closed-state button content.
38
+ * @slot header - Replaces the panel header.
39
+ * @slot header-title - Replaces only the title area of the header.
40
+ * @slot header-actions - Extra buttons beside the close button.
41
+ * @slot footer - Below the composer (disclaimers, attribution).
42
+ */
43
+ export class ChitUI extends LitElement {
44
+ /** @override */
45
+ static styles = [
46
+ hostStyles,
47
+ launcherStyles,
48
+ panelStyles,
49
+ messageStyles,
50
+ contentStyles,
51
+ composerStyles,
52
+ ];
53
+
54
+ /** @override */
55
+ static properties = {
56
+ state: { type: String, reflect: true, noAccessor: true },
57
+ theme: { type: Object },
58
+ messages: { type: Array },
59
+ typing: { type: Object },
60
+ busy: { type: Boolean, reflect: true },
61
+ inputDisabled: { type: Boolean, reflect: true, attribute: 'input-disabled' },
62
+ inputHidden: { type: Boolean, reflect: true, attribute: 'input-hidden' },
63
+ placeholder: { type: String, reflect: true },
64
+ sendOnEnter: { type: Boolean, reflect: true, attribute: 'send-on-enter' },
65
+ maxLength: { type: Number, reflect: true, attribute: 'max-length' },
66
+ focusOnOpen: { type: String, reflect: true, attribute: 'focus-on-open' },
67
+ locale: { type: String, reflect: true },
68
+ labels: { type: Object },
69
+ messageStyles: { type: String },
70
+ };
71
+
72
+ /** @type {ChatState} */
73
+ #state = 'closed';
74
+
75
+ /**
76
+ * Set by the widget's own event handlers just before they move the state, so
77
+ * the events can say the move came from a person rather than from code.
78
+ *
79
+ * @type {Trigger}
80
+ */
81
+ #trigger = 'api';
82
+
83
+ /** @type {StateController} */
84
+ #transitions;
85
+
86
+ /** @type {BreakpointController} */
87
+ #breakpoint;
88
+
89
+ /** @type {ThemeController} */
90
+ #themes;
91
+
92
+ /** @type {ScrollController} */
93
+ #scroll;
94
+
95
+ /** @type {ComposerController} */
96
+ #composer;
97
+
98
+ /** Consumer CSS for the inside of message bubbles. */
99
+ #messageSheet = new AdoptedSheet('data-chit-message-styles');
100
+
101
+ /** What each message's content was built from last render, by id. */
102
+ /** @type {Map<string, unknown>} */
103
+ #renderedContent = new Map();
104
+
105
+ /** The in-flight transition, so the methods can hand it back to the caller. */
106
+ /** @type {Promise<boolean> | null} */
107
+ #settled = null;
108
+
109
+ constructor() {
110
+ super();
111
+
112
+ /** @type {Theme} Partial; unset keys fall back to the default theme. */
113
+ this.theme = {};
114
+
115
+ /** @type {Message[]} Rendered as given. The library never mutates this. */
116
+ this.messages = [];
117
+
118
+ /** @type {boolean | { html: string }} Show the "typing" bubble. */
119
+ this.typing = false;
120
+
121
+ /** @type {boolean} Lock the composer while a reply is in flight. */
122
+ this.busy = false;
123
+
124
+ /** @type {boolean} */
125
+ this.inputDisabled = false;
126
+
127
+ /** @type {boolean} */
128
+ this.inputHidden = false;
129
+
130
+ /** @type {string | undefined} */
131
+ this.placeholder = undefined;
132
+
133
+ /** @type {boolean} */
134
+ this.sendOnEnter = true;
135
+
136
+ /** @type {number | undefined} */
137
+ this.maxLength = undefined;
138
+
139
+ /**
140
+ * Focus the composer once the open transition finishes. 'auto' focuses on
141
+ * PC only, so a phone keyboard never pops up unasked.
142
+ *
143
+ * Named focusOnOpen rather than autofocus: HTMLElement.autofocus is a
144
+ * standard boolean property and cannot carry a third value.
145
+ *
146
+ * @type {'auto' | 'always' | 'never'}
147
+ */
148
+ this.focusOnOpen = 'auto';
149
+
150
+ /** @type {string | undefined} */
151
+ this.locale = undefined;
152
+
153
+ /** @type {Partial<Labels> | undefined} Overrides for the built-in UI strings. */
154
+ this.labels = undefined;
155
+
156
+ /** @type {string} Extra CSS applied inside message content. */
157
+ this.messageStyles = '';
158
+
159
+ /** @type {((html: string) => string) | undefined} Applied to `html` messages only. */
160
+ this.sanitize = undefined;
161
+
162
+ /** @type {((time: Date) => string) | undefined} */
163
+ this.formatTime = undefined;
164
+
165
+ this.#transitions = new StateController(this, {
166
+ animation: (from, to, phase) => this.#animationFor(from, to, phase),
167
+ keepsLauncher: () => this.currentTheme.open.launcher === 'visible',
168
+ onOpened: () => this.#applyOpenFocus(),
169
+ });
170
+
171
+ this.#breakpoint = new BreakpointController(this);
172
+ this.#themes = new ThemeController(this, { device: () => this.#breakpoint.device });
173
+ this.addController(this.#themes);
174
+ this.#scroll = new ScrollController(this, {
175
+ behavior: () => this.currentTheme.open.animation.scroll,
176
+ });
177
+ this.#composer = new ComposerController(this);
178
+ }
179
+
180
+ /**
181
+ * Current state. Assigning it starts the transition, exactly as calling the
182
+ * matching method would.
183
+ *
184
+ * @type {ChatState}
185
+ */
186
+ get state() {
187
+ return this.#state;
188
+ }
189
+
190
+ set state(value) {
191
+ const previous = this.#state;
192
+ if (value === previous) return;
193
+ this.#state = value;
194
+ this.requestUpdate('state', previous);
195
+
196
+ // A transition the controller itself is applying (a revert, or the tail of
197
+ // a method call) must not start another one.
198
+ if (!this.#transitions || this.#transitions.applying) return;
199
+ const trigger = this.#trigger;
200
+ this.#trigger = 'api';
201
+
202
+ const settled = this.#transitions.request(value, trigger);
203
+ this.#settled = settled;
204
+ void settled.then(() => {
205
+ if (this.#settled === settled) this.#settled = null;
206
+ });
207
+ }
208
+
209
+ /** @returns {Device} Which theme bucket the viewport currently falls in. */
210
+ get device() {
211
+ return this.#breakpoint.device;
212
+ }
213
+
214
+ /**
215
+ * The theme in force for this device, every gap filled in.
216
+ *
217
+ * @returns {ResolvedTheme}
218
+ */
219
+ get currentTheme() {
220
+ return this.#themes.current;
221
+ }
222
+
223
+ /** @returns {Labels} */
224
+ get currentLabels() {
225
+ return resolveLabels(this.locale, this.labels);
226
+ }
227
+
228
+ /**
229
+ * The language in force: the `locale` property, else the document's.
230
+ * Labels and timestamps both read it, so a page in Japanese does not end up
231
+ * with Japanese buttons and American clock times.
232
+ *
233
+ * @returns {string | undefined}
234
+ */
235
+ get resolvedLocale() {
236
+ return resolveLocale(this.locale);
237
+ }
238
+
239
+ /** @returns {ChatState} What the DOM is showing, which lags `state` while animating. */
240
+ get renderedState() {
241
+ return this.#transitions.renderedState;
242
+ }
243
+
244
+ /** @returns {boolean} True when messages arrived while the reader was scrolled up. */
245
+ get hasUnseen() {
246
+ return this.#scroll.hasUnseen;
247
+ }
248
+
249
+ /**
250
+ * What is currently typed in the composer.
251
+ *
252
+ * Not a reactive property on purpose: a keystroke would otherwise re-render
253
+ * the whole shadow tree, every message included.
254
+ *
255
+ * @type {string}
256
+ */
257
+ get value() {
258
+ return this.#composer.value;
259
+ }
260
+
261
+ set value(next) {
262
+ this.#composer.value = next;
263
+ }
264
+
265
+ /** @returns {boolean} Whether what is typed could be sent right now. */
266
+ get canSend() {
267
+ return this.#composer.canSend;
268
+ }
269
+
270
+ // --- public API -------------------------------------------------------
271
+
272
+ /** @returns {Promise<boolean>} Resolves once the open animation has finished. */
273
+ open() {
274
+ return this.#go('open', 'api');
275
+ }
276
+
277
+ /** @returns {Promise<boolean>} */
278
+ close() {
279
+ return this.#go('closed', 'api');
280
+ }
281
+
282
+ /** @returns {Promise<boolean>} */
283
+ hide() {
284
+ return this.#go('hidden', 'api');
285
+ }
286
+
287
+ /** @returns {Promise<boolean>} From `hidden` back to the launcher. A no-op otherwise. */
288
+ show() {
289
+ return this.#state === 'hidden' ? this.#go('closed', 'api') : Promise.resolve(true);
290
+ }
291
+
292
+ /** @returns {Promise<boolean>} */
293
+ toggle() {
294
+ return this.#go(this.state === 'open' ? 'closed' : 'open', 'api');
295
+ }
296
+
297
+ /**
298
+ * Send what is in the composer, or the given text, as `chat-submit`.
299
+ *
300
+ * Adding the message to `messages` stays the consumer's job; the widget only
301
+ * reports that someone pressed send.
302
+ *
303
+ * @param {string} [text]
304
+ * @returns {boolean} False when there was nothing to send.
305
+ */
306
+ submit(text) {
307
+ return this.#composer.submit(text);
308
+ }
309
+
310
+ /** Empty the composer and put the caret back in it. */
311
+ clearInput() {
312
+ this.#composer.clear();
313
+ }
314
+
315
+ /**
316
+ * Scroll the conversation to the newest message.
317
+ *
318
+ * @param {{ smooth?: boolean }} [options]
319
+ */
320
+ scrollToBottom(options) {
321
+ this.#scroll.scrollToBottom(options);
322
+ }
323
+
324
+ /**
325
+ * The content container of one message, once it has been drawn.
326
+ *
327
+ * @param {string} id
328
+ * @returns {HTMLElement | null}
329
+ */
330
+ getMessageElement(id) {
331
+ return /** @type {HTMLElement | null} */ (
332
+ this.renderRoot.querySelector(`[part~="message"][data-id="${CSS.escape(id)}"] [part~="message-content"]`)
333
+ );
334
+ }
335
+
336
+ /**
337
+ * Put the caret in the composer.
338
+ *
339
+ * This obeys the caller, not `focusOnOpen`: someone who calls it has asked
340
+ * for focus. `focusOnOpen` only governs what happens by itself on open.
341
+ *
342
+ * @returns {boolean} False when there is no composer to focus.
343
+ */
344
+ focusInput() {
345
+ const input = /** @type {HTMLElement | null} */ (
346
+ this.renderRoot.querySelector('[part~="input"]')
347
+ );
348
+ if (!input) return false;
349
+ input.focus();
350
+ return true;
351
+ }
352
+
353
+ /**
354
+ * What the widget does with focus when the panel finishes opening.
355
+ *
356
+ * 'never' means exactly that: focus is left wherever the person had it, so a
357
+ * widget that opens on its own does not steal the caret out of a form on the
358
+ * page. Otherwise the composer takes it, or the panel does when there is no
359
+ * composer, so that Esc and screen-reader navigation start inside the dialog.
360
+ */
361
+ #applyOpenFocus() {
362
+ if (this.focusOnOpen === 'never') return;
363
+ if (this.focusOnOpen === 'auto' && this.device === 'mobile') return;
364
+ if (this.focusInput()) return;
365
+
366
+ const panel = /** @type {HTMLElement | null} */ (
367
+ this.renderRoot.querySelector('[part~="panel"]')
368
+ );
369
+ panel?.focus();
370
+ }
371
+
372
+ // --- composer plumbing, called by the render functions ------------------
373
+
374
+ /** @param {Event} event */
375
+ handleInput(event) {
376
+ this.#composer.onInput(event);
377
+ }
378
+
379
+ /** @param {KeyboardEvent} event */
380
+ handleKeydown(event) {
381
+ this.#composer.onKeydown(event);
382
+ }
383
+
384
+ /** @param {boolean} started */
385
+ handleComposition(started) {
386
+ if (started) this.#composer.onCompositionStart();
387
+ else this.#composer.onCompositionEnd();
388
+ }
389
+
390
+ // --- internal ---------------------------------------------------------
391
+
392
+ /**
393
+ * Render a consumer-supplied typing bubble.
394
+ *
395
+ * @param {string} markup
396
+ * @returns {unknown}
397
+ */
398
+ renderTypingHtml(markup) {
399
+ return unsafeHTML(this.sanitize ? this.sanitize(markup) : markup);
400
+ }
401
+
402
+ /**
403
+ * Re-publish a click inside a message as `chat-message-click`.
404
+ *
405
+ * `composedPath()[0]` rather than `event.target`: the target is retargeted to
406
+ * the message container once the event leaves a component's shadow root, and
407
+ * the consumer wants the element actually clicked.
408
+ *
409
+ * @param {MouseEvent} event
410
+ */
411
+ handleMessageClick(event) {
412
+ const path = event.composedPath();
413
+ const article = /** @type {HTMLElement | undefined} */ (
414
+ path.find(
415
+ (node) =>
416
+ node instanceof HTMLElement && node.getAttribute('part')?.split(/\s+/).includes('message'),
417
+ )
418
+ );
419
+ if (!article) return;
420
+
421
+ const id = article.dataset.id;
422
+ const message = this.messages.find((candidate) => candidate.id === id);
423
+ if (!message) return;
424
+
425
+ const target = /** @type {Element} */ (path[0]);
426
+ const allowed = emit(
427
+ this,
428
+ Events.MESSAGE_CLICK,
429
+ { message, target, originalEvent: event },
430
+ { cancelable: true },
431
+ );
432
+ if (!allowed) event.preventDefault();
433
+ }
434
+
435
+ /** Called by the launcher; the resulting events report `trigger: 'user'`. */
436
+ toggleFromUser() {
437
+ return this.#go(this.state === 'open' ? 'closed' : 'open', 'user');
438
+ }
439
+
440
+ /** Called by the close button and by Esc. */
441
+ closeFromUser() {
442
+ return this.#go('closed', 'user');
443
+ }
444
+
445
+ /**
446
+ * @param {ChatState} to
447
+ * @param {Trigger} trigger
448
+ * @returns {Promise<boolean>}
449
+ */
450
+ #go(to, trigger) {
451
+ if (to !== this.#state) {
452
+ this.#trigger = trigger;
453
+ this.state = to;
454
+ }
455
+ return this.#settled ?? Promise.resolve(true);
456
+ }
457
+
458
+ /**
459
+ * Which effect and how long, for one leg of a transition.
460
+ *
461
+ * Leaving for `hidden` uses the hidden theme's exit; every other leg uses the
462
+ * theme of the state being left or entered.
463
+ *
464
+ * @param {ChatState} from
465
+ * @param {ChatState} to
466
+ * @param {'enter' | 'exit'} phase
467
+ * @returns {{ effect: Effect, duration: number }}
468
+ */
469
+ #animationFor(from, to, phase) {
470
+ const theme = this.currentTheme;
471
+
472
+ if (phase === 'exit' && to === 'hidden') {
473
+ return { effect: theme.hidden.animation.exit, duration: theme.hidden.animation.duration };
474
+ }
475
+
476
+ const source = (phase === 'exit' ? from : to) === 'open' ? theme.open : theme.closed;
477
+ return {
478
+ effect: phase === 'exit' ? source.animation.exit : source.animation.enter,
479
+ duration: source.animation.duration,
480
+ };
481
+ }
482
+
483
+ /** @override */
484
+ willUpdate() {
485
+ // Order matters: the breakpoint decides the device, and the device decides
486
+ // which `closed` settings the theme resolves to.
487
+ this.#breakpoint.observe(ThemeController.breakpointOf(this.theme));
488
+ this.#themes.apply(this.theme, this.#breakpoint.device);
489
+
490
+ this.#messageSheet.write(this.messageStyles ?? '', this.renderRoot);
491
+
492
+ const theme = this.currentTheme;
493
+ this.dataset.launcherPosition = theme.closed.position;
494
+ this.dataset.panelPosition = theme.open.position;
495
+ this.dataset.idle = theme.closed.animation.idle;
496
+ }
497
+
498
+ /** @override */
499
+ updated() {
500
+ const live = new Set(this.messages.map((message) => message.id));
501
+ pruneComponents(this, live);
502
+ this.#announceRenders(live);
503
+ }
504
+
505
+ /**
506
+ * Fire `chat-message-render` for messages that were drawn for the first time
507
+ * or whose content changed.
508
+ *
509
+ * Only the content matters here: a status or streaming flag flipping redraws
510
+ * the bubble but leaves the consumer's markup alone, and firing then would
511
+ * make listeners re-bind on every token of a streamed reply.
512
+ *
513
+ * @param {Set<string>} live
514
+ */
515
+ #announceRenders(live) {
516
+ for (const id of this.#renderedContent.keys()) {
517
+ if (!live.has(id)) this.#renderedContent.delete(id);
518
+ }
519
+
520
+ for (const message of this.messages) {
521
+ const key = contentKey(message);
522
+ if (this.#renderedContent.has(message.id) && Object.is(this.#renderedContent.get(message.id), key)) {
523
+ continue;
524
+ }
525
+ this.#renderedContent.set(message.id, key);
526
+
527
+ const element = this.getMessageElement(message.id);
528
+ if (!element) continue;
529
+ emit(this, Events.MESSAGE_RENDER, {
530
+ message,
531
+ element,
532
+ instance: componentInstance(this, message.id),
533
+ });
534
+ }
535
+ }
536
+
537
+ /** @override */
538
+ render() {
539
+ const shown = this.renderedState;
540
+ const showLauncher =
541
+ shown === 'closed' || (shown === 'open' && this.currentTheme.open.launcher === 'visible');
542
+ const showPanel = shown === 'open';
543
+
544
+ return html`
545
+ ${showLauncher ? renderLauncher(this) : nothing} ${showPanel ? renderPanel(this) : nothing}
546
+ `;
547
+ }
548
+ }