@hidemikimura/chit-ui 0.2.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 +33 -0
- package/README.md +108 -2
- package/dist/chit-ui.iife.min.js +193 -98
- package/dist/chit-ui.iife.min.js.map +1 -1
- package/dist/chit-ui.min.js +184 -89
- 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/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/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 +255 -4
- package/src/controllers/composer-controller.js +2 -0
- package/src/controllers/drag-controller.js +319 -0
- package/src/events.js +1 -0
- package/src/global.d.ts +7 -1
- package/src/i18n/labels.js +6 -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/launcher.css.js +13 -0
- package/src/styles/message.css.js +60 -15
- package/src/styles/panel.css.js +23 -2
- package/src/theme/default-theme.js +16 -0
- package/src/types.js +18 -0
|
@@ -152,6 +152,7 @@ export class ComposerController {
|
|
|
152
152
|
if (!allowed) return false;
|
|
153
153
|
|
|
154
154
|
this.clear();
|
|
155
|
+
this.#host.handleSubmitted();
|
|
155
156
|
return true;
|
|
156
157
|
}
|
|
157
158
|
|
|
@@ -212,4 +213,5 @@ export class ComposerController {
|
|
|
212
213
|
* @property {boolean} sendOnEnter
|
|
213
214
|
* @property {number | undefined} maxLength
|
|
214
215
|
* @property {import('../i18n/labels.js').Labels} currentLabels
|
|
216
|
+
* @property {() => void} handleSubmitted Called after a submit that no listener cancelled.
|
|
215
217
|
*/
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/** @import { ReactiveController, LitElement } from 'lit' */
|
|
4
|
+
/** @import { Position } from '../types.js' */
|
|
5
|
+
|
|
6
|
+
/** How far a pointer may travel before the gesture counts as a drag, not a click. */
|
|
7
|
+
const SLOP = 4;
|
|
8
|
+
|
|
9
|
+
/** How close to the viewport edge the dragged thing may come. */
|
|
10
|
+
const MARGIN = 8;
|
|
11
|
+
|
|
12
|
+
/** Arrow-key step, and the bigger step Shift asks for. */
|
|
13
|
+
const STEP = 8;
|
|
14
|
+
const BIG_STEP = 32;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Moves one thing — the launcher or the panel — around the viewport.
|
|
18
|
+
*
|
|
19
|
+
* What a gesture produces is not a position but a displacement: how far the
|
|
20
|
+
* reader has dragged the widget from wherever the theme put it, in screen
|
|
21
|
+
* pixels. The host keeps one such displacement for the whole widget and hands
|
|
22
|
+
* it to both controllers, which is what makes the two states travel together:
|
|
23
|
+
* drag the launcher into a corner and the panel opens in that same corner,
|
|
24
|
+
* because both are the theme's position plus the same shift.
|
|
25
|
+
*
|
|
26
|
+
* Each controller then turns the displacement into the terms its own corner
|
|
27
|
+
* uses — at a right-hand corner, moving right means a smaller offset — and
|
|
28
|
+
* writes it into the two custom properties the stylesheet already reads. The
|
|
29
|
+
* offset lives on the host as an inline property, which outranks the theme's
|
|
30
|
+
* sheet and the page's CSS: a reader who dragged the widget somewhere means
|
|
31
|
+
* it, and until the drag is cleared theirs is the most specific word on where
|
|
32
|
+
* it goes.
|
|
33
|
+
*
|
|
34
|
+
* @implements {ReactiveController}
|
|
35
|
+
*/
|
|
36
|
+
export class DragController {
|
|
37
|
+
/** @type {LitElement} */
|
|
38
|
+
#host;
|
|
39
|
+
|
|
40
|
+
/** @type {DragOptions} */
|
|
41
|
+
#options;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Pointer state, only while a gesture is in flight.
|
|
45
|
+
*
|
|
46
|
+
* @type {{
|
|
47
|
+
* pointer: { x: number, y: number },
|
|
48
|
+
* displacement: { x: number, y: number },
|
|
49
|
+
* size: { width: number, height: number },
|
|
50
|
+
* corner: Position,
|
|
51
|
+
* base: { x: number, y: number },
|
|
52
|
+
* moved: boolean,
|
|
53
|
+
* pointerId: number,
|
|
54
|
+
* target: HTMLElement,
|
|
55
|
+
* } | undefined}
|
|
56
|
+
*/
|
|
57
|
+
#from;
|
|
58
|
+
|
|
59
|
+
/** True once a gesture has passed the slop, until the click that follows it. */
|
|
60
|
+
#dragged = false;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {LitElement} host
|
|
64
|
+
* @param {DragOptions} options
|
|
65
|
+
*/
|
|
66
|
+
constructor(host, options) {
|
|
67
|
+
this.#host = host;
|
|
68
|
+
this.#options = options;
|
|
69
|
+
host.addController(this);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
hostConnected() {
|
|
73
|
+
// A smaller window can leave the widget hanging off the edge; placing it
|
|
74
|
+
// again runs it back through the clamp.
|
|
75
|
+
this.#onResize = () => {
|
|
76
|
+
const now = this.#options.current();
|
|
77
|
+
if (now) this.#options.onMove(now);
|
|
78
|
+
};
|
|
79
|
+
window.addEventListener('resize', this.#onResize);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
hostDisconnected() {
|
|
83
|
+
if (this.#onResize) window.removeEventListener('resize', this.#onResize);
|
|
84
|
+
this.#onResize = undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @type {(() => void) | undefined} */
|
|
88
|
+
#onResize;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* True when the gesture that just ended was a drag. Reading it clears it, so
|
|
92
|
+
* the click a pointer-up fires can be skipped exactly once.
|
|
93
|
+
*
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
consumeDrag() {
|
|
97
|
+
const dragged = this.#dragged;
|
|
98
|
+
this.#dragged = false;
|
|
99
|
+
return dragged;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Put this thing where the displacement says, within the viewport.
|
|
104
|
+
*
|
|
105
|
+
* @param {{ x: number, y: number } | null} displacement
|
|
106
|
+
* @returns {{ x: number, y: number } | undefined} Where it ended up, when it is on screen.
|
|
107
|
+
*/
|
|
108
|
+
place(displacement) {
|
|
109
|
+
if (!displacement) {
|
|
110
|
+
for (const name of [this.#options.vars.x, this.#options.vars.y]) {
|
|
111
|
+
this.#host.style.removeProperty(name);
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const element = this.#options.element();
|
|
117
|
+
const offset = this.#toOffset(displacement);
|
|
118
|
+
// Nothing on screen yet: write it anyway, so the thing is already in
|
|
119
|
+
// place the moment it is rendered, and let the next pass clamp it.
|
|
120
|
+
const placed = element ? this.#clamp(offset, this.#options.size()) : offset;
|
|
121
|
+
|
|
122
|
+
this.#host.style.setProperty(this.#options.vars.x, `${placed.x}px`);
|
|
123
|
+
this.#host.style.setProperty(this.#options.vars.y, `${placed.y}px`);
|
|
124
|
+
return element ? placed : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Begin a drag.
|
|
129
|
+
*
|
|
130
|
+
* @param {PointerEvent} event
|
|
131
|
+
* @returns {void}
|
|
132
|
+
*/
|
|
133
|
+
start(event) {
|
|
134
|
+
if (!this.#options.enabled() || event.button !== 0) return;
|
|
135
|
+
const element = this.#options.element();
|
|
136
|
+
if (!element) return;
|
|
137
|
+
|
|
138
|
+
this.#from = {
|
|
139
|
+
pointer: { x: event.clientX, y: event.clientY },
|
|
140
|
+
displacement: this.#options.current() ?? { x: 0, y: 0 },
|
|
141
|
+
size: this.#options.size(),
|
|
142
|
+
corner: this.#options.corner(),
|
|
143
|
+
base: this.#options.base(),
|
|
144
|
+
moved: false,
|
|
145
|
+
pointerId: event.pointerId,
|
|
146
|
+
target: /** @type {HTMLElement} */ (event.currentTarget),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Capture keeps the moves coming even when the pointer leaves the handle.
|
|
150
|
+
// It throws if the pointer has already gone; the drag still works without
|
|
151
|
+
// it, so there is nothing to do about that but carry on.
|
|
152
|
+
try {
|
|
153
|
+
this.#from.target.setPointerCapture(event.pointerId);
|
|
154
|
+
} catch {
|
|
155
|
+
/* no capture available */
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
this.#from.target.addEventListener('pointermove', this.#onPointerMove);
|
|
159
|
+
this.#from.target.addEventListener('pointerup', this.#onPointerEnd);
|
|
160
|
+
this.#from.target.addEventListener('pointercancel', this.#onPointerEnd);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** @param {PointerEvent} event */
|
|
164
|
+
#onPointerMove = (event) => {
|
|
165
|
+
const from = this.#from;
|
|
166
|
+
if (!from || event.pointerId !== from.pointerId) return;
|
|
167
|
+
|
|
168
|
+
const dx = event.clientX - from.pointer.x;
|
|
169
|
+
const dy = event.clientY - from.pointer.y;
|
|
170
|
+
if (!from.moved && Math.hypot(dx, dy) < SLOP) return;
|
|
171
|
+
|
|
172
|
+
from.moved = true;
|
|
173
|
+
// A drag must not also select text or scroll the page under the finger.
|
|
174
|
+
event.preventDefault();
|
|
175
|
+
this.#options.onMove(
|
|
176
|
+
this.#limit(
|
|
177
|
+
{ x: from.displacement.x + dx, y: from.displacement.y + dy },
|
|
178
|
+
from.base,
|
|
179
|
+
from.corner,
|
|
180
|
+
from.size,
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** @param {PointerEvent} event */
|
|
186
|
+
#onPointerEnd = (event) => {
|
|
187
|
+
const from = this.#from;
|
|
188
|
+
if (!from || event.pointerId !== from.pointerId) return;
|
|
189
|
+
|
|
190
|
+
from.target.removeEventListener('pointermove', this.#onPointerMove);
|
|
191
|
+
from.target.removeEventListener('pointerup', this.#onPointerEnd);
|
|
192
|
+
from.target.removeEventListener('pointercancel', this.#onPointerEnd);
|
|
193
|
+
try {
|
|
194
|
+
if (from.target.hasPointerCapture(event.pointerId)) {
|
|
195
|
+
from.target.releasePointerCapture(event.pointerId);
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
/* never had it */
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this.#from = undefined;
|
|
202
|
+
if (!from.moved) return;
|
|
203
|
+
this.#dragged = true;
|
|
204
|
+
this.#options.onSettle(this.#options.name);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Move with the arrow keys, so the same thing can be done without a pointer.
|
|
209
|
+
*
|
|
210
|
+
* @param {KeyboardEvent} event
|
|
211
|
+
* @returns {boolean} True when the key was used.
|
|
212
|
+
*/
|
|
213
|
+
nudge(event) {
|
|
214
|
+
if (!this.#options.enabled()) return false;
|
|
215
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return false;
|
|
216
|
+
|
|
217
|
+
/** @type {Record<string, [number, number]>} */
|
|
218
|
+
const directions = {
|
|
219
|
+
ArrowLeft: [-1, 0],
|
|
220
|
+
ArrowRight: [1, 0],
|
|
221
|
+
ArrowUp: [0, -1],
|
|
222
|
+
ArrowDown: [0, 1],
|
|
223
|
+
};
|
|
224
|
+
const direction = directions[event.key];
|
|
225
|
+
if (!direction) return false;
|
|
226
|
+
|
|
227
|
+
const element = this.#options.element();
|
|
228
|
+
if (!element) return false;
|
|
229
|
+
|
|
230
|
+
const step = event.shiftKey ? BIG_STEP : STEP;
|
|
231
|
+
const now = this.#options.current() ?? { x: 0, y: 0 };
|
|
232
|
+
event.preventDefault();
|
|
233
|
+
this.#options.onMove(
|
|
234
|
+
this.#limit(
|
|
235
|
+
{ x: now.x + direction[0] * step, y: now.y + direction[1] * step },
|
|
236
|
+
this.#options.base(),
|
|
237
|
+
this.#options.corner(),
|
|
238
|
+
this.#options.size(),
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
this.#options.onSettle(this.#options.name);
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The displacement in the terms this corner counts in.
|
|
247
|
+
*
|
|
248
|
+
* @param {{ x: number, y: number }} displacement
|
|
249
|
+
* @param {{ x: number, y: number }} [base]
|
|
250
|
+
* @param {Position} [corner]
|
|
251
|
+
* @returns {{ x: number, y: number }}
|
|
252
|
+
*/
|
|
253
|
+
#toOffset(displacement, base = this.#options.base(), corner = this.#options.corner()) {
|
|
254
|
+
return {
|
|
255
|
+
x: base.x + (corner.endsWith('right') ? -displacement.x : displacement.x),
|
|
256
|
+
y: base.y + (corner.startsWith('bottom') ? -displacement.y : displacement.y),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* As much of a displacement as this thing can take without leaving the
|
|
262
|
+
* viewport. The dragged thing sets the shared displacement, so the limit
|
|
263
|
+
* has to be expressed there rather than only in the offset it writes.
|
|
264
|
+
*
|
|
265
|
+
* The size is the size the thing means to be, not the one it happens to
|
|
266
|
+
* have: the panel's own stylesheet caps it against the space between its
|
|
267
|
+
* corner and the far edge, so measuring it while it is being pushed into
|
|
268
|
+
* that corner would read back a smaller box and let it be pushed further,
|
|
269
|
+
* squeezing it flat instead of stopping it.
|
|
270
|
+
*
|
|
271
|
+
* @param {{ x: number, y: number }} displacement
|
|
272
|
+
* @param {{ x: number, y: number }} base
|
|
273
|
+
* @param {Position} corner
|
|
274
|
+
* @param {{ width: number, height: number }} size
|
|
275
|
+
* @returns {{ x: number, y: number }}
|
|
276
|
+
*/
|
|
277
|
+
#limit(displacement, base, corner, size) {
|
|
278
|
+
const offset = this.#clamp(this.#toOffset(displacement, base, corner), size);
|
|
279
|
+
return {
|
|
280
|
+
x: corner.endsWith('right') ? base.x - offset.x : offset.x - base.x,
|
|
281
|
+
y: corner.startsWith('bottom') ? base.y - offset.y : offset.y - base.y,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* @param {{ x: number, y: number }} offset
|
|
287
|
+
* @param {{ width: number, height: number }} size
|
|
288
|
+
* @returns {{ x: number, y: number }}
|
|
289
|
+
*/
|
|
290
|
+
#clamp(offset, size) {
|
|
291
|
+
return {
|
|
292
|
+
x: clamp(offset.x, window.innerWidth - size.width),
|
|
293
|
+
y: clamp(offset.y, window.innerHeight - size.height),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* @param {number} value
|
|
300
|
+
* @param {number} max The offset at which the far edge touches the viewport.
|
|
301
|
+
* @returns {number}
|
|
302
|
+
*/
|
|
303
|
+
function clamp(value, max) {
|
|
304
|
+
return Math.round(Math.min(Math.max(value, MARGIN), Math.max(MARGIN, max - MARGIN)));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* @typedef {Object} DragOptions
|
|
309
|
+
* @property {'launcher' | 'panel'} name
|
|
310
|
+
* @property {() => boolean} enabled
|
|
311
|
+
* @property {() => HTMLElement | null} element What moves.
|
|
312
|
+
* @property {() => { width: number, height: number }} size How big it means to be.
|
|
313
|
+
* @property {() => Position} corner Which corner the offset counts from.
|
|
314
|
+
* @property {() => { x: number, y: number }} base The theme's offset, before any drag.
|
|
315
|
+
* @property {() => { x: number, y: number } | null} current The widget's displacement now.
|
|
316
|
+
* @property {(displacement: { x: number, y: number }) => void} onMove A new displacement.
|
|
317
|
+
* @property {(name: 'launcher' | 'panel') => void} onSettle The gesture ended here.
|
|
318
|
+
* @property {{ x: string, y: string }} vars The custom properties to write.
|
|
319
|
+
*/
|
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/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">
|
|
@@ -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);
|
|
@@ -102,7 +102,8 @@ export const messageStyles = css`
|
|
|
102
102
|
*/
|
|
103
103
|
|
|
104
104
|
[part~='bubble']::after,
|
|
105
|
-
[part~='typing']::after
|
|
105
|
+
[part~='typing']::after,
|
|
106
|
+
[part~='loading']::after {
|
|
106
107
|
content: '';
|
|
107
108
|
display: none;
|
|
108
109
|
position: absolute;
|
|
@@ -118,9 +119,11 @@ export const messageStyles = css`
|
|
|
118
119
|
*/
|
|
119
120
|
@supports (clip-path: path('M 0 0 Z')) {
|
|
120
121
|
:host([data-bubble-tail='top']) [part~='bubble']::after,
|
|
121
|
-
:host([data-bubble-tail='bottom']) [part~='bubble']::after,
|
|
122
122
|
:host([data-bubble-tail='top']) [part~='typing']::after,
|
|
123
|
-
:host([data-bubble-tail='
|
|
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 {
|
|
124
127
|
display: block;
|
|
125
128
|
}
|
|
126
129
|
|
|
@@ -129,15 +132,18 @@ export const messageStyles = css`
|
|
|
129
132
|
* bubble carries one cue about who is talking, not two.
|
|
130
133
|
*/
|
|
131
134
|
:host([data-bubble-tail='top']) [part~='bubble'],
|
|
132
|
-
:host([data-bubble-tail='bottom']) [part~='bubble'],
|
|
133
135
|
:host([data-bubble-tail='top']) [part~='typing'],
|
|
134
|
-
:host([data-bubble-tail='
|
|
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'] {
|
|
135
140
|
border-radius: var(--chit-bubble-radius);
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
/* Their side: the leaf points left, its root inside the bubble. */
|
|
139
144
|
:host([data-bubble-tail]) [part~='message-assistant'] [part~='bubble']::after,
|
|
140
|
-
:host([data-bubble-tail]) [part~='typing']::after
|
|
145
|
+
:host([data-bubble-tail]) [part~='typing']::after,
|
|
146
|
+
:host([data-bubble-tail]) [part~='loading']::after {
|
|
141
147
|
right: calc(100% - var(--_chit-tail-root));
|
|
142
148
|
background: var(--chit-color-assistant-bg);
|
|
143
149
|
}
|
|
@@ -150,17 +156,20 @@ export const messageStyles = css`
|
|
|
150
156
|
}
|
|
151
157
|
|
|
152
158
|
:host([data-bubble-tail='top']) [part~='bubble']::after,
|
|
153
|
-
:host([data-bubble-tail='top']) [part~='typing']::after
|
|
159
|
+
:host([data-bubble-tail='top']) [part~='typing']::after,
|
|
160
|
+
:host([data-bubble-tail='top']) [part~='loading']::after {
|
|
154
161
|
top: var(--_chit-tail-offset);
|
|
155
162
|
}
|
|
156
163
|
|
|
157
164
|
:host([data-bubble-tail='bottom']) [part~='bubble']::after,
|
|
158
|
-
:host([data-bubble-tail='bottom']) [part~='typing']::after
|
|
165
|
+
:host([data-bubble-tail='bottom']) [part~='typing']::after,
|
|
166
|
+
:host([data-bubble-tail='bottom']) [part~='loading']::after {
|
|
159
167
|
bottom: var(--_chit-tail-offset);
|
|
160
168
|
}
|
|
161
169
|
|
|
162
170
|
:host([data-bubble-tail='bottom']) [part~='message-assistant'] [part~='bubble']::after,
|
|
163
|
-
:host([data-bubble-tail='bottom']) [part~='typing']::after
|
|
171
|
+
:host([data-bubble-tail='bottom']) [part~='typing']::after,
|
|
172
|
+
:host([data-bubble-tail='bottom']) [part~='loading']::after {
|
|
164
173
|
transform: scaleY(-1);
|
|
165
174
|
}
|
|
166
175
|
|
|
@@ -228,24 +237,57 @@ export const messageStyles = css`
|
|
|
228
237
|
}
|
|
229
238
|
}
|
|
230
239
|
|
|
231
|
-
[part~='typing']
|
|
240
|
+
[part~='typing'],
|
|
241
|
+
[part~='loading'] {
|
|
232
242
|
position: relative;
|
|
233
243
|
align-self: flex-start;
|
|
234
244
|
/* Indented past the speaker's icon, when the theme gives them one. */
|
|
235
245
|
margin-inline-start: var(--_chit-speaker-gutter, 0px);
|
|
236
246
|
padding: 0.7em 0.9em;
|
|
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;
|
|
237
253
|
border-radius: var(--chit-bubble-radius);
|
|
238
254
|
border-bottom-left-radius: 4px;
|
|
239
255
|
background: var(--chit-color-assistant-bg);
|
|
240
256
|
color: var(--chit-color-assistant-text);
|
|
241
257
|
}
|
|
242
258
|
|
|
243
|
-
|
|
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 {
|
|
244
285
|
display: inline-flex;
|
|
245
286
|
gap: 0.25em;
|
|
246
287
|
}
|
|
247
288
|
|
|
248
|
-
[part~='typing'] .dots i
|
|
289
|
+
[part~='typing'] .dots i,
|
|
290
|
+
[part~='loading'] .dots i {
|
|
249
291
|
width: 0.4em;
|
|
250
292
|
height: 0.4em;
|
|
251
293
|
border-radius: 50%;
|
|
@@ -254,10 +296,12 @@ export const messageStyles = css`
|
|
|
254
296
|
animation: chit-typing 1.2s ease-in-out infinite;
|
|
255
297
|
}
|
|
256
298
|
|
|
257
|
-
[part~='typing'] .dots i:nth-child(2)
|
|
299
|
+
[part~='typing'] .dots i:nth-child(2),
|
|
300
|
+
[part~='loading'] .dots i:nth-child(2) {
|
|
258
301
|
animation-delay: 0.15s;
|
|
259
302
|
}
|
|
260
|
-
[part~='typing'] .dots i:nth-child(3)
|
|
303
|
+
[part~='typing'] .dots i:nth-child(3),
|
|
304
|
+
[part~='loading'] .dots i:nth-child(3) {
|
|
261
305
|
animation-delay: 0.3s;
|
|
262
306
|
}
|
|
263
307
|
|
|
@@ -299,7 +343,8 @@ export const messageStyles = css`
|
|
|
299
343
|
|
|
300
344
|
@media (prefers-reduced-motion: reduce) {
|
|
301
345
|
[part~='cursor'],
|
|
302
|
-
[part~='typing'] .dots i
|
|
346
|
+
[part~='typing'] .dots i,
|
|
347
|
+
[part~='loading'] .dots i {
|
|
303
348
|
animation: none;
|
|
304
349
|
}
|
|
305
350
|
}
|