@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
@@ -0,0 +1,211 @@
1
+ // @ts-check
2
+ import { unsafeHTML } from 'lit/directives/unsafe-html.js';
3
+
4
+ /** @import { ChitUI } from '../chit-ui.js' */
5
+ /** @import { Message } from '../types.js' */
6
+
7
+ /**
8
+ * @typedef {Object} ComponentEntry
9
+ * @property {(new () => HTMLElement) | string} source What `component` held when this was built.
10
+ * @property {HTMLElement} element
11
+ * @property {Record<string, unknown>} props Last props written to the element.
12
+ */
13
+
14
+ /**
15
+ * Per-host component instances, keyed by a caller-chosen string.
16
+ *
17
+ * Messages key by `msg:<id>`; the launcher has its own key. Keeping one cache
18
+ * means one place decides when an element is rebuilt and when its props are
19
+ * merely re-assigned.
20
+ */
21
+ const INSTANCES = new WeakMap();
22
+
23
+ /**
24
+ * The cache key for a message's component.
25
+ *
26
+ * @param {string} id
27
+ * @returns {string}
28
+ */
29
+ const messageKey = (id) => `msg:${id}`;
30
+
31
+ /** The cache key for the closed-state component. */
32
+ export const LAUNCHER_KEY = 'launcher';
33
+
34
+ /** The five ways a message can carry its content. Exactly one may be present. */
35
+ const CONTENT_FIELDS = /** @type {const} */ ([
36
+ 'text',
37
+ 'html',
38
+ 'template',
39
+ 'element',
40
+ 'component',
41
+ ]);
42
+
43
+ /**
44
+ * Which form a message uses. Also drives the `data-content` attribute, because
45
+ * plain text is the one form whose whitespace has to survive.
46
+ *
47
+ * @param {Message} message
48
+ * @returns {'text' | 'html' | 'template' | 'element' | 'component'}
49
+ */
50
+ export function contentField(message) {
51
+ const present = CONTENT_FIELDS.filter((field) => field in message);
52
+ if (present.length === 1) return present[0];
53
+ if (present.length === 0) {
54
+ throw new Error(
55
+ `ChitUI: message "${message.id}" has no content. Give it one of html, template, element or component.`,
56
+ );
57
+ }
58
+ throw new Error(
59
+ `ChitUI: message "${message.id}" has ${present.join(' and ')}. Give it exactly one.`,
60
+ );
61
+ }
62
+
63
+ /**
64
+ * The value a message's content is built from. Used to decide whether a
65
+ * message actually changed, so `chat-message-render` does not fire on every
66
+ * unrelated update.
67
+ *
68
+ * @param {Message} message
69
+ * @returns {unknown}
70
+ */
71
+ export function contentKey(message) {
72
+ return /** @type {Record<string, unknown>} */ (message)[contentField(message)];
73
+ }
74
+
75
+ /**
76
+ * The registered tag name for a `component`.
77
+ *
78
+ * Registration stays the consumer's job: the library never invents a tag name
79
+ * and calls `customElements.define` behind their back, because that name would
80
+ * then be taken globally for the life of the page.
81
+ *
82
+ * @param {(new () => HTMLElement) | string} source
83
+ * @returns {string}
84
+ */
85
+ function tagNameOf(source) {
86
+ if (typeof source === 'string') return source;
87
+
88
+ const registry = /** @type {CustomElementRegistry & { getName?: (c: unknown) => string | null }} */ (
89
+ customElements
90
+ );
91
+ const registered = registry.getName?.(source);
92
+ if (registered) return registered;
93
+
94
+ const declared = /** @type {{ tagName?: string }} */ (/** @type {unknown} */ (source)).tagName;
95
+ if (typeof declared === 'string' && declared) return declared;
96
+
97
+ throw new Error(
98
+ 'ChitUI: component is not registered in customElements. ' +
99
+ 'Call customElements.define() for it, or pass its tag name as a string.',
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Build (or reuse) an element for a component reference and write its props.
105
+ *
106
+ * The instance is kept across renders so the component keeps its own state; a
107
+ * changed reference replaces it, and changed props are assigned one by one so
108
+ * the component's own setters see each change.
109
+ *
110
+ * @param {ChitUI} host
111
+ * @param {string} key Cache key.
112
+ * @param {(new () => HTMLElement) | string} source
113
+ * @param {Record<string, unknown>} [props]
114
+ * @returns {HTMLElement}
115
+ */
116
+ export function resolveComponent(host, key, source, props = {}) {
117
+ let cache = INSTANCES.get(host);
118
+ if (!cache) {
119
+ cache = new Map();
120
+ INSTANCES.set(host, cache);
121
+ }
122
+
123
+ const existing = /** @type {ComponentEntry | undefined} */ (cache.get(key));
124
+
125
+ if (existing && existing.source === source) {
126
+ for (const [name, value] of Object.entries(props)) {
127
+ if (!Object.is(existing.props[name], value)) {
128
+ /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (existing.element))[name] = value;
129
+ }
130
+ }
131
+ existing.props = { ...props };
132
+ return existing.element;
133
+ }
134
+
135
+ const element = document.createElement(tagNameOf(source));
136
+ for (const [name, value] of Object.entries(props)) {
137
+ /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (element))[name] = value;
138
+ }
139
+ cache.set(key, { source, element, props: { ...props } });
140
+ return element;
141
+ }
142
+
143
+ /**
144
+ * Drop cached instances for messages that are gone, so a long conversation
145
+ * does not hold on to elements it will never show again. Non-message entries,
146
+ * such as the launcher's, are left alone.
147
+ *
148
+ * @param {ChitUI} host
149
+ * @param {Set<string>} liveIds
150
+ */
151
+ export function pruneComponents(host, liveIds) {
152
+ const cache = INSTANCES.get(host);
153
+ if (!cache) return;
154
+ for (const key of cache.keys()) {
155
+ if (!key.startsWith('msg:')) continue;
156
+ if (!liveIds.has(key.slice(4))) cache.delete(key);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * The component instance backing a message, when it has one.
162
+ *
163
+ * @param {ChitUI} host
164
+ * @param {string} id
165
+ * @returns {HTMLElement | undefined}
166
+ */
167
+ export function componentInstance(host, id) {
168
+ return INSTANCES.get(host)?.get(messageKey(id))?.element;
169
+ }
170
+
171
+ /**
172
+ * Turn a message's content into something Lit can render as a child value.
173
+ *
174
+ * All four shapes collapse here: an HTML string becomes an `unsafeHTML`
175
+ * directive, a Lit template passes straight through, and an element or a
176
+ * component becomes a Node, which Lit inserts as-is.
177
+ *
178
+ * @param {ChitUI} host
179
+ * @param {Message} message
180
+ * @returns {unknown}
181
+ */
182
+ export function renderContent(host, message) {
183
+ switch (contentField(message)) {
184
+ case 'text':
185
+ // A string child is rendered by Lit as a text node, so the escaping is
186
+ // structural: there is no parse step for markup to sneak through. Line
187
+ // breaks survive because the content box is pre-wrap for this form.
188
+ return /** @type {{ text: string }} */ (/** @type {unknown} */ (message)).text;
189
+
190
+ case 'template':
191
+ return /** @type {{ template: unknown }} */ (/** @type {unknown} */ (message)).template;
192
+
193
+ case 'element':
194
+ return /** @type {{ element: HTMLElement }} */ (/** @type {unknown} */ (message)).element;
195
+
196
+ case 'component': {
197
+ const carrier = /** @type {{ component: (new () => HTMLElement) | string, props?: Record<string, unknown> }} */ (
198
+ /** @type {unknown} */ (message)
199
+ );
200
+ return resolveComponent(host, messageKey(message.id), carrier.component, carrier.props);
201
+ }
202
+
203
+ default: {
204
+ // Only the html form goes through `sanitize`: `text` is escaped by
205
+ // construction, and the rest are structures the consumer built
206
+ // themselves rather than markup we could meaningfully clean.
207
+ const raw = /** @type {{ html: string }} */ (/** @type {unknown} */ (message)).html;
208
+ return unsafeHTML(host.sanitize ? host.sanitize(raw) : raw);
209
+ }
210
+ }
211
+ }
@@ -0,0 +1,64 @@
1
+ // @ts-check
2
+ import { html, nothing } from 'lit';
3
+ import { LAUNCHER_KEY, resolveComponent } from './content.js';
4
+
5
+ /** @import { ChitUI } from '../chit-ui.js' */
6
+
7
+ /**
8
+ * The closed state: one button.
9
+ *
10
+ * What goes inside it has three routes, in order of how much the consumer
11
+ * takes over:
12
+ *
13
+ * - the theme's image or label, drawn inside the themed circle;
14
+ * - the `launcher` slot, their markup inside that same circle;
15
+ * - `closed.component`, a component that draws the whole launcher — the button
16
+ * then keeps only its role, and gives up its size, background, shadow and
17
+ * radius, because two parties styling one box is how things end up clipped.
18
+ *
19
+ * @param {ChitUI} host
20
+ * @returns {import('lit').TemplateResult}
21
+ */
22
+ export function renderLauncher(host) {
23
+ const closed = host.currentTheme.closed;
24
+ const labels = host.currentLabels;
25
+ const custom = closed.component
26
+ ? resolveComponent(host, LAUNCHER_KEY, closed.component, closed.props)
27
+ : null;
28
+
29
+ return html`
30
+ <button
31
+ part="launcher"
32
+ type="button"
33
+ aria-label=${closed.label || labels.launcher}
34
+ aria-expanded=${host.state === 'open'}
35
+ ?data-has-image=${!custom && !!closed.image}
36
+ ?data-custom=${!!custom}
37
+ @click=${() => host.toggleFromUser()}
38
+ >
39
+ ${custom ?? html`<slot name="launcher">${defaultContent(closed)}</slot>`}
40
+ </button>
41
+ `;
42
+ }
43
+
44
+ /**
45
+ * @param {import('../types.js').ResolvedClosed} closed
46
+ * @returns {unknown}
47
+ */
48
+ function defaultContent(closed) {
49
+ if (closed.image) return nothing;
50
+ if (closed.label) return html`<span part="launcher-label">${closed.label}</span>`;
51
+ return defaultIcon();
52
+ }
53
+
54
+ /** A plain speech bubble, so the widget looks like something before theming. */
55
+ function defaultIcon() {
56
+ return html`
57
+ <svg part="launcher-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
58
+ <path
59
+ fill="currentColor"
60
+ d="M12 3c-4.97 0-9 3.36-9 7.5 0 2.3 1.25 4.36 3.2 5.73-.13 1.2-.6 2.3-1.36 3.2a.5.5 0 0 0 .5.82 8.2 8.2 0 0 0 3.87-1.86c.88.26 1.82.41 2.79.41 4.97 0 9-3.36 9-7.5S16.97 3 12 3Z"
61
+ />
62
+ </svg>
63
+ `;
64
+ }
@@ -0,0 +1,67 @@
1
+ // @ts-check
2
+ import { html, nothing } from 'lit';
3
+ import { repeat } from 'lit/directives/repeat.js';
4
+ import { renderMessage } from './message.js';
5
+
6
+ /** @import { ChitUI } from '../chit-ui.js' */
7
+
8
+ /**
9
+ * The scrolling conversation.
10
+ *
11
+ * `repeat` keys on the message id so an existing bubble's DOM is reused when
12
+ * the consumer assigns a new array; without it Lit would reorder by position
13
+ * and a component message would lose its state on every insert.
14
+ *
15
+ * Clicks are handled once here and re-published as `chat-message-click`, which
16
+ * is how a consumer reaches a button inside a message without hunting through
17
+ * the shadow root.
18
+ *
19
+ * @param {ChitUI} host
20
+ * @returns {import('lit').TemplateResult}
21
+ */
22
+ export function renderMessageList(host) {
23
+ const labels = host.currentLabels;
24
+ const typing = host.typing;
25
+ const empty = host.messages.length === 0 && !typing;
26
+
27
+ return html`
28
+ <div
29
+ part="messages"
30
+ role="log"
31
+ aria-live="polite"
32
+ aria-relevant="additions text"
33
+ aria-label=${labels.conversation}
34
+ @click=${(/** @type {MouseEvent} */ event) => host.handleMessageClick(event)}
35
+ >
36
+ <div part="messages-inner" class="inner">
37
+ ${empty ? html`<slot name="empty"></slot>` : nothing}
38
+ ${repeat(
39
+ host.messages,
40
+ (message) => message.id,
41
+ (message) => renderMessage(host, message),
42
+ )}
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}
52
+ </div>
53
+ </div>
54
+
55
+ ${host.hasUnseen
56
+ ? html`
57
+ <button
58
+ part="to-latest"
59
+ type="button"
60
+ @click=${() => host.scrollToBottom({ smooth: true })}
61
+ >
62
+ ${labels.toLatest}
63
+ </button>
64
+ `
65
+ : nothing}
66
+ `;
67
+ }
@@ -0,0 +1,82 @@
1
+ // @ts-check
2
+ import { html, nothing } from 'lit';
3
+ import { contentField, renderContent } from './content.js';
4
+
5
+ /** @import { ChitUI } from '../chit-ui.js' */
6
+ /** @import { Message } from '../types.js' */
7
+
8
+ /**
9
+ * Format a timestamp. The consumer's `formatTime` wins; otherwise a plain
10
+ * locale-aware hour:minute, which is what a chat bubble wants.
11
+ *
12
+ * @param {ChitUI} host
13
+ * @param {string | Date} time
14
+ * @returns {string}
15
+ */
16
+ function formatTime(host, time) {
17
+ const date = time instanceof Date ? time : new Date(time);
18
+ if (Number.isNaN(date.getTime())) return '';
19
+ if (host.formatTime) return host.formatTime(date);
20
+ return new Intl.DateTimeFormat(host.resolvedLocale, {
21
+ hour: '2-digit',
22
+ minute: '2-digit',
23
+ }).format(date);
24
+ }
25
+
26
+ /**
27
+ * One message.
28
+ *
29
+ * `system` messages are a centred line rather than a bubble: they are the
30
+ * widget talking about the conversation ("an operator joined"), not a party to
31
+ * it, so they get no avatar, no name and no timestamp.
32
+ *
33
+ * @param {ChitUI} host
34
+ * @param {Message} message
35
+ * @returns {import('lit').TemplateResult}
36
+ */
37
+ export function renderMessage(host, message) {
38
+ if (message.role === 'system') {
39
+ return html`
40
+ <article part="message message-system" data-role="system" data-id=${message.id}>
41
+ <div part="message-content" data-content=${contentField(message)}>${renderContent(host, message)}</div>
42
+ </article>
43
+ `;
44
+ }
45
+
46
+ const meta = message.time || message.status;
47
+
48
+ return html`
49
+ <article
50
+ part="message message-${message.role}"
51
+ data-role=${message.role}
52
+ data-id=${message.id}
53
+ ?data-streaming=${!!message.streaming}
54
+ aria-busy=${message.streaming ? 'true' : 'false'}
55
+ >
56
+ ${message.avatar
57
+ ? html`<img part="avatar" src=${message.avatar} alt="" loading="lazy" />`
58
+ : nothing}
59
+ <div class="body">
60
+ ${message.name ? html`<span part="name">${message.name}</span>` : nothing}
61
+ <div part="bubble">
62
+ <div part="message-content" data-content=${contentField(message)}>${renderContent(host, message)}</div>
63
+ ${message.streaming ? html`<span part="cursor" aria-hidden="true"></span>` : nothing}
64
+ </div>
65
+ ${meta
66
+ ? html`
67
+ <div part="meta" class="meta">
68
+ ${message.time
69
+ ? html`<time part="time">${formatTime(host, message.time)}</time>`
70
+ : nothing}
71
+ ${message.status
72
+ ? html`<span part="status" data-status=${message.status}>
73
+ ${host.currentLabels.status[message.status]}
74
+ </span>`
75
+ : nothing}
76
+ </div>
77
+ `
78
+ : nothing}
79
+ </div>
80
+ </article>
81
+ `;
82
+ }
@@ -0,0 +1,77 @@
1
+ // @ts-check
2
+ import { html, nothing } from 'lit';
3
+ import { renderMessageList } from './message-list.js';
4
+ import { renderComposer } from './composer.js';
5
+
6
+ /** @import { ChitUI } from '../chit-ui.js' */
7
+
8
+ /**
9
+ * The open state. Header and footer are slots; the message list and the
10
+ * composer arrive in later milestones and are placeholders for now.
11
+ *
12
+ * @param {ChitUI} host
13
+ * @returns {import('lit').TemplateResult}
14
+ */
15
+ export function renderPanel(host) {
16
+ const open = host.currentTheme.open;
17
+ const labels = host.currentLabels;
18
+ const title = open.header?.title ?? labels.panel;
19
+
20
+ return html`
21
+ <section
22
+ part="panel"
23
+ role="dialog"
24
+ aria-modal="false"
25
+ aria-label=${title}
26
+ tabindex="-1"
27
+ @keydown=${(/** @type {KeyboardEvent} */ event) => onKeydown(host, event)}
28
+ >
29
+ <header part="header">
30
+ <slot name="header">
31
+ <slot name="header-title">
32
+ ${open.header?.logo
33
+ ? html`<img part="header-logo" src=${open.header.logo} alt="" />`
34
+ : nothing}
35
+ <span part="header-heading">${title}</span>
36
+ </slot>
37
+ <span part="header-actions">
38
+ <slot name="header-actions"></slot>
39
+ <button
40
+ part="close-button"
41
+ type="button"
42
+ aria-label=${labels.close}
43
+ @click=${() => host.closeFromUser()}
44
+ >
45
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
46
+ <path
47
+ fill="none"
48
+ stroke="currentColor"
49
+ stroke-width="2"
50
+ stroke-linecap="round"
51
+ d="M6 6l12 12M18 6L6 18"
52
+ />
53
+ </svg>
54
+ </button>
55
+ </span>
56
+ </slot>
57
+ </header>
58
+
59
+ ${renderMessageList(host)} ${renderComposer(host)}
60
+
61
+ <slot name="footer"></slot>
62
+ </section>
63
+ `;
64
+ }
65
+
66
+ /**
67
+ * Esc closes the panel. It is handled here rather than on the host so that it
68
+ * only applies while the panel has focus inside it.
69
+ *
70
+ * @param {ChitUI} host
71
+ * @param {KeyboardEvent} event
72
+ */
73
+ function onKeydown(host, event) {
74
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
75
+ event.preventDefault();
76
+ host.closeFromUser();
77
+ }
@@ -0,0 +1,72 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Constructable stylesheets landed in Safari 16.4 and we support 16, so the
5
+ * same fallback Lit uses applies here: a <style> element in the shadow root.
6
+ */
7
+ const SUPPORTS_ADOPTING =
8
+ typeof ShadowRoot !== 'undefined' &&
9
+ 'adoptedStyleSheets' in Document.prototype &&
10
+ 'replaceSync' in CSSStyleSheet.prototype;
11
+
12
+ /**
13
+ * A stylesheet the component owns and rewrites at runtime, appended after the
14
+ * component's static styles so its declarations win over them.
15
+ */
16
+ export class AdoptedSheet {
17
+ /** @type {CSSStyleSheet | null} */
18
+ #sheet = null;
19
+ /** @type {HTMLStyleElement | null} */
20
+ #element = null;
21
+ /** @type {string} */
22
+ #written = '';
23
+ /** @type {string} */
24
+ #marker;
25
+
26
+ /** @param {string} marker Identifies the sheet in the DOM when the fallback is used. */
27
+ constructor(marker) {
28
+ this.#marker = marker;
29
+ }
30
+
31
+ /** @param {ShadowRoot | HTMLElement | DocumentFragment} root */
32
+ attach(root) {
33
+ if (!(root instanceof ShadowRoot)) return;
34
+
35
+ if (SUPPORTS_ADOPTING) {
36
+ if (!this.#sheet) this.#sheet = new CSSStyleSheet();
37
+ if (!root.adoptedStyleSheets.includes(this.#sheet)) {
38
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, this.#sheet];
39
+ }
40
+ return;
41
+ }
42
+
43
+ if (!this.#element) {
44
+ this.#element = document.createElement('style');
45
+ this.#element.setAttribute(this.#marker, '');
46
+ }
47
+ if (this.#element.parentNode !== root) root.append(this.#element);
48
+ }
49
+
50
+ /**
51
+ * Replace the sheet's contents. Cheap to call on every update: identical CSS
52
+ * is ignored.
53
+ *
54
+ * @param {string} css
55
+ * @param {ShadowRoot | HTMLElement | DocumentFragment} root
56
+ */
57
+ write(css, root) {
58
+ if (css === this.#written) return;
59
+ this.attach(root);
60
+
61
+ if (this.#sheet) this.#sheet.replaceSync(css);
62
+ else if (this.#element) this.#element.textContent = css;
63
+ else return;
64
+
65
+ this.#written = css;
66
+ }
67
+
68
+ detach() {
69
+ this.#element?.remove();
70
+ this.#element = null;
71
+ }
72
+ }
@@ -0,0 +1,108 @@
1
+ // @ts-check
2
+ import { css } from 'lit';
3
+
4
+ export const composerStyles = css`
5
+ [part~='composer'] {
6
+ display: flex;
7
+ flex: none;
8
+ align-items: flex-end;
9
+ gap: 0.5em;
10
+ padding: 0.6em 0.75em;
11
+ border-top: 1px solid var(--chit-color-border);
12
+ background: var(--chit-color-input-bg);
13
+ }
14
+
15
+ [part~='input'] {
16
+ flex: 1 1 auto;
17
+ min-width: 0;
18
+ /* One row to start with, growing to the theme's limit. */
19
+ field-sizing: content;
20
+ max-height: calc(1.5em * var(--_chit-input-max-rows, 5) + 1em);
21
+ padding: 0.45em 0.6em;
22
+ border: 1px solid var(--chit-color-border);
23
+ border-radius: 12px;
24
+ background: var(--chit-color-input-bg);
25
+ color: var(--chit-color-input-text);
26
+ font: inherit;
27
+ line-height: 1.5;
28
+ resize: none;
29
+ overflow-y: auto;
30
+ }
31
+
32
+ [part~='input']::placeholder {
33
+ color: var(--chit-color-input-placeholder);
34
+ }
35
+
36
+ [part~='input']:focus-visible {
37
+ outline: 2px solid var(--chit-color-accent);
38
+ outline-offset: -1px;
39
+ }
40
+
41
+ [part~='input']:disabled {
42
+ opacity: 0.6;
43
+ cursor: not-allowed;
44
+ }
45
+
46
+ [part~='counter'] {
47
+ align-self: center;
48
+ font-size: 0.75em;
49
+ color: var(--chit-color-system-text);
50
+ font-variant-numeric: tabular-nums;
51
+ }
52
+
53
+ [part~='counter'][data-over] {
54
+ color: #d93025;
55
+ font-weight: 600;
56
+ }
57
+
58
+ [part~='send-button'] {
59
+ display: grid;
60
+ flex: none;
61
+ place-items: center;
62
+ width: 2.4em;
63
+ height: 2.4em;
64
+ padding: 0;
65
+ border: 0;
66
+ border-radius: 50%;
67
+ background: var(--chit-color-accent);
68
+ color: #fff;
69
+ cursor: pointer;
70
+ transition: opacity 120ms ease;
71
+ }
72
+
73
+ [part~='send-button'] svg {
74
+ width: 1.1em;
75
+ height: 1.1em;
76
+ }
77
+
78
+ [part~='send-button']:disabled {
79
+ opacity: 0.4;
80
+ cursor: default;
81
+ }
82
+
83
+ [part~='send-button']:focus-visible {
84
+ outline: 2px solid var(--chit-color-accent);
85
+ outline-offset: 2px;
86
+ }
87
+
88
+ [part~='spinner'] {
89
+ width: 1.1em;
90
+ height: 1.1em;
91
+ border: 2px solid currentColor;
92
+ border-top-color: transparent;
93
+ border-radius: 50%;
94
+ animation: chit-spin 0.7s linear infinite;
95
+ }
96
+
97
+ @keyframes chit-spin {
98
+ to {
99
+ transform: rotate(360deg);
100
+ }
101
+ }
102
+
103
+ @media (prefers-reduced-motion: reduce) {
104
+ [part~='spinner'] {
105
+ animation-duration: 2s;
106
+ }
107
+ }
108
+ `;