@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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +440 -0
  3. package/dist/chit-ui.iife.min.js +964 -0
  4. package/dist/chit-ui.iife.min.js.map +1 -0
  5. package/dist/chit-ui.min.js +964 -0
  6. package/dist/chit-ui.min.js.map +1 -0
  7. package/dist/types/bundle.d.ts +7 -0
  8. package/dist/types/chit-ui.d.ts +251 -0
  9. package/dist/types/controllers/breakpoint-controller.d.ts +29 -0
  10. package/dist/types/controllers/composer-controller.d.ts +54 -0
  11. package/dist/types/controllers/scroll-controller.d.ts +44 -0
  12. package/dist/types/controllers/state-controller.d.ts +61 -0
  13. package/dist/types/controllers/theme-controller.d.ts +47 -0
  14. package/dist/types/element.d.ts +1 -0
  15. package/dist/types/events.d.ts +28 -0
  16. package/dist/types/global.d.ts +25 -0
  17. package/dist/types/i18n/labels.d.ts +68 -0
  18. package/dist/types/index.d.ts +4 -0
  19. package/dist/types/render/composer.d.ts +15 -0
  20. package/dist/types/render/content.d.ts +75 -0
  21. package/dist/types/render/launcher.d.ts +18 -0
  22. package/dist/types/render/message-list.d.ts +17 -0
  23. package/dist/types/render/message.d.ts +14 -0
  24. package/dist/types/render/panel.d.ts +10 -0
  25. package/dist/types/styles/adopted-sheet.d.ts +20 -0
  26. package/dist/types/styles/composer.css.d.ts +1 -0
  27. package/dist/types/styles/content.css.d.ts +9 -0
  28. package/dist/types/styles/host.css.d.ts +9 -0
  29. package/dist/types/styles/launcher.css.d.ts +1 -0
  30. package/dist/types/styles/message.css.d.ts +1 -0
  31. package/dist/types/styles/panel.css.d.ts +1 -0
  32. package/dist/types/theme/default-theme.d.ts +97 -0
  33. package/dist/types/theme/merge-theme.d.ts +33 -0
  34. package/dist/types/theme/theme-to-css.d.ts +24 -0
  35. package/dist/types/types.d.ts +268 -0
  36. package/package.json +71 -0
  37. package/src/bundle.js +11 -0
  38. package/src/chit-ui.js +548 -0
  39. package/src/controllers/breakpoint-controller.js +82 -0
  40. package/src/controllers/composer-controller.js +215 -0
  41. package/src/controllers/scroll-controller.js +252 -0
  42. package/src/controllers/state-controller.js +316 -0
  43. package/src/controllers/theme-controller.js +75 -0
  44. package/src/element.js +4 -0
  45. package/src/events.js +33 -0
  46. package/src/global.d.ts +25 -0
  47. package/src/i18n/labels.js +72 -0
  48. package/src/index.js +9 -0
  49. package/src/render/composer.js +72 -0
  50. package/src/render/content.js +211 -0
  51. package/src/render/launcher.js +64 -0
  52. package/src/render/message-list.js +67 -0
  53. package/src/render/message.js +82 -0
  54. package/src/render/panel.js +77 -0
  55. package/src/styles/adopted-sheet.js +72 -0
  56. package/src/styles/composer.css.js +108 -0
  57. package/src/styles/content.css.js +119 -0
  58. package/src/styles/host.css.js +160 -0
  59. package/src/styles/launcher.css.js +139 -0
  60. package/src/styles/message.css.js +192 -0
  61. package/src/styles/panel.css.js +126 -0
  62. package/src/theme/default-theme.js +111 -0
  63. package/src/theme/merge-theme.js +100 -0
  64. package/src/theme/theme-to-css.js +94 -0
  65. 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
@@ -0,0 +1,4 @@
1
+ // @ts-check
2
+ // Side-effect free entry: exports the class without registering a tag name,
3
+ // so the caller can pick their own.
4
+ export { ChitUI } from './chit-ui.js';
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
+ }
@@ -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,9 @@
1
+ // @ts-check
2
+ import { ChitUI } from './chit-ui.js';
3
+
4
+ if (!customElements.get('chit-ui')) {
5
+ customElements.define('chit-ui', ChitUI);
6
+ }
7
+
8
+ export { ChitUI };
9
+ export { Events } from './events.js';
@@ -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
+ }