@hidemikimura/chit-ui 0.2.0 → 0.3.1
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 +59 -0
- package/README.md +148 -3
- package/dist/chit-ui.iife.min.js +218 -99
- package/dist/chit-ui.iife.min.js.map +1 -1
- package/dist/chit-ui.min.js +209 -90
- package/dist/chit-ui.min.js.map +1 -1
- package/dist/types/chit-ui.d.ts +64 -4
- package/dist/types/controllers/composer-controller.d.ts +4 -0
- package/dist/types/controllers/drag-controller.d.ts +118 -0
- package/dist/types/controllers/scroll-lock-controller.d.ts +37 -0
- package/dist/types/controllers/viewport-controller.d.ts +38 -0
- package/dist/types/events.d.ts +1 -0
- package/dist/types/global.d.ts +7 -1
- package/dist/types/i18n/labels.d.ts +8 -0
- package/dist/types/index.d.ts +162 -0
- package/dist/types/render/message-list.d.ts +0 -1
- package/dist/types/theme/default-theme.d.ts +8 -0
- package/dist/types/types.d.ts +40 -0
- package/package.json +1 -1
- package/src/chit-ui.js +261 -4
- package/src/controllers/composer-controller.js +2 -0
- package/src/controllers/drag-controller.js +319 -0
- package/src/controllers/scroll-controller.js +21 -1
- package/src/controllers/scroll-lock-controller.js +185 -0
- package/src/controllers/viewport-controller.js +102 -0
- package/src/events.js +1 -0
- package/src/global.d.ts +7 -1
- package/src/i18n/labels.js +6 -0
- package/src/index.js +34 -0
- package/src/render/launcher.js +9 -1
- package/src/render/message-list.js +40 -10
- package/src/render/panel.js +17 -1
- package/src/styles/composer.css.js +18 -0
- package/src/styles/launcher.css.js +13 -0
- package/src/styles/message.css.js +60 -15
- package/src/styles/panel.css.js +30 -3
- package/src/theme/default-theme.js +16 -0
- package/src/types.js +18 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/** @import { ReactiveController, LitElement } from 'lit' */
|
|
4
|
+
|
|
5
|
+
/** How far a finger must travel before the gesture's direction is trusted. */
|
|
6
|
+
const THRESHOLD = 3;
|
|
7
|
+
|
|
8
|
+
/** Slack on the "is there anywhere left to scroll" reading, in pixels. */
|
|
9
|
+
const EDGE_SLACK = 1;
|
|
10
|
+
|
|
11
|
+
/** Overflow values that make an element a scroll container. */
|
|
12
|
+
const SCROLLABLE = /^(auto|scroll|overlay)$/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Keeps a touch that lands on the open panel from scrolling the page behind
|
|
16
|
+
* it.
|
|
17
|
+
*
|
|
18
|
+
* `overscroll-behavior: contain` is on the message list already, and it does
|
|
19
|
+
* the job for the case it covers: a list that can scroll, dragged past its
|
|
20
|
+
* own end. It has nothing to say about the cases that actually leak. A short
|
|
21
|
+
* conversation does not overflow, so the list is not a scroll container at
|
|
22
|
+
* all and the touch goes straight through to the document — which is what a
|
|
23
|
+
* reader on an iPhone sees as the page sliding around underneath a chat that
|
|
24
|
+
* looks like it should be holding still. The same happens for a touch that
|
|
25
|
+
* starts on the title bar or beside the input.
|
|
26
|
+
*
|
|
27
|
+
* So the rule is decided here instead: a touch on the panel may scroll
|
|
28
|
+
* something inside the panel, and otherwise it does nothing. On the first
|
|
29
|
+
* move of each gesture the controller looks along the touch's own path —
|
|
30
|
+
* `composedPath`, so a scroller inside a consumer's component counts too —
|
|
31
|
+
* for something that can still travel in the direction the finger is going.
|
|
32
|
+
* Finding one, it stands aside for the rest of the gesture; finding none, it
|
|
33
|
+
* cancels the move, and iOS then takes the whole gesture as "not a scroll".
|
|
34
|
+
*
|
|
35
|
+
* Two touches are left alone: that is a pinch, and zooming is the reader's.
|
|
36
|
+
* A move that is no longer cancelable is left alone too — the browser has
|
|
37
|
+
* already committed to scrolling, and `overscroll-behavior` is what keeps
|
|
38
|
+
* that inside the list.
|
|
39
|
+
*
|
|
40
|
+
* @implements {ReactiveController}
|
|
41
|
+
*/
|
|
42
|
+
export class ScrollLockController {
|
|
43
|
+
/** @type {LitElement} */
|
|
44
|
+
#host;
|
|
45
|
+
|
|
46
|
+
/** @type {HTMLElement | null} */
|
|
47
|
+
#panel = null;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The gesture in flight: where it started, and what was decided about it.
|
|
51
|
+
*
|
|
52
|
+
* @type {{ x: number, y: number, verdict: 'undecided' | 'allow' | 'block' } | undefined}
|
|
53
|
+
*/
|
|
54
|
+
#touch;
|
|
55
|
+
|
|
56
|
+
/** @param {LitElement} host */
|
|
57
|
+
constructor(host) {
|
|
58
|
+
this.#host = host;
|
|
59
|
+
host.addController(this);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
hostUpdated() {
|
|
63
|
+
const panel = /** @type {HTMLElement | null} */ (
|
|
64
|
+
this.#host.renderRoot?.querySelector('[part~="panel"]') ?? null
|
|
65
|
+
);
|
|
66
|
+
if (panel === this.#panel) return;
|
|
67
|
+
|
|
68
|
+
this.#unbind();
|
|
69
|
+
this.#panel = panel;
|
|
70
|
+
this.#bind();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
hostDisconnected() {
|
|
74
|
+
this.#unbind();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#bind() {
|
|
78
|
+
const panel = this.#panel;
|
|
79
|
+
if (!panel) return;
|
|
80
|
+
panel.addEventListener('touchstart', this.#onStart, { passive: true });
|
|
81
|
+
// Cancelling a move is the whole point, so this one cannot be passive.
|
|
82
|
+
panel.addEventListener('touchmove', this.#onMove, { passive: false });
|
|
83
|
+
panel.addEventListener('touchend', this.#onEnd, { passive: true });
|
|
84
|
+
panel.addEventListener('touchcancel', this.#onEnd, { passive: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#unbind() {
|
|
88
|
+
const panel = this.#panel;
|
|
89
|
+
if (!panel) return;
|
|
90
|
+
panel.removeEventListener('touchstart', this.#onStart);
|
|
91
|
+
panel.removeEventListener('touchmove', this.#onMove);
|
|
92
|
+
panel.removeEventListener('touchend', this.#onEnd);
|
|
93
|
+
panel.removeEventListener('touchcancel', this.#onEnd);
|
|
94
|
+
this.#panel = null;
|
|
95
|
+
this.#touch = undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {TouchEvent} event */
|
|
99
|
+
#onStart = (event) => {
|
|
100
|
+
if (event.touches.length !== 1) {
|
|
101
|
+
this.#touch = undefined;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const touch = event.touches[0];
|
|
105
|
+
this.#touch = { x: touch.clientX, y: touch.clientY, verdict: 'undecided' };
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
#onEnd = () => {
|
|
109
|
+
this.#touch = undefined;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** @param {TouchEvent} event */
|
|
113
|
+
#onMove = (event) => {
|
|
114
|
+
const from = this.#touch;
|
|
115
|
+
if (!from) return;
|
|
116
|
+
|
|
117
|
+
// A second finger arriving turns this into a pinch. Hands off for the
|
|
118
|
+
// rest of the gesture, zoom included.
|
|
119
|
+
if (event.touches.length > 1) {
|
|
120
|
+
this.#touch = undefined;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (from.verdict === 'block') {
|
|
125
|
+
if (event.cancelable) event.preventDefault();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (from.verdict === 'allow') return;
|
|
129
|
+
|
|
130
|
+
const touch = event.touches[0];
|
|
131
|
+
const dx = touch.clientX - from.x;
|
|
132
|
+
const dy = touch.clientY - from.y;
|
|
133
|
+
// Too small to read a direction from, and too small for the browser to
|
|
134
|
+
// have started scrolling either.
|
|
135
|
+
if (Math.abs(dx) < THRESHOLD && Math.abs(dy) < THRESHOLD) return;
|
|
136
|
+
|
|
137
|
+
const vertical = Math.abs(dy) >= Math.abs(dx);
|
|
138
|
+
const allowed = this.#canScroll(event, vertical, vertical ? dy : dx);
|
|
139
|
+
from.verdict = allowed ? 'allow' : 'block';
|
|
140
|
+
if (!allowed && event.cancelable) event.preventDefault();
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Is there something between the touch and the panel that can still scroll
|
|
145
|
+
* the way the finger is going?
|
|
146
|
+
*
|
|
147
|
+
* @param {TouchEvent} event
|
|
148
|
+
* @param {boolean} vertical
|
|
149
|
+
* @param {number} delta Positive when the finger moves down, or right.
|
|
150
|
+
* @returns {boolean}
|
|
151
|
+
*/
|
|
152
|
+
#canScroll(event, vertical, delta) {
|
|
153
|
+
const panel = this.#panel;
|
|
154
|
+
if (!panel) return true;
|
|
155
|
+
|
|
156
|
+
for (const node of event.composedPath()) {
|
|
157
|
+
if (!(node instanceof HTMLElement)) continue;
|
|
158
|
+
|
|
159
|
+
if (this.#hasRoom(node, vertical, delta)) return true;
|
|
160
|
+
if (node === panel) break;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {HTMLElement} element
|
|
167
|
+
* @param {boolean} vertical
|
|
168
|
+
* @param {number} delta
|
|
169
|
+
* @returns {boolean}
|
|
170
|
+
*/
|
|
171
|
+
#hasRoom(element, vertical, delta) {
|
|
172
|
+
const style = getComputedStyle(element);
|
|
173
|
+
const overflow = vertical ? style.overflowY : style.overflowX;
|
|
174
|
+
if (!SCROLLABLE.test(overflow)) return false;
|
|
175
|
+
|
|
176
|
+
const position = vertical ? element.scrollTop : element.scrollLeft;
|
|
177
|
+
const size = vertical ? element.clientHeight : element.clientWidth;
|
|
178
|
+
const content = vertical ? element.scrollHeight : element.scrollWidth;
|
|
179
|
+
if (content <= size + EDGE_SLACK) return false;
|
|
180
|
+
|
|
181
|
+
// A finger moving down pulls the content down, which means reading from
|
|
182
|
+
// further up: there has to be something above the current position.
|
|
183
|
+
return delta > 0 ? position > EDGE_SLACK : position < content - size - EDGE_SLACK;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/** @import { ReactiveController, LitElement } from 'lit' */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Past this the reader has pinched to zoom, and the widget stays out of it.
|
|
7
|
+
* A little over 1 because browsers report scales like 1.0000002.
|
|
8
|
+
*/
|
|
9
|
+
const ZOOMED = 1.01;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Keeps the full-screen panel inside the part of the screen the reader can
|
|
13
|
+
* actually see.
|
|
14
|
+
*
|
|
15
|
+
* On a phone the panel is `position: fixed` across the whole viewport, and
|
|
16
|
+
* that viewport is not what is on screen once a keyboard slides up: the
|
|
17
|
+
* layout viewport keeps its full height, so the composer — and with it the
|
|
18
|
+
* last few messages — ends up behind the keyboard. iOS then scrolls the page
|
|
19
|
+
* to chase the focused field, which moves the fixed panel out of frame
|
|
20
|
+
* instead of helping. `dvh` is no use here either; it tracks the browser's
|
|
21
|
+
* own chrome collapsing, not the keyboard.
|
|
22
|
+
*
|
|
23
|
+
* `visualViewport` is the one thing that does report the visible box, so the
|
|
24
|
+
* panel is pinned to it: its top to `offsetTop`, its height to `height`. The
|
|
25
|
+
* composer lands just above the keyboard, and the conversation gets whatever
|
|
26
|
+
* is left.
|
|
27
|
+
*
|
|
28
|
+
* A pinch is left alone. Zooming is how a reader gets a closer look, and a
|
|
29
|
+
* panel that re-fitted itself to the magnified box would take that away by
|
|
30
|
+
* reflowing the text back to the same apparent size.
|
|
31
|
+
*
|
|
32
|
+
* @implements {ReactiveController}
|
|
33
|
+
*/
|
|
34
|
+
export class ViewportController {
|
|
35
|
+
/** @type {LitElement} */
|
|
36
|
+
#host;
|
|
37
|
+
|
|
38
|
+
/** @type {() => boolean} */
|
|
39
|
+
#enabled;
|
|
40
|
+
|
|
41
|
+
/** @type {(() => void) | undefined} */
|
|
42
|
+
#onChange;
|
|
43
|
+
|
|
44
|
+
/** True while the custom properties are set, to avoid pointless writes. */
|
|
45
|
+
#applied = false;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {LitElement} host
|
|
49
|
+
* @param {{ enabled: () => boolean }} options
|
|
50
|
+
*/
|
|
51
|
+
constructor(host, { enabled }) {
|
|
52
|
+
this.#host = host;
|
|
53
|
+
this.#enabled = enabled;
|
|
54
|
+
host.addController(this);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
hostConnected() {
|
|
58
|
+
const viewport = window.visualViewport;
|
|
59
|
+
if (!viewport) return;
|
|
60
|
+
|
|
61
|
+
this.#onChange = () => this.#apply();
|
|
62
|
+
// Resize is the keyboard arriving and leaving; scroll is iOS shifting the
|
|
63
|
+
// visible box around while it is up.
|
|
64
|
+
viewport.addEventListener('resize', this.#onChange);
|
|
65
|
+
viewport.addEventListener('scroll', this.#onChange);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
hostDisconnected() {
|
|
69
|
+
const viewport = window.visualViewport;
|
|
70
|
+
if (viewport && this.#onChange) {
|
|
71
|
+
viewport.removeEventListener('resize', this.#onChange);
|
|
72
|
+
viewport.removeEventListener('scroll', this.#onChange);
|
|
73
|
+
}
|
|
74
|
+
this.#onChange = undefined;
|
|
75
|
+
this.#clear();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
hostUpdated() {
|
|
79
|
+
this.#apply();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#apply() {
|
|
83
|
+
const viewport = window.visualViewport;
|
|
84
|
+
const panel = this.#host.renderRoot?.querySelector('[part~="panel"]');
|
|
85
|
+
|
|
86
|
+
if (!viewport || !panel || !this.#enabled() || viewport.scale > ZOOMED) {
|
|
87
|
+
this.#clear();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.#host.style.setProperty('--_chit-vv-top', `${Math.round(viewport.offsetTop)}px`);
|
|
92
|
+
this.#host.style.setProperty('--_chit-vv-height', `${Math.round(viewport.height)}px`);
|
|
93
|
+
this.#applied = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#clear() {
|
|
97
|
+
if (!this.#applied) return;
|
|
98
|
+
this.#host.style.removeProperty('--_chit-vv-top');
|
|
99
|
+
this.#host.style.removeProperty('--_chit-vv-height');
|
|
100
|
+
this.#applied = false;
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/events.js
CHANGED
package/src/global.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ChitUI } from './chit-ui.js';
|
|
2
|
-
import type { Message, ChatState, Trigger, Device } from './types.js';
|
|
2
|
+
import type { Message, ChatState, Trigger, Device, Position } from './types.js';
|
|
3
3
|
|
|
4
4
|
export interface ChitUIEventMap {
|
|
5
5
|
'chat-submit': CustomEvent<{ text: string }>;
|
|
@@ -17,6 +17,12 @@ export interface ChitUIEventMap {
|
|
|
17
17
|
'chat-breakpoint-change': CustomEvent<{ device: Device }>;
|
|
18
18
|
'chat-home': CustomEvent<{ trigger: Trigger }>;
|
|
19
19
|
'chat-attach': CustomEvent<{ files: File[] }>;
|
|
20
|
+
'chat-move': CustomEvent<{
|
|
21
|
+
target: 'launcher' | 'panel';
|
|
22
|
+
position: Position;
|
|
23
|
+
offset: { x: number; y: number };
|
|
24
|
+
displacement: { x: number; y: number };
|
|
25
|
+
}>;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
declare global {
|
package/src/i18n/labels.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* @property {string} attach Attach button beside the composer.
|
|
12
12
|
* @property {string} input Composer textarea.
|
|
13
13
|
* @property {string} typing Announced while the other side is typing.
|
|
14
|
+
* @property {string} loading Announced while waiting for an answer.
|
|
15
|
+
* @property {string} move The panel's drag handle.
|
|
14
16
|
* @property {string} toLatest "Jump to newest" button.
|
|
15
17
|
* @property {{ sending: string, sent: string, error: string }} status Delivery state of one's own message.
|
|
16
18
|
* @property {string} charactersLeft Counter, with {n} for the number remaining.
|
|
@@ -29,6 +31,8 @@ const BUILT_IN = {
|
|
|
29
31
|
attach: '画像や動画を添付',
|
|
30
32
|
input: 'メッセージを入力',
|
|
31
33
|
typing: '入力中',
|
|
34
|
+
loading: '応答を待っています',
|
|
35
|
+
move: 'チャットの位置を移動(矢印キー)',
|
|
32
36
|
toLatest: '最新へ',
|
|
33
37
|
status: { sending: '送信中', sent: '送信済み', error: '送信できませんでした' },
|
|
34
38
|
charactersLeft: '残り {n} 文字',
|
|
@@ -44,6 +48,8 @@ const BUILT_IN = {
|
|
|
44
48
|
attach: 'Attach an image or video',
|
|
45
49
|
input: 'Type a message',
|
|
46
50
|
typing: 'Typing',
|
|
51
|
+
loading: 'Waiting for a reply',
|
|
52
|
+
move: 'Move the chat (arrow keys)',
|
|
47
53
|
toLatest: 'Jump to latest',
|
|
48
54
|
status: { sending: 'Sending', sent: 'Sent', error: 'Not delivered' },
|
|
49
55
|
charactersLeft: '{n} characters left',
|
package/src/index.js
CHANGED
|
@@ -7,3 +7,37 @@ if (!customElements.get('chit-ui')) {
|
|
|
7
7
|
|
|
8
8
|
export { ChitUI };
|
|
9
9
|
export { Events } from './events.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The public types, re-exported so a consumer can write
|
|
13
|
+
* `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
|
|
14
|
+
* into `dist/types/` for them.
|
|
15
|
+
*
|
|
16
|
+
* @typedef {import('./types.js').Role} Role
|
|
17
|
+
* @typedef {import('./types.js').MessageStatus} MessageStatus
|
|
18
|
+
* @typedef {import('./types.js').ChatState} ChatState
|
|
19
|
+
* @typedef {import('./types.js').Trigger} Trigger
|
|
20
|
+
* @typedef {import('./types.js').Device} Device
|
|
21
|
+
* @typedef {import('./types.js').Effect} Effect
|
|
22
|
+
* @typedef {import('./types.js').Position} Position
|
|
23
|
+
* @typedef {import('./types.js').BubbleTail} BubbleTail
|
|
24
|
+
* @typedef {import('./types.js').LoadingStyle} LoadingStyle
|
|
25
|
+
* @typedef {import('./types.js').MessageBase} MessageBase
|
|
26
|
+
* @typedef {import('./types.js').TextMessage} TextMessage
|
|
27
|
+
* @typedef {import('./types.js').HtmlMessage} HtmlMessage
|
|
28
|
+
* @typedef {import('./types.js').TemplateMessage} TemplateMessage
|
|
29
|
+
* @typedef {import('./types.js').ElementMessage} ElementMessage
|
|
30
|
+
* @typedef {import('./types.js').ComponentMessage} ComponentMessage
|
|
31
|
+
* @typedef {import('./types.js').Message} Message
|
|
32
|
+
* @typedef {import('./types.js').Animation} Animation
|
|
33
|
+
* @typedef {import('./types.js').Offset} Offset
|
|
34
|
+
* @typedef {import('./types.js').ClosedTheme} ClosedTheme
|
|
35
|
+
* @typedef {import('./types.js').OpenColors} OpenColors
|
|
36
|
+
* @typedef {import('./types.js').SpeakerTheme} SpeakerTheme
|
|
37
|
+
* @typedef {import('./types.js').LoadingTheme} LoadingTheme
|
|
38
|
+
* @typedef {import('./types.js').OpenTheme} OpenTheme
|
|
39
|
+
* @typedef {import('./types.js').Theme} Theme
|
|
40
|
+
* @typedef {import('./types.js').ResolvedClosed} ResolvedClosed
|
|
41
|
+
* @typedef {import('./types.js').ResolvedOpen} ResolvedOpen
|
|
42
|
+
* @typedef {import('./types.js').ResolvedTheme} ResolvedTheme
|
|
43
|
+
*/
|
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/panel.js
CHANGED
|
@@ -53,9 +53,25 @@ export function renderPanel(host) {
|
|
|
53
53
|
function renderHeader(host, title) {
|
|
54
54
|
const open = host.currentTheme.open;
|
|
55
55
|
const labels = host.currentLabels;
|
|
56
|
+
const draggable = open.draggable && host.device === 'pc';
|
|
56
57
|
|
|
57
58
|
return html`
|
|
58
|
-
<header
|
|
59
|
+
<header
|
|
60
|
+
part="header"
|
|
61
|
+
?data-draggable=${draggable}
|
|
62
|
+
tabindex=${draggable ? '0' : nothing}
|
|
63
|
+
aria-label=${draggable ? labels.move : nothing}
|
|
64
|
+
@pointerdown=${(/** @type {PointerEvent} */ event) => {
|
|
65
|
+
// Only the bar itself is a handle: a drag that starts on the close
|
|
66
|
+
// button would swallow the click that closes the panel.
|
|
67
|
+
if (/** @type {HTMLElement} */ (event.target).closest('button')) return;
|
|
68
|
+
host.panelDrag.start(event);
|
|
69
|
+
}}
|
|
70
|
+
@keydown=${(/** @type {KeyboardEvent} */ event) => {
|
|
71
|
+
if (event.target !== event.currentTarget) return;
|
|
72
|
+
host.panelDrag.nudge(event);
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
59
75
|
<slot name="header">
|
|
60
76
|
<slot name="header-title">
|
|
61
77
|
<span part="header-title">
|
|
@@ -29,6 +29,24 @@ export const composerStyles = css`
|
|
|
29
29
|
overflow-y: auto;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/*
|
|
33
|
+
* iOS Safari zooms the page in whenever a field smaller than 16px takes
|
|
34
|
+
* focus, and it does not zoom back out when the field is done with — the
|
|
35
|
+
* reader is left with a widget wider than the screen and no obvious way
|
|
36
|
+
* back. Nothing in the page can undo that zoom short of pinning the
|
|
37
|
+
* viewport's scale, which would also take pinch-zoom away from everyone.
|
|
38
|
+
* So the field is kept at the size that never triggers it, and larger
|
|
39
|
+
* still if the theme's font is larger.
|
|
40
|
+
*
|
|
41
|
+
* Coarse pointers only: on a desktop the same rule would make the field
|
|
42
|
+
* stand out from the rest of the widget for no reason.
|
|
43
|
+
*/
|
|
44
|
+
@media (pointer: coarse) {
|
|
45
|
+
[part~='input'] {
|
|
46
|
+
font-size: max(16px, 1em);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
[part~='input']::placeholder {
|
|
33
51
|
color: var(--chit-color-input-placeholder);
|
|
34
52
|
}
|
|
@@ -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);
|