@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,316 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { Events, emit } from '../events.js';
|
|
3
|
+
|
|
4
|
+
/** @import { ReactiveController, LitElement } from 'lit' */
|
|
5
|
+
/** @import { ChatState, Trigger, Effect } from '../types.js' */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {{ effect: Effect, duration: number }} AnimationSpec
|
|
9
|
+
* @typedef {(from: ChatState, to: ChatState, phase: 'enter' | 'exit') => AnimationSpec} AnimationResolver
|
|
10
|
+
*
|
|
11
|
+
* @typedef {Object} StateControllerOptions
|
|
12
|
+
* @property {AnimationResolver} animation Which effect and duration to play.
|
|
13
|
+
* @property {() => boolean} keepsLauncher True when the launcher stays visible behind an open panel.
|
|
14
|
+
* @property {() => void} onOpened Called once the panel is open and settled.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Which `part` carries the visible element of each state. */
|
|
18
|
+
const PART_FOR_STATE = /** @type {const} */ ({
|
|
19
|
+
closed: 'launcher',
|
|
20
|
+
open: 'panel',
|
|
21
|
+
hidden: null,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Owns everything about moving between closed / open / hidden.
|
|
26
|
+
*
|
|
27
|
+
* The host's `state` property changes the instant it is assigned; what the DOM
|
|
28
|
+
* shows lags behind it for as long as the transition animation runs. That
|
|
29
|
+
* lagging value is `renderedState`, and the render functions key off it — so a
|
|
30
|
+
* panel animating out is still in the DOM while `state` already reads 'closed'.
|
|
31
|
+
*
|
|
32
|
+
* @implements {ReactiveController}
|
|
33
|
+
*/
|
|
34
|
+
export class StateController {
|
|
35
|
+
/** @type {LitElement & { state: ChatState }} */
|
|
36
|
+
#host;
|
|
37
|
+
/** @type {AnimationResolver} */
|
|
38
|
+
#resolveAnimation;
|
|
39
|
+
/** @type {() => boolean} */
|
|
40
|
+
#keepsLauncher;
|
|
41
|
+
/** @type {() => void} */
|
|
42
|
+
#onOpened;
|
|
43
|
+
|
|
44
|
+
/** Logical state: the target of the most recent transition. */
|
|
45
|
+
#current = /** @type {ChatState} */ ('closed');
|
|
46
|
+
/** What the DOM is showing right now. */
|
|
47
|
+
#rendered = /** @type {ChatState} */ ('closed');
|
|
48
|
+
|
|
49
|
+
/** Guards the host's own `state` setter against re-entering us. */
|
|
50
|
+
#applying = false;
|
|
51
|
+
/** Bumped per transition so a superseded one can bail out after an await. */
|
|
52
|
+
#generation = 0;
|
|
53
|
+
/** @type {((settled: boolean) => void) | null} */
|
|
54
|
+
#settle = null;
|
|
55
|
+
/** @type {(() => void) | null} */
|
|
56
|
+
#stopWaiting = null;
|
|
57
|
+
/** @type {boolean} */
|
|
58
|
+
#restoreFocusToLauncher = false;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {LitElement & { state: ChatState }} host
|
|
62
|
+
* @param {StateControllerOptions} options
|
|
63
|
+
*/
|
|
64
|
+
constructor(host, { animation, keepsLauncher, onOpened }) {
|
|
65
|
+
this.#host = host;
|
|
66
|
+
this.#resolveAnimation = animation;
|
|
67
|
+
this.#keepsLauncher = keepsLauncher;
|
|
68
|
+
this.#onOpened = onOpened;
|
|
69
|
+
host.addController(this);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @returns {ChatState} What the DOM currently shows. */
|
|
73
|
+
get renderedState() {
|
|
74
|
+
return this.#rendered;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @returns {boolean} True while the host's own setter must not call us back. */
|
|
78
|
+
get applying() {
|
|
79
|
+
return this.#applying;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
hostConnected() {
|
|
83
|
+
this.#current = this.#host.state;
|
|
84
|
+
this.#rendered = this.#host.state;
|
|
85
|
+
this.#host.dataset.renderedState = this.#rendered;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
hostDisconnected() {
|
|
89
|
+
this.#stopWaiting?.();
|
|
90
|
+
this.#stopWaiting = null;
|
|
91
|
+
this.#settle?.(false);
|
|
92
|
+
this.#settle = null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
hostUpdated() {
|
|
96
|
+
this.#host.dataset.renderedState = this.#rendered;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Move to `to`, running the before-event, the animations and the completion
|
|
101
|
+
* events in order.
|
|
102
|
+
*
|
|
103
|
+
* @param {ChatState} to
|
|
104
|
+
* @param {Trigger} trigger
|
|
105
|
+
* @returns {Promise<boolean>} false when a listener cancelled it, or when a
|
|
106
|
+
* later transition superseded this one.
|
|
107
|
+
*/
|
|
108
|
+
request(to, trigger) {
|
|
109
|
+
const from = this.#current;
|
|
110
|
+
if (to === from) return Promise.resolve(true);
|
|
111
|
+
|
|
112
|
+
if (!this.#announceIntent(from, to, trigger)) {
|
|
113
|
+
this.#assign(from);
|
|
114
|
+
return Promise.resolve(false);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A transition already in flight loses: snap the DOM to where it was
|
|
118
|
+
// heading, tell its caller it did not finish, and start over from there.
|
|
119
|
+
this.#interrupt();
|
|
120
|
+
|
|
121
|
+
this.#current = to;
|
|
122
|
+
this.#assign(to);
|
|
123
|
+
emit(this.#host, Events.STATE_CHANGE, { from, to, trigger });
|
|
124
|
+
|
|
125
|
+
return this.#run(from, to, trigger);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Fire the cancelable before-event for this transition, if it has one.
|
|
130
|
+
*
|
|
131
|
+
* @param {ChatState} from
|
|
132
|
+
* @param {ChatState} to
|
|
133
|
+
* @param {Trigger} trigger
|
|
134
|
+
* @returns {boolean} false when a listener called preventDefault().
|
|
135
|
+
*/
|
|
136
|
+
#announceIntent(from, to, trigger) {
|
|
137
|
+
if (to === 'open') {
|
|
138
|
+
return emit(this.#host, Events.BEFORE_OPEN, { from, trigger }, { cancelable: true });
|
|
139
|
+
}
|
|
140
|
+
if (to === 'closed' && from === 'open') {
|
|
141
|
+
return emit(this.#host, Events.BEFORE_CLOSE, { from, trigger }, { cancelable: true });
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#interrupt() {
|
|
147
|
+
this.#generation += 1;
|
|
148
|
+
this.#stopWaiting?.();
|
|
149
|
+
this.#stopWaiting = null;
|
|
150
|
+
this.#settle?.(false);
|
|
151
|
+
this.#settle = null;
|
|
152
|
+
if (this.#rendered !== this.#current) {
|
|
153
|
+
this.#rendered = this.#current;
|
|
154
|
+
this.#host.requestUpdate();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {ChatState} from
|
|
160
|
+
* @param {ChatState} to
|
|
161
|
+
* @param {Trigger} trigger
|
|
162
|
+
* @returns {Promise<boolean>}
|
|
163
|
+
*/
|
|
164
|
+
#run(from, to, trigger) {
|
|
165
|
+
const generation = this.#generation;
|
|
166
|
+
/** @type {Promise<boolean>} */
|
|
167
|
+
const settled = new Promise((resolve) => {
|
|
168
|
+
this.#settle = resolve;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
void (async () => {
|
|
172
|
+
this.#restoreFocusToLauncher = this.#focusIsInsidePanel();
|
|
173
|
+
await this.#host.updateComplete;
|
|
174
|
+
if (generation !== this.#generation) return;
|
|
175
|
+
|
|
176
|
+
const leaving = this.#elementFor(from);
|
|
177
|
+
if (leaving && !this.#launcherSurvives(from, to)) {
|
|
178
|
+
await this.#animate(leaving, 'exit', this.#resolveAnimation(from, to, 'exit'));
|
|
179
|
+
if (generation !== this.#generation) return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.#rendered = to;
|
|
183
|
+
this.#host.requestUpdate();
|
|
184
|
+
await this.#host.updateComplete;
|
|
185
|
+
if (generation !== this.#generation) return;
|
|
186
|
+
|
|
187
|
+
const arriving = this.#elementFor(to);
|
|
188
|
+
if (arriving) {
|
|
189
|
+
await this.#animate(arriving, 'enter', this.#resolveAnimation(from, to, 'enter'));
|
|
190
|
+
if (generation !== this.#generation) return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
this.#announceArrival(from, to, trigger);
|
|
194
|
+
this.#manageFocus(to);
|
|
195
|
+
|
|
196
|
+
this.#settle?.(true);
|
|
197
|
+
this.#settle = null;
|
|
198
|
+
})();
|
|
199
|
+
|
|
200
|
+
return settled;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Whether the element of `from` survives the transition and so must not play
|
|
205
|
+
* an exit animation. Only the launcher can, and only when the theme asks to
|
|
206
|
+
* keep it visible behind an open panel.
|
|
207
|
+
*
|
|
208
|
+
* @param {ChatState} from
|
|
209
|
+
* @param {ChatState} to
|
|
210
|
+
* @returns {boolean}
|
|
211
|
+
*/
|
|
212
|
+
#launcherSurvives(from, to) {
|
|
213
|
+
return from === 'closed' && to === 'open' && this.#keepsLauncher();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @param {ChatState} from
|
|
218
|
+
* @param {ChatState} to
|
|
219
|
+
* @param {Trigger} trigger
|
|
220
|
+
*/
|
|
221
|
+
#announceArrival(from, to, trigger) {
|
|
222
|
+
if (from === 'hidden') emit(this.#host, Events.SHOW, { from, to });
|
|
223
|
+
if (to === 'hidden') {
|
|
224
|
+
emit(this.#host, Events.HIDE, { from, to });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (to === 'open') emit(this.#host, Events.OPEN, { from, trigger });
|
|
228
|
+
else if (to === 'closed' && from === 'open') emit(this.#host, Events.CLOSE, { from, trigger });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** @param {ChatState} to */
|
|
232
|
+
#manageFocus(to) {
|
|
233
|
+
if (to === 'open') {
|
|
234
|
+
this.#onOpened();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (to === 'closed' && this.#restoreFocusToLauncher) {
|
|
238
|
+
this.#elementFor('closed')?.focus();
|
|
239
|
+
}
|
|
240
|
+
this.#restoreFocusToLauncher = false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#focusIsInsidePanel() {
|
|
244
|
+
const active = this.#host.renderRoot instanceof ShadowRoot
|
|
245
|
+
? this.#host.renderRoot.activeElement
|
|
246
|
+
: null;
|
|
247
|
+
const panel = this.#elementFor('open');
|
|
248
|
+
return !!(active && panel && (active === panel || panel.contains(active)));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* @param {ChatState} state
|
|
253
|
+
* @returns {HTMLElement | null}
|
|
254
|
+
*/
|
|
255
|
+
#elementFor(state) {
|
|
256
|
+
const part = PART_FOR_STATE[state];
|
|
257
|
+
if (!part) return null;
|
|
258
|
+
return /** @type {HTMLElement | null} */ (
|
|
259
|
+
this.#host.renderRoot.querySelector(`[part~="${part}"]`)
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Assign the host's `state` without the setter calling us back.
|
|
265
|
+
*
|
|
266
|
+
* @param {ChatState} state
|
|
267
|
+
*/
|
|
268
|
+
#assign(state) {
|
|
269
|
+
if (this.#host.state === state) return;
|
|
270
|
+
this.#applying = true;
|
|
271
|
+
this.#host.state = state;
|
|
272
|
+
this.#applying = false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Play one CSS animation and resolve when it ends. Resolves immediately when
|
|
277
|
+
* there is nothing to play; falls back to a timer in case `animationend`
|
|
278
|
+
* never arrives (a backgrounded tab, a display:none ancestor).
|
|
279
|
+
*
|
|
280
|
+
* @param {HTMLElement} element
|
|
281
|
+
* @param {'enter' | 'exit'} phase
|
|
282
|
+
* @param {AnimationSpec} spec
|
|
283
|
+
* @returns {Promise<void>}
|
|
284
|
+
*/
|
|
285
|
+
#animate(element, phase, spec) {
|
|
286
|
+
const { effect, duration } = spec;
|
|
287
|
+
const skip =
|
|
288
|
+
effect === 'none' ||
|
|
289
|
+
duration <= 0 ||
|
|
290
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
291
|
+
if (skip) return Promise.resolve();
|
|
292
|
+
|
|
293
|
+
return new Promise((resolve) => {
|
|
294
|
+
let finished = false;
|
|
295
|
+
/** @param {Event} [event] */
|
|
296
|
+
const finish = (event) => {
|
|
297
|
+
if (event && event.target !== element) return;
|
|
298
|
+
if (finished) return;
|
|
299
|
+
finished = true;
|
|
300
|
+
clearTimeout(timer);
|
|
301
|
+
element.removeEventListener('animationend', finish);
|
|
302
|
+
element.removeAttribute('data-anim');
|
|
303
|
+
element.style.removeProperty('--_chit-anim-duration');
|
|
304
|
+
if (this.#stopWaiting === finish) this.#stopWaiting = null;
|
|
305
|
+
resolve();
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
element.dataset.effect = effect;
|
|
309
|
+
element.dataset.anim = phase;
|
|
310
|
+
element.style.setProperty('--_chit-anim-duration', `${duration}ms`);
|
|
311
|
+
element.addEventListener('animationend', finish);
|
|
312
|
+
const timer = setTimeout(finish, duration + 50);
|
|
313
|
+
this.#stopWaiting = finish;
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { resolveTheme, breakpointOf } from '../theme/merge-theme.js';
|
|
3
|
+
import { themeToCss } from '../theme/theme-to-css.js';
|
|
4
|
+
import { AdoptedSheet } from '../styles/adopted-sheet.js';
|
|
5
|
+
|
|
6
|
+
/** @import { ReactiveController, LitElement } from 'lit' */
|
|
7
|
+
/** @import { Theme, ResolvedTheme, Device } from '../types.js' */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Turns the `theme` property into the custom properties everything else reads.
|
|
11
|
+
*
|
|
12
|
+
* The generated sheet is adopted *after* the component's own stylesheets, so a
|
|
13
|
+
* theme value beats the fallback declared alongside the rules that use it.
|
|
14
|
+
* Page CSS still wins over both: a `chit-ui { --chit-color-accent: ... }` rule
|
|
15
|
+
* lives in the outer tree, and the outer tree takes precedence over `:host`.
|
|
16
|
+
*
|
|
17
|
+
* @implements {ReactiveController}
|
|
18
|
+
*/
|
|
19
|
+
export class ThemeController {
|
|
20
|
+
/** @type {LitElement} */
|
|
21
|
+
#host;
|
|
22
|
+
/** @type {AdoptedSheet} */
|
|
23
|
+
#sheet = new AdoptedSheet('data-chit-theme');
|
|
24
|
+
/** @type {ResolvedTheme} */
|
|
25
|
+
#resolved;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {LitElement} host
|
|
29
|
+
* @param {{ device: () => Device }} options
|
|
30
|
+
*/
|
|
31
|
+
constructor(host, { device }) {
|
|
32
|
+
this.#host = host;
|
|
33
|
+
this.#resolved = resolveTheme(undefined, device());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @returns {ResolvedTheme} The theme in force, defaults filled in. */
|
|
37
|
+
get current() {
|
|
38
|
+
return this.#resolved;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
hostConnected() {
|
|
42
|
+
this.#write();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
hostDisconnected() {
|
|
46
|
+
this.#sheet.detach();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The breakpoint a theme asks for. Needed before `apply`, because the device
|
|
51
|
+
* cannot be decided until the breakpoint is known.
|
|
52
|
+
*
|
|
53
|
+
* @param {Theme | undefined} theme
|
|
54
|
+
* @returns {number}
|
|
55
|
+
*/
|
|
56
|
+
static breakpointOf(theme) {
|
|
57
|
+
return breakpointOf(theme);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Re-resolve for the current theme and device. Cheap to call on every update:
|
|
62
|
+
* the stylesheet is only rewritten when the generated text actually changes.
|
|
63
|
+
*
|
|
64
|
+
* @param {Theme | undefined} theme
|
|
65
|
+
* @param {Device} device
|
|
66
|
+
*/
|
|
67
|
+
apply(theme, device) {
|
|
68
|
+
this.#resolved = resolveTheme(theme, device);
|
|
69
|
+
this.#write();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#write() {
|
|
73
|
+
this.#sheet.write(themeToCss(this.#resolved), this.#host.renderRoot);
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/element.js
ADDED
package/src/events.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/** Every event this library dispatches. Never write the string literals elsewhere. */
|
|
4
|
+
export const Events = Object.freeze({
|
|
5
|
+
SUBMIT: 'chat-submit',
|
|
6
|
+
BEFORE_OPEN: 'chat-before-open',
|
|
7
|
+
OPEN: 'chat-open',
|
|
8
|
+
BEFORE_CLOSE: 'chat-before-close',
|
|
9
|
+
CLOSE: 'chat-close',
|
|
10
|
+
HIDE: 'chat-hide',
|
|
11
|
+
SHOW: 'chat-show',
|
|
12
|
+
STATE_CHANGE: 'chat-state-change',
|
|
13
|
+
INPUT: 'chat-input',
|
|
14
|
+
MESSAGE_RENDER: 'chat-message-render',
|
|
15
|
+
MESSAGE_CLICK: 'chat-message-click',
|
|
16
|
+
SCROLL_TOP: 'chat-scroll-top',
|
|
17
|
+
BREAKPOINT_CHANGE: 'chat-breakpoint-change',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Dispatch a composed, bubbling CustomEvent from the host.
|
|
22
|
+
*
|
|
23
|
+
* @param {HTMLElement} host
|
|
24
|
+
* @param {string} name
|
|
25
|
+
* @param {unknown} detail
|
|
26
|
+
* @param {{ cancelable?: boolean }} [options]
|
|
27
|
+
* @returns {boolean} false when a listener called preventDefault().
|
|
28
|
+
*/
|
|
29
|
+
export function emit(host, name, detail, { cancelable = false } = {}) {
|
|
30
|
+
return host.dispatchEvent(
|
|
31
|
+
new CustomEvent(name, { detail, bubbles: true, composed: true, cancelable }),
|
|
32
|
+
);
|
|
33
|
+
}
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ChitUI } from './chit-ui.js';
|
|
2
|
+
import type { Message, ChatState, Trigger, Device } from './types.js';
|
|
3
|
+
|
|
4
|
+
export interface ChitUIEventMap {
|
|
5
|
+
'chat-submit': CustomEvent<{ text: string }>;
|
|
6
|
+
'chat-before-open': CustomEvent<{ from: ChatState; trigger: Trigger }>;
|
|
7
|
+
'chat-open': CustomEvent<{ from: ChatState; trigger: Trigger }>;
|
|
8
|
+
'chat-before-close': CustomEvent<{ from: ChatState; trigger: Trigger }>;
|
|
9
|
+
'chat-close': CustomEvent<{ from: ChatState; trigger: Trigger }>;
|
|
10
|
+
'chat-hide': CustomEvent<{ from: ChatState; to: 'hidden' }>;
|
|
11
|
+
'chat-show': CustomEvent<{ from: 'hidden'; to: ChatState }>;
|
|
12
|
+
'chat-state-change': CustomEvent<{ from: ChatState; to: ChatState; trigger: Trigger }>;
|
|
13
|
+
'chat-input': CustomEvent<{ value: string }>;
|
|
14
|
+
'chat-message-render': CustomEvent<{ message: Message; element: HTMLElement; instance?: HTMLElement }>;
|
|
15
|
+
'chat-message-click': CustomEvent<{ message: Message; target: Element; originalEvent: MouseEvent }>;
|
|
16
|
+
'chat-scroll-top': CustomEvent<Record<string, never>>;
|
|
17
|
+
'chat-breakpoint-change': CustomEvent<{ device: Device }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare global {
|
|
21
|
+
interface HTMLElementTagNameMap {
|
|
22
|
+
'chit-ui': ChitUI;
|
|
23
|
+
}
|
|
24
|
+
interface HTMLElementEventMap extends ChitUIEventMap {}
|
|
25
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} Labels
|
|
5
|
+
* @property {string} launcher Accessible name of the closed-state button.
|
|
6
|
+
* @property {string} panel Accessible name of the dialog.
|
|
7
|
+
* @property {string} conversation Accessible name of the message log.
|
|
8
|
+
* @property {string} close Close button.
|
|
9
|
+
* @property {string} send Send button.
|
|
10
|
+
* @property {string} input Composer textarea.
|
|
11
|
+
* @property {string} typing Announced while the other side is typing.
|
|
12
|
+
* @property {string} toLatest "Jump to newest" button.
|
|
13
|
+
* @property {{ sending: string, sent: string, error: string }} status Delivery state of one's own message.
|
|
14
|
+
* @property {string} charactersLeft Counter, with {n} for the number remaining.
|
|
15
|
+
* @property {string} overLimit Counter past the limit, with {n} for the excess.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** @type {Record<string, Labels>} */
|
|
19
|
+
const BUILT_IN = {
|
|
20
|
+
ja: {
|
|
21
|
+
launcher: 'チャットを開く',
|
|
22
|
+
panel: 'チャット',
|
|
23
|
+
conversation: '会話',
|
|
24
|
+
close: 'チャットを閉じる',
|
|
25
|
+
send: '送信',
|
|
26
|
+
input: 'メッセージを入力',
|
|
27
|
+
typing: '入力中',
|
|
28
|
+
toLatest: '最新へ',
|
|
29
|
+
status: { sending: '送信中', sent: '送信済み', error: '送信できませんでした' },
|
|
30
|
+
charactersLeft: '残り {n} 文字',
|
|
31
|
+
overLimit: '{n} 文字超過しています',
|
|
32
|
+
},
|
|
33
|
+
en: {
|
|
34
|
+
launcher: 'Open chat',
|
|
35
|
+
panel: 'Chat',
|
|
36
|
+
conversation: 'Conversation',
|
|
37
|
+
close: 'Close chat',
|
|
38
|
+
send: 'Send',
|
|
39
|
+
input: 'Type a message',
|
|
40
|
+
typing: 'Typing',
|
|
41
|
+
toLatest: 'Jump to latest',
|
|
42
|
+
status: { sending: 'Sending', sent: 'Sent', error: 'Not delivered' },
|
|
43
|
+
charactersLeft: '{n} characters left',
|
|
44
|
+
overLimit: '{n} characters over the limit',
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The language the widget speaks: the `locale` property, else the document's,
|
|
50
|
+
* else the browser's. Everything locale-dependent — labels and timestamps
|
|
51
|
+
* alike — goes through this, so they cannot disagree.
|
|
52
|
+
*
|
|
53
|
+
* @param {string | undefined} locale
|
|
54
|
+
* @returns {string | undefined} undefined means "whatever the browser uses".
|
|
55
|
+
*/
|
|
56
|
+
export function resolveLocale(locale) {
|
|
57
|
+
return locale || document.documentElement.lang || undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Pick the label set for a locale, falling back to English for anything we do
|
|
62
|
+
* not ship. `overrides` is the host's `labels` property.
|
|
63
|
+
*
|
|
64
|
+
* @param {string | undefined} locale
|
|
65
|
+
* @param {Partial<Labels> | undefined} [overrides]
|
|
66
|
+
* @returns {Labels}
|
|
67
|
+
*/
|
|
68
|
+
export function resolveLabels(locale, overrides) {
|
|
69
|
+
const lang = (resolveLocale(locale) || 'en').toLowerCase();
|
|
70
|
+
const base = BUILT_IN[lang] ?? BUILT_IN[lang.split('-')[0]] ?? BUILT_IN.en;
|
|
71
|
+
return overrides ? { ...base, ...overrides } : base;
|
|
72
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { html, nothing } from 'lit';
|
|
3
|
+
import { live } from 'lit/directives/live.js';
|
|
4
|
+
|
|
5
|
+
/** @import { ChitUI } from '../chit-ui.js' */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The composer: a textarea that grows with its content, and a send button.
|
|
9
|
+
*
|
|
10
|
+
* The whole thing can be replaced through the `composer` slot; a consumer who
|
|
11
|
+
* does that calls `host.submit(text)` themselves. `input-before` and
|
|
12
|
+
* `input-after` are the lighter option, for an attach or emoji button beside
|
|
13
|
+
* the box the library still owns.
|
|
14
|
+
*
|
|
15
|
+
* @param {ChitUI} host
|
|
16
|
+
* @returns {import('lit').TemplateResult | typeof nothing}
|
|
17
|
+
*/
|
|
18
|
+
export function renderComposer(host) {
|
|
19
|
+
if (host.inputHidden) return nothing;
|
|
20
|
+
|
|
21
|
+
const labels = host.currentLabels;
|
|
22
|
+
const theme = host.currentTheme.open;
|
|
23
|
+
const disabled = host.busy || host.inputDisabled;
|
|
24
|
+
const placeholder = host.placeholder ?? theme.input.placeholder ?? labels.input;
|
|
25
|
+
|
|
26
|
+
return html`
|
|
27
|
+
<form
|
|
28
|
+
part="composer"
|
|
29
|
+
@submit=${(/** @type {SubmitEvent} */ event) => {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
host.submit();
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
<slot name="composer">
|
|
35
|
+
<slot name="input-before"></slot>
|
|
36
|
+
|
|
37
|
+
<textarea
|
|
38
|
+
part="input"
|
|
39
|
+
rows="1"
|
|
40
|
+
.value=${live(host.value)}
|
|
41
|
+
placeholder=${placeholder}
|
|
42
|
+
aria-label=${labels.input}
|
|
43
|
+
?disabled=${disabled}
|
|
44
|
+
aria-describedby=${host.maxLength === undefined ? nothing : 'chit-counter'}
|
|
45
|
+
@input=${(/** @type {Event} */ event) => host.handleInput(event)}
|
|
46
|
+
@keydown=${(/** @type {KeyboardEvent} */ event) => host.handleKeydown(event)}
|
|
47
|
+
@compositionstart=${() => host.handleComposition(true)}
|
|
48
|
+
@compositionend=${() => host.handleComposition(false)}
|
|
49
|
+
></textarea>
|
|
50
|
+
|
|
51
|
+
<slot name="input-after"></slot>
|
|
52
|
+
|
|
53
|
+
${host.maxLength === undefined
|
|
54
|
+
? nothing
|
|
55
|
+
: html`<span part="counter" id="chit-counter" aria-live="polite" hidden></span>`}
|
|
56
|
+
|
|
57
|
+
<button
|
|
58
|
+
part="send-button"
|
|
59
|
+
type="submit"
|
|
60
|
+
aria-label=${labels.send}
|
|
61
|
+
?disabled=${!host.canSend}
|
|
62
|
+
>
|
|
63
|
+
${host.busy
|
|
64
|
+
? html`<span part="spinner" aria-hidden="true"></span>`
|
|
65
|
+
: html`<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
|
66
|
+
<path fill="currentColor" d="M3.4 20.4 21 12 3.4 3.6 3.39 10.2 15.6 12 3.39 13.8z" />
|
|
67
|
+
</svg>`}
|
|
68
|
+
</button>
|
|
69
|
+
</slot>
|
|
70
|
+
</form>
|
|
71
|
+
`;
|
|
72
|
+
}
|