@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.
- package/LICENSE +21 -0
- package/README.md +440 -0
- package/dist/chit-ui.iife.min.js +964 -0
- package/dist/chit-ui.iife.min.js.map +1 -0
- package/dist/chit-ui.min.js +964 -0
- package/dist/chit-ui.min.js.map +1 -0
- package/dist/types/bundle.d.ts +7 -0
- package/dist/types/chit-ui.d.ts +251 -0
- package/dist/types/controllers/breakpoint-controller.d.ts +29 -0
- package/dist/types/controllers/composer-controller.d.ts +54 -0
- package/dist/types/controllers/scroll-controller.d.ts +44 -0
- package/dist/types/controllers/state-controller.d.ts +61 -0
- package/dist/types/controllers/theme-controller.d.ts +47 -0
- package/dist/types/element.d.ts +1 -0
- package/dist/types/events.d.ts +28 -0
- package/dist/types/global.d.ts +25 -0
- package/dist/types/i18n/labels.d.ts +68 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/render/composer.d.ts +15 -0
- package/dist/types/render/content.d.ts +75 -0
- package/dist/types/render/launcher.d.ts +18 -0
- package/dist/types/render/message-list.d.ts +17 -0
- package/dist/types/render/message.d.ts +14 -0
- package/dist/types/render/panel.d.ts +10 -0
- package/dist/types/styles/adopted-sheet.d.ts +20 -0
- package/dist/types/styles/composer.css.d.ts +1 -0
- package/dist/types/styles/content.css.d.ts +9 -0
- package/dist/types/styles/host.css.d.ts +9 -0
- package/dist/types/styles/launcher.css.d.ts +1 -0
- package/dist/types/styles/message.css.d.ts +1 -0
- package/dist/types/styles/panel.css.d.ts +1 -0
- package/dist/types/theme/default-theme.d.ts +97 -0
- package/dist/types/theme/merge-theme.d.ts +33 -0
- package/dist/types/theme/theme-to-css.d.ts +24 -0
- package/dist/types/types.d.ts +268 -0
- package/package.json +71 -0
- package/src/bundle.js +11 -0
- package/src/chit-ui.js +548 -0
- package/src/controllers/breakpoint-controller.js +82 -0
- package/src/controllers/composer-controller.js +215 -0
- package/src/controllers/scroll-controller.js +252 -0
- package/src/controllers/state-controller.js +316 -0
- package/src/controllers/theme-controller.js +75 -0
- package/src/element.js +4 -0
- package/src/events.js +33 -0
- package/src/global.d.ts +25 -0
- package/src/i18n/labels.js +72 -0
- package/src/index.js +9 -0
- package/src/render/composer.js +72 -0
- package/src/render/content.js +211 -0
- package/src/render/launcher.js +64 -0
- package/src/render/message-list.js +67 -0
- package/src/render/message.js +82 -0
- package/src/render/panel.js +77 -0
- package/src/styles/adopted-sheet.js +72 -0
- package/src/styles/composer.css.js +108 -0
- package/src/styles/content.css.js +119 -0
- package/src/styles/host.css.js +160 -0
- package/src/styles/launcher.css.js +139 -0
- package/src/styles/message.css.js +192 -0
- package/src/styles/panel.css.js +126 -0
- package/src/theme/default-theme.js +111 -0
- package/src/theme/merge-theme.js +100 -0
- package/src/theme/theme-to-css.js +94 -0
- 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
|
+
`;
|