@hidemikimura/chit-ui 0.1.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.
- package/CHANGELOG.md +91 -0
- package/README.md +314 -11
- package/dist/chit-ui.iife.min.js +416 -94
- package/dist/chit-ui.iife.min.js.map +1 -1
- package/dist/chit-ui.min.js +407 -85
- package/dist/chit-ui.min.js.map +1 -1
- package/dist/types/bundle.d.ts +1 -0
- package/dist/types/chit-ui.d.ts +89 -4
- package/dist/types/controllers/composer-controller.d.ts +4 -0
- package/dist/types/controllers/drag-controller.d.ts +118 -0
- package/dist/types/events.d.ts +3 -0
- package/dist/types/global.d.ts +9 -1
- package/dist/types/i18n/labels.d.ts +16 -0
- package/dist/types/render/composer.d.ts +0 -1
- package/dist/types/render/message-list.d.ts +0 -1
- package/dist/types/theme/default-theme.d.ts +30 -1
- package/dist/types/themes/green.d.ts +16 -0
- package/dist/types/themes/index.d.ts +1 -0
- package/dist/types/types.d.ts +105 -4
- package/package.json +6 -1
- package/src/bundle.js +1 -0
- package/src/chit-ui.js +292 -4
- package/src/controllers/composer-controller.js +2 -0
- package/src/controllers/drag-controller.js +319 -0
- package/src/events.js +3 -0
- package/src/global.d.ts +9 -1
- package/src/i18n/labels.js +12 -0
- package/src/render/composer.js +58 -0
- package/src/render/launcher.js +9 -1
- package/src/render/message-list.js +40 -10
- package/src/render/message.js +13 -4
- package/src/render/panel.js +73 -11
- package/src/styles/composer.css.js +39 -0
- package/src/styles/host.css.js +2 -0
- package/src/styles/launcher.css.js +13 -0
- package/src/styles/message.css.js +168 -9
- package/src/styles/panel.css.js +44 -4
- package/src/theme/default-theme.js +37 -2
- package/src/theme/theme-to-css.js +5 -0
- package/src/themes/green.js +47 -0
- package/src/themes/index.js +15 -0
- package/src/types.js +38 -6
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 }>;
|
|
@@ -15,6 +15,14 @@ export interface ChitUIEventMap {
|
|
|
15
15
|
'chat-message-click': CustomEvent<{ message: Message; target: Element; originalEvent: MouseEvent }>;
|
|
16
16
|
'chat-scroll-top': CustomEvent<Record<string, never>>;
|
|
17
17
|
'chat-breakpoint-change': CustomEvent<{ device: Device }>;
|
|
18
|
+
'chat-home': CustomEvent<{ trigger: Trigger }>;
|
|
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
|
+
}>;
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
declare global {
|
package/src/i18n/labels.js
CHANGED
|
@@ -6,9 +6,13 @@
|
|
|
6
6
|
* @property {string} panel Accessible name of the dialog.
|
|
7
7
|
* @property {string} conversation Accessible name of the message log.
|
|
8
8
|
* @property {string} close Close button.
|
|
9
|
+
* @property {string} home Home button: back to the start of the conversation.
|
|
9
10
|
* @property {string} send Send button.
|
|
11
|
+
* @property {string} attach Attach button beside the composer.
|
|
10
12
|
* @property {string} input Composer textarea.
|
|
11
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.
|
|
12
16
|
* @property {string} toLatest "Jump to newest" button.
|
|
13
17
|
* @property {{ sending: string, sent: string, error: string }} status Delivery state of one's own message.
|
|
14
18
|
* @property {string} charactersLeft Counter, with {n} for the number remaining.
|
|
@@ -22,9 +26,13 @@ const BUILT_IN = {
|
|
|
22
26
|
panel: 'チャット',
|
|
23
27
|
conversation: '会話',
|
|
24
28
|
close: 'チャットを閉じる',
|
|
29
|
+
home: '最初に戻る',
|
|
25
30
|
send: '送信',
|
|
31
|
+
attach: '画像や動画を添付',
|
|
26
32
|
input: 'メッセージを入力',
|
|
27
33
|
typing: '入力中',
|
|
34
|
+
loading: '応答を待っています',
|
|
35
|
+
move: 'チャットの位置を移動(矢印キー)',
|
|
28
36
|
toLatest: '最新へ',
|
|
29
37
|
status: { sending: '送信中', sent: '送信済み', error: '送信できませんでした' },
|
|
30
38
|
charactersLeft: '残り {n} 文字',
|
|
@@ -35,9 +43,13 @@ const BUILT_IN = {
|
|
|
35
43
|
panel: 'Chat',
|
|
36
44
|
conversation: 'Conversation',
|
|
37
45
|
close: 'Close chat',
|
|
46
|
+
home: 'Back to the start',
|
|
38
47
|
send: 'Send',
|
|
48
|
+
attach: 'Attach an image or video',
|
|
39
49
|
input: 'Type a message',
|
|
40
50
|
typing: 'Typing',
|
|
51
|
+
loading: 'Waiting for a reply',
|
|
52
|
+
move: 'Move the chat (arrow keys)',
|
|
41
53
|
toLatest: 'Jump to latest',
|
|
42
54
|
status: { sending: 'Sending', sent: 'Sent', error: 'Not delivered' },
|
|
43
55
|
charactersLeft: '{n} characters left',
|
package/src/render/composer.js
CHANGED
|
@@ -4,6 +4,62 @@ import { live } from 'lit/directives/live.js';
|
|
|
4
4
|
|
|
5
5
|
/** @import { ChitUI } from '../chit-ui.js' */
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* The attach button and the file input it opens.
|
|
9
|
+
*
|
|
10
|
+
* The widget owns the picking and nothing else: it hands the chosen files to
|
|
11
|
+
* the consumer through `chat-attach` and forgets them. Uploading them, showing
|
|
12
|
+
* a preview and adding a message are all things only the consumer can do, and
|
|
13
|
+
* a half-done version here would be in the way of the real one.
|
|
14
|
+
*
|
|
15
|
+
* The input is reset after every pick so that choosing the same file twice in
|
|
16
|
+
* a row still reports the second time.
|
|
17
|
+
*
|
|
18
|
+
* @param {ChitUI} host
|
|
19
|
+
* @param {boolean} disabled
|
|
20
|
+
* @returns {import('lit').TemplateResult}
|
|
21
|
+
*/
|
|
22
|
+
function renderAttach(host, disabled) {
|
|
23
|
+
const labels = host.currentLabels;
|
|
24
|
+
const input = host.currentTheme.open.input;
|
|
25
|
+
|
|
26
|
+
return html`
|
|
27
|
+
<button
|
|
28
|
+
part="attach-button"
|
|
29
|
+
type="button"
|
|
30
|
+
aria-label=${labels.attach}
|
|
31
|
+
title=${labels.attach}
|
|
32
|
+
?disabled=${disabled}
|
|
33
|
+
@click=${() => host.openAttach()}
|
|
34
|
+
>
|
|
35
|
+
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
|
36
|
+
<path
|
|
37
|
+
fill="none"
|
|
38
|
+
stroke="currentColor"
|
|
39
|
+
stroke-width="2"
|
|
40
|
+
stroke-linecap="round"
|
|
41
|
+
stroke-linejoin="round"
|
|
42
|
+
d="M20 11.5 12.2 19.3a4.6 4.6 0 0 1-6.5-6.5l7.8-7.8a3 3 0 0 1 4.3 4.3l-7.8 7.8a1.5 1.5 0 0 1-2.1-2.1l7.1-7.1"
|
|
43
|
+
/>
|
|
44
|
+
</svg>
|
|
45
|
+
</button>
|
|
46
|
+
<input
|
|
47
|
+
part="attach-input"
|
|
48
|
+
type="file"
|
|
49
|
+
tabindex="-1"
|
|
50
|
+
aria-hidden="true"
|
|
51
|
+
accept=${input.accept}
|
|
52
|
+
?multiple=${input.multiple}
|
|
53
|
+
@change=${(/** @type {Event} */ event) => {
|
|
54
|
+
const field = /** @type {HTMLInputElement} */ (event.target);
|
|
55
|
+
const files = Array.from(field.files ?? []);
|
|
56
|
+
field.value = '';
|
|
57
|
+
if (files.length) host.handleAttach(files);
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
`;
|
|
61
|
+
}
|
|
62
|
+
|
|
7
63
|
/**
|
|
8
64
|
* The composer: a textarea that grows with its content, and a send button.
|
|
9
65
|
*
|
|
@@ -32,6 +88,8 @@ export function renderComposer(host) {
|
|
|
32
88
|
}}
|
|
33
89
|
>
|
|
34
90
|
<slot name="composer">
|
|
91
|
+
${theme.input.attach ? renderAttach(host, disabled) : nothing}
|
|
92
|
+
|
|
35
93
|
<slot name="input-before"></slot>
|
|
36
94
|
|
|
37
95
|
<textarea
|
package/src/render/launcher.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
${
|
|
44
|
-
?
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
package/src/render/message.js
CHANGED
|
@@ -45,6 +45,13 @@ export function renderMessage(host, message) {
|
|
|
45
45
|
|
|
46
46
|
const meta = message.time || message.status;
|
|
47
47
|
|
|
48
|
+
// The theme says who is talking; a message overrides either half. `null` on
|
|
49
|
+
// the message hides what the theme set, which is how a run of replies shows
|
|
50
|
+
// the icon only on the first of them.
|
|
51
|
+
const speaker = host.currentTheme.open.speaker[message.role];
|
|
52
|
+
const avatar = message.avatar === undefined ? speaker.avatar : message.avatar;
|
|
53
|
+
const name = message.name === undefined ? speaker.name : message.name;
|
|
54
|
+
|
|
48
55
|
return html`
|
|
49
56
|
<article
|
|
50
57
|
part="message message-${message.role}"
|
|
@@ -53,11 +60,13 @@ export function renderMessage(host, message) {
|
|
|
53
60
|
?data-streaming=${!!message.streaming}
|
|
54
61
|
aria-busy=${message.streaming ? 'true' : 'false'}
|
|
55
62
|
>
|
|
56
|
-
${
|
|
57
|
-
? html`<img part="avatar" src=${
|
|
58
|
-
:
|
|
63
|
+
${avatar
|
|
64
|
+
? html`<img part="avatar" src=${avatar} alt="" loading="lazy" />`
|
|
65
|
+
: speaker.avatar
|
|
66
|
+
? html`<span part="avatar" data-placeholder aria-hidden="true"></span>`
|
|
67
|
+
: nothing}
|
|
59
68
|
<div class="body">
|
|
60
|
-
${
|
|
69
|
+
${name ? html`<span part="name">${name}</span>` : nothing}
|
|
61
70
|
<div part="bubble">
|
|
62
71
|
<div part="message-content" data-content=${contentField(message)}>${renderContent(host, message)}</div>
|
|
63
72
|
${message.streaming ? html`<span part="cursor" aria-hidden="true"></span>` : nothing}
|
package/src/render/panel.js
CHANGED
|
@@ -15,7 +15,7 @@ import { renderComposer } from './composer.js';
|
|
|
15
15
|
export function renderPanel(host) {
|
|
16
16
|
const open = host.currentTheme.open;
|
|
17
17
|
const labels = host.currentLabels;
|
|
18
|
-
const title = open.header
|
|
18
|
+
const title = open.header.title ?? labels.panel;
|
|
19
19
|
|
|
20
20
|
return html`
|
|
21
21
|
<section
|
|
@@ -26,16 +26,83 @@ export function renderPanel(host) {
|
|
|
26
26
|
tabindex="-1"
|
|
27
27
|
@keydown=${(/** @type {KeyboardEvent} */ event) => onKeydown(host, event)}
|
|
28
28
|
>
|
|
29
|
-
|
|
29
|
+
${open.header.visible ? renderHeader(host, title) : nothing}
|
|
30
|
+
|
|
31
|
+
${renderMessageList(host)} ${renderComposer(host)}
|
|
32
|
+
|
|
33
|
+
<slot name="footer"></slot>
|
|
34
|
+
</section>
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The title bar.
|
|
40
|
+
*
|
|
41
|
+
* It can be left out entirely (`open.header.visible = false`), which is what a
|
|
42
|
+
* widget embedded in a host that already draws its own title bar wants — LINE
|
|
43
|
+
* LIFF, for one — rather than two stacked titles. The dialog keeps its
|
|
44
|
+
* accessible name either way: that name is read, not seen.
|
|
45
|
+
*
|
|
46
|
+
* Note that hiding it takes the close button with it. Esc and the launcher
|
|
47
|
+
* still close the panel, and the host's own chrome usually supplies the rest.
|
|
48
|
+
*
|
|
49
|
+
* @param {ChitUI} host
|
|
50
|
+
* @param {string} title
|
|
51
|
+
* @returns {import('lit').TemplateResult}
|
|
52
|
+
*/
|
|
53
|
+
function renderHeader(host, title) {
|
|
54
|
+
const open = host.currentTheme.open;
|
|
55
|
+
const labels = host.currentLabels;
|
|
56
|
+
const draggable = open.draggable && host.device === 'pc';
|
|
57
|
+
|
|
58
|
+
return html`
|
|
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
|
+
>
|
|
30
75
|
<slot name="header">
|
|
31
76
|
<slot name="header-title">
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
77
|
+
<span part="header-title">
|
|
78
|
+
${open.header.logo
|
|
79
|
+
? html`<img part="header-logo" src=${open.header.logo} alt="" />`
|
|
80
|
+
: nothing}
|
|
81
|
+
<span part="header-heading">${title}</span>
|
|
82
|
+
</span>
|
|
36
83
|
</slot>
|
|
37
84
|
<span part="header-actions">
|
|
38
85
|
<slot name="header-actions"></slot>
|
|
86
|
+
${open.header.home
|
|
87
|
+
? html`<button
|
|
88
|
+
part="home-button"
|
|
89
|
+
type="button"
|
|
90
|
+
aria-label=${labels.home}
|
|
91
|
+
title=${labels.home}
|
|
92
|
+
@click=${() => host.home('user')}
|
|
93
|
+
>
|
|
94
|
+
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
|
95
|
+
<path
|
|
96
|
+
fill="none"
|
|
97
|
+
stroke="currentColor"
|
|
98
|
+
stroke-width="2"
|
|
99
|
+
stroke-linejoin="round"
|
|
100
|
+
stroke-linecap="round"
|
|
101
|
+
d="M4 11.2 12 4l8 7.2M6.5 9.8V19h11V9.8M10 19v-4.6h4V19"
|
|
102
|
+
/>
|
|
103
|
+
</svg>
|
|
104
|
+
</button>`
|
|
105
|
+
: nothing}
|
|
39
106
|
<button
|
|
40
107
|
part="close-button"
|
|
41
108
|
type="button"
|
|
@@ -55,11 +122,6 @@ export function renderPanel(host) {
|
|
|
55
122
|
</span>
|
|
56
123
|
</slot>
|
|
57
124
|
</header>
|
|
58
|
-
|
|
59
|
-
${renderMessageList(host)} ${renderComposer(host)}
|
|
60
|
-
|
|
61
|
-
<slot name="footer"></slot>
|
|
62
|
-
</section>
|
|
63
125
|
`;
|
|
64
126
|
}
|
|
65
127
|
|
|
@@ -55,6 +55,45 @@ export const composerStyles = css`
|
|
|
55
55
|
font-weight: 600;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/* Out of sight, but still clickable from the button beside it. */
|
|
59
|
+
[part~='attach-input'] {
|
|
60
|
+
display: none;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
[part~='attach-button'] {
|
|
64
|
+
display: grid;
|
|
65
|
+
flex: none;
|
|
66
|
+
place-items: center;
|
|
67
|
+
width: 2.4em;
|
|
68
|
+
height: 2.4em;
|
|
69
|
+
padding: 0;
|
|
70
|
+
border: 0;
|
|
71
|
+
border-radius: 50%;
|
|
72
|
+
background: transparent;
|
|
73
|
+
color: var(--chit-color-system-text);
|
|
74
|
+
cursor: pointer;
|
|
75
|
+
transition: opacity 120ms ease;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
[part~='attach-button'] svg {
|
|
79
|
+
width: 1.3em;
|
|
80
|
+
height: 1.3em;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
[part~='attach-button']:hover:not(:disabled) {
|
|
84
|
+
background: color-mix(in srgb, currentColor 10%, transparent);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
[part~='attach-button']:focus-visible {
|
|
88
|
+
outline: 2px solid var(--chit-color-accent);
|
|
89
|
+
outline-offset: 2px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
[part~='attach-button']:disabled {
|
|
93
|
+
opacity: 0.4;
|
|
94
|
+
cursor: default;
|
|
95
|
+
}
|
|
96
|
+
|
|
58
97
|
[part~='send-button'] {
|
|
59
98
|
display: grid;
|
|
60
99
|
flex: none;
|
package/src/styles/host.css.js
CHANGED
|
@@ -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);
|
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
import { css } from 'lit';
|
|
3
3
|
|
|
4
4
|
export const messageStyles = css`
|
|
5
|
+
:host {
|
|
6
|
+
/* Internal: the box the tail's path is drawn in. The path is written in
|
|
7
|
+
these units, so the two move together. */
|
|
8
|
+
--_chit-tail-w: 18px;
|
|
9
|
+
--_chit-tail-h: 20px;
|
|
10
|
+
/* How deep the tail's root sits inside the bubble. Less than the
|
|
11
|
+
bubble's own padding, so it never reaches the text. */
|
|
12
|
+
--_chit-tail-root: 9px;
|
|
13
|
+
/* Far enough down that the bubble's edge has straightened out under it. */
|
|
14
|
+
--_chit-tail-offset: calc(var(--chit-bubble-radius) / 4 + 1px);
|
|
15
|
+
}
|
|
16
|
+
|
|
5
17
|
[part~='messages'] {
|
|
6
18
|
flex: 1 1 auto;
|
|
7
19
|
min-height: 0;
|
|
@@ -26,7 +38,13 @@ export const messageStyles = css`
|
|
|
26
38
|
*/
|
|
27
39
|
[part~='message'] {
|
|
28
40
|
display: flex;
|
|
29
|
-
|
|
41
|
+
/*
|
|
42
|
+
* The icon sits level with the top of the speaker's block — beside the
|
|
43
|
+
* name when there is one, beside the bubble's first line otherwise. At
|
|
44
|
+
* the bottom it would end up next to the timestamp, away from everything
|
|
45
|
+
* it identifies.
|
|
46
|
+
*/
|
|
47
|
+
align-items: flex-start;
|
|
30
48
|
gap: 0.5em;
|
|
31
49
|
width: 100%;
|
|
32
50
|
max-width: 100%;
|
|
@@ -52,7 +70,7 @@ export const messageStyles = css`
|
|
|
52
70
|
[part~='bubble'] {
|
|
53
71
|
position: relative;
|
|
54
72
|
padding: 0.6em 0.85em;
|
|
55
|
-
border-radius:
|
|
73
|
+
border-radius: var(--chit-bubble-radius);
|
|
56
74
|
background: var(--chit-color-assistant-bg);
|
|
57
75
|
color: var(--chit-color-assistant-text);
|
|
58
76
|
}
|
|
@@ -67,6 +85,99 @@ export const messageStyles = css`
|
|
|
67
85
|
border-bottom-left-radius: 4px;
|
|
68
86
|
}
|
|
69
87
|
|
|
88
|
+
/* --- tails -------------------------------------------------------------
|
|
89
|
+
*
|
|
90
|
+
* A tail is one pseudo-element: a box in the bubble's colour, clipped to a
|
|
91
|
+
* leaf whose point sits outside the bubble and whose root is buried inside
|
|
92
|
+
* it. Burying the root is what makes the join disappear — the bubble's own
|
|
93
|
+
* background covers the seam, so every corner keeps its radius and the two
|
|
94
|
+
* read as one shape at any radius.
|
|
95
|
+
*
|
|
96
|
+
* It hangs a little below the corner, where the bubble's edge has
|
|
97
|
+
* straightened out, so the root has something flat to sit against. The
|
|
98
|
+
* offset follows the radius for that reason.
|
|
99
|
+
*
|
|
100
|
+
* The other side and the downward version are the same path mirrored, so
|
|
101
|
+
* the curve is written once.
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
[part~='bubble']::after,
|
|
105
|
+
[part~='typing']::after,
|
|
106
|
+
[part~='loading']::after {
|
|
107
|
+
content: '';
|
|
108
|
+
display: none;
|
|
109
|
+
position: absolute;
|
|
110
|
+
width: var(--_chit-tail-w);
|
|
111
|
+
height: var(--_chit-tail-h);
|
|
112
|
+
clip-path: path('M 18 0.5 Q 8 0.8 0.5 3.5 Q 0 5.2 2 7 Q 10 9.5 18 19 Z');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
* Everything below needs clip-path: path() to draw the leaf. Where that is
|
|
117
|
+
* missing the tail stays hidden and the bubble keeps its default shape,
|
|
118
|
+
* rather than a bare rectangle poking out of its side.
|
|
119
|
+
*/
|
|
120
|
+
@supports (clip-path: path('M 0 0 Z')) {
|
|
121
|
+
:host([data-bubble-tail='top']) [part~='bubble']::after,
|
|
122
|
+
:host([data-bubble-tail='top']) [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 {
|
|
127
|
+
display: block;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/*
|
|
131
|
+
* With a tail there is no need to clip the speaker's corner as well: one
|
|
132
|
+
* bubble carries one cue about who is talking, not two.
|
|
133
|
+
*/
|
|
134
|
+
:host([data-bubble-tail='top']) [part~='bubble'],
|
|
135
|
+
:host([data-bubble-tail='top']) [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'] {
|
|
140
|
+
border-radius: var(--chit-bubble-radius);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* Their side: the leaf points left, its root inside the bubble. */
|
|
144
|
+
:host([data-bubble-tail]) [part~='message-assistant'] [part~='bubble']::after,
|
|
145
|
+
:host([data-bubble-tail]) [part~='typing']::after,
|
|
146
|
+
:host([data-bubble-tail]) [part~='loading']::after {
|
|
147
|
+
right: calc(100% - var(--_chit-tail-root));
|
|
148
|
+
background: var(--chit-color-assistant-bg);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* Your side: the same leaf, mirrored. */
|
|
152
|
+
:host([data-bubble-tail]) [part~='message-user'] [part~='bubble']::after {
|
|
153
|
+
left: calc(100% - var(--_chit-tail-root));
|
|
154
|
+
background: var(--chit-color-user-bg);
|
|
155
|
+
transform: scaleX(-1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
:host([data-bubble-tail='top']) [part~='bubble']::after,
|
|
159
|
+
:host([data-bubble-tail='top']) [part~='typing']::after,
|
|
160
|
+
:host([data-bubble-tail='top']) [part~='loading']::after {
|
|
161
|
+
top: var(--_chit-tail-offset);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
:host([data-bubble-tail='bottom']) [part~='bubble']::after,
|
|
165
|
+
:host([data-bubble-tail='bottom']) [part~='typing']::after,
|
|
166
|
+
:host([data-bubble-tail='bottom']) [part~='loading']::after {
|
|
167
|
+
bottom: var(--_chit-tail-offset);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
:host([data-bubble-tail='bottom']) [part~='message-assistant'] [part~='bubble']::after,
|
|
171
|
+
:host([data-bubble-tail='bottom']) [part~='typing']::after,
|
|
172
|
+
:host([data-bubble-tail='bottom']) [part~='loading']::after {
|
|
173
|
+
transform: scaleY(-1);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
:host([data-bubble-tail='bottom']) [part~='message-user'] [part~='bubble']::after {
|
|
177
|
+
transform: scale(-1, -1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
70
181
|
[part~='avatar'] {
|
|
71
182
|
flex: none;
|
|
72
183
|
width: 2em;
|
|
@@ -75,6 +186,15 @@ export const messageStyles = css`
|
|
|
75
186
|
object-fit: cover;
|
|
76
187
|
}
|
|
77
188
|
|
|
189
|
+
/*
|
|
190
|
+
* A message that hides the theme's icon (avatar: null, for the second and
|
|
191
|
+
* later messages in a run) keeps its place in the row, so the bubbles below
|
|
192
|
+
* stay in line with the one that shows the face.
|
|
193
|
+
*/
|
|
194
|
+
[part~='avatar'][data-placeholder] {
|
|
195
|
+
visibility: hidden;
|
|
196
|
+
}
|
|
197
|
+
|
|
78
198
|
[part~='name'] {
|
|
79
199
|
font-size: 0.8em;
|
|
80
200
|
color: var(--chit-color-system-text);
|
|
@@ -117,21 +237,57 @@ export const messageStyles = css`
|
|
|
117
237
|
}
|
|
118
238
|
}
|
|
119
239
|
|
|
120
|
-
[part~='typing']
|
|
240
|
+
[part~='typing'],
|
|
241
|
+
[part~='loading'] {
|
|
242
|
+
position: relative;
|
|
121
243
|
align-self: flex-start;
|
|
244
|
+
/* Indented past the speaker's icon, when the theme gives them one. */
|
|
245
|
+
margin-inline-start: var(--_chit-speaker-gutter, 0px);
|
|
122
246
|
padding: 0.7em 0.9em;
|
|
123
|
-
|
|
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;
|
|
253
|
+
border-radius: var(--chit-bubble-radius);
|
|
124
254
|
border-bottom-left-radius: 4px;
|
|
125
255
|
background: var(--chit-color-assistant-bg);
|
|
126
256
|
color: var(--chit-color-assistant-text);
|
|
127
257
|
}
|
|
128
258
|
|
|
129
|
-
|
|
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 {
|
|
130
285
|
display: inline-flex;
|
|
131
286
|
gap: 0.25em;
|
|
132
287
|
}
|
|
133
288
|
|
|
134
|
-
[part~='typing'] .dots i
|
|
289
|
+
[part~='typing'] .dots i,
|
|
290
|
+
[part~='loading'] .dots i {
|
|
135
291
|
width: 0.4em;
|
|
136
292
|
height: 0.4em;
|
|
137
293
|
border-radius: 50%;
|
|
@@ -140,10 +296,12 @@ export const messageStyles = css`
|
|
|
140
296
|
animation: chit-typing 1.2s ease-in-out infinite;
|
|
141
297
|
}
|
|
142
298
|
|
|
143
|
-
[part~='typing'] .dots i:nth-child(2)
|
|
299
|
+
[part~='typing'] .dots i:nth-child(2),
|
|
300
|
+
[part~='loading'] .dots i:nth-child(2) {
|
|
144
301
|
animation-delay: 0.15s;
|
|
145
302
|
}
|
|
146
|
-
[part~='typing'] .dots i:nth-child(3)
|
|
303
|
+
[part~='typing'] .dots i:nth-child(3),
|
|
304
|
+
[part~='loading'] .dots i:nth-child(3) {
|
|
147
305
|
animation-delay: 0.3s;
|
|
148
306
|
}
|
|
149
307
|
|
|
@@ -185,7 +343,8 @@ export const messageStyles = css`
|
|
|
185
343
|
|
|
186
344
|
@media (prefers-reduced-motion: reduce) {
|
|
187
345
|
[part~='cursor'],
|
|
188
|
-
[part~='typing'] .dots i
|
|
346
|
+
[part~='typing'] .dots i,
|
|
347
|
+
[part~='loading'] .dots i {
|
|
189
348
|
animation: none;
|
|
190
349
|
}
|
|
191
350
|
}
|