@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,82 @@
1
+ // @ts-check
2
+ import { Events, emit } from '../events.js';
3
+
4
+ /** @import { ReactiveController, LitElement } from 'lit' */
5
+ /** @import { Device } from '../types.js' */
6
+
7
+ /**
8
+ * Tracks whether the viewport is narrow enough to count as a phone.
9
+ *
10
+ * Uses matchMedia rather than a resize listener: it only fires when the
11
+ * boundary is actually crossed, so nothing runs while the user drags a window
12
+ * around inside one bucket.
13
+ *
14
+ * @implements {ReactiveController}
15
+ */
16
+ export class BreakpointController {
17
+ /** @type {LitElement} */
18
+ #host;
19
+ /** @type {MediaQueryList | null} */
20
+ #query = null;
21
+ /** @type {number} */
22
+ #breakpoint = 0;
23
+ /** @type {Device} */
24
+ #device = 'pc';
25
+ /** Set once the first measurement is in, so start-up is not a "change". */
26
+ #measured = false;
27
+
28
+ /** @param {LitElement} host */
29
+ constructor(host) {
30
+ this.#host = host;
31
+ host.addController(this);
32
+ }
33
+
34
+ /** @returns {Device} */
35
+ get device() {
36
+ return this.#device;
37
+ }
38
+
39
+ hostDisconnected() {
40
+ this.#query?.removeEventListener('change', this.#onChange);
41
+ this.#query = null;
42
+ }
43
+
44
+ /**
45
+ * Point the controller at a breakpoint. Safe to call on every update; the
46
+ * media query is only rebuilt when the value actually changes.
47
+ *
48
+ * @param {number} breakpoint
49
+ */
50
+ observe(breakpoint) {
51
+ if (breakpoint === this.#breakpoint && this.#query) return;
52
+ this.#breakpoint = breakpoint;
53
+ this.#query?.removeEventListener('change', this.#onChange);
54
+ this.#query = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
55
+ this.#query.addEventListener('change', this.#onChange);
56
+ this.#apply(this.#query.matches ? 'mobile' : 'pc');
57
+ }
58
+
59
+ /** @param {MediaQueryListEvent} event */
60
+ #onChange = (event) => {
61
+ this.#apply(event.matches ? 'mobile' : 'pc');
62
+ };
63
+
64
+ /**
65
+ * Record the device and announce it, unless this is the first measurement —
66
+ * start-up is not a change. A later flip is announced whether it came from
67
+ * the viewport resizing or from the theme moving the breakpoint, because
68
+ * either way the answer a consumer asked for is now different.
69
+ *
70
+ * @param {Device} device
71
+ */
72
+ #apply(device) {
73
+ if (device === this.#device && this.#host.dataset.device) return;
74
+
75
+ this.#device = device;
76
+ this.#host.dataset.device = device;
77
+ this.#host.requestUpdate();
78
+
79
+ if (this.#measured) emit(this.#host, Events.BREAKPOINT_CHANGE, { device });
80
+ this.#measured = true;
81
+ }
82
+ }
@@ -0,0 +1,215 @@
1
+ // @ts-check
2
+ import { Events, emit } from '../events.js';
3
+
4
+ /** @import { ReactiveController, LitElement } from 'lit' */
5
+
6
+ /** Browsers that size a textarea to its content need no JS help. */
7
+ const SUPPORTS_FIELD_SIZING =
8
+ typeof CSS !== 'undefined' && CSS.supports?.('field-sizing', 'content');
9
+
10
+ /**
11
+ * Owns the composer: what has been typed, whether an IME is mid-composition,
12
+ * and when a keystroke counts as "send".
13
+ *
14
+ * The typed text is deliberately not a reactive property. A keystroke would
15
+ * otherwise re-render the whole shadow tree, `repeat` over every message
16
+ * included; instead the two things that depend on it — the send button's
17
+ * enabled state and the character counter — are updated in place.
18
+ *
19
+ * @implements {ReactiveController}
20
+ */
21
+ export class ComposerController {
22
+ /** @type {LitElement & ComposerHost} */
23
+ #host;
24
+
25
+ #value = '';
26
+
27
+ /** True between compositionstart and compositionend. */
28
+ #composing = false;
29
+
30
+ /**
31
+ * True for one turn of the event loop after a composition ends.
32
+ *
33
+ * WebKit dispatches the Enter that confirmed an IME candidate as a plain
34
+ * keydown *after* compositionend, with `isComposing` already false — so
35
+ * without this flag, confirming a Japanese candidate would send the message.
36
+ */
37
+ #justComposed = false;
38
+
39
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
40
+ #composedTimer;
41
+
42
+ /** @param {LitElement & ComposerHost} host */
43
+ constructor(host) {
44
+ this.#host = host;
45
+ host.addController(this);
46
+ }
47
+
48
+ /** @returns {string} */
49
+ get value() {
50
+ return this.#value;
51
+ }
52
+
53
+ set value(next) {
54
+ this.#value = next ?? '';
55
+ const input = this.#input;
56
+ if (input && input.value !== this.#value) input.value = this.#value;
57
+ this.#grow();
58
+ this.#syncDerived();
59
+ }
60
+
61
+ /** @returns {boolean} True while an IME candidate window is open. */
62
+ get composing() {
63
+ return this.#composing;
64
+ }
65
+
66
+ /** @returns {number} Characters typed, counted by code point. */
67
+ get length() {
68
+ return Array.from(this.#value).length;
69
+ }
70
+
71
+ /** @returns {boolean} Whether the current text could be sent right now. */
72
+ get canSend() {
73
+ if (this.#host.busy || this.#host.inputDisabled) return false;
74
+ if (this.#value.trim() === '') return false;
75
+ const limit = this.#host.maxLength;
76
+ return limit === undefined || this.length <= limit;
77
+ }
78
+
79
+ hostDisconnected() {
80
+ clearTimeout(this.#composedTimer);
81
+ }
82
+
83
+ hostUpdated() {
84
+ const input = this.#input;
85
+ if (input && input.value !== this.#value) input.value = this.#value;
86
+ this.#grow();
87
+ this.#syncDerived();
88
+ }
89
+
90
+ /** @returns {HTMLTextAreaElement | null} */
91
+ get #input() {
92
+ return /** @type {HTMLTextAreaElement | null} */ (
93
+ this.#host.renderRoot.querySelector('[part~="input"]')
94
+ );
95
+ }
96
+
97
+ /** @param {Event} event */
98
+ onInput(event) {
99
+ this.#value = /** @type {HTMLTextAreaElement} */ (event.target).value;
100
+ this.#grow();
101
+ this.#syncDerived();
102
+ emit(this.#host, Events.INPUT, { value: this.#value });
103
+ }
104
+
105
+ onCompositionStart() {
106
+ this.#composing = true;
107
+ }
108
+
109
+ onCompositionEnd() {
110
+ this.#composing = false;
111
+ this.#justComposed = true;
112
+ clearTimeout(this.#composedTimer);
113
+ this.#composedTimer = setTimeout(() => {
114
+ this.#justComposed = false;
115
+ }, 0);
116
+ }
117
+
118
+ /** @param {KeyboardEvent} event */
119
+ onKeydown(event) {
120
+ if (event.key !== 'Enter') return;
121
+ if (!this.#host.sendOnEnter) return;
122
+ // Shift+Enter is a newline; the others are the browser's or the page's.
123
+ if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return;
124
+
125
+ // Chrome, Edge and Firefox mark the confirming Enter on the event itself.
126
+ // Older engines only set keyCode 229.
127
+ if (event.isComposing || event.keyCode === 229) return;
128
+ // Safari's post-compositionend Enter, and Android soft keyboards that send
129
+ // Enter while a composition is still open.
130
+ if (this.#composing || this.#justComposed) return;
131
+
132
+ event.preventDefault();
133
+ this.submit();
134
+ }
135
+
136
+ /**
137
+ * Hand the text to the consumer and clear the box.
138
+ *
139
+ * The text is passed as typed: leading and trailing newlines are theirs to
140
+ * keep or strip, since only they know whether the message is prose or code.
141
+ *
142
+ * @param {string} [text] Defaults to what is in the box.
143
+ * @returns {boolean} False when there was nothing to send.
144
+ */
145
+ submit(text) {
146
+ const value = text ?? this.#value;
147
+ if (text === undefined && !this.canSend) return false;
148
+ if (text !== undefined && (this.#host.busy || this.#host.inputDisabled)) return false;
149
+ if (value.trim() === '') return false;
150
+
151
+ const allowed = emit(this.#host, Events.SUBMIT, { text: value }, { cancelable: true });
152
+ if (!allowed) return false;
153
+
154
+ this.clear();
155
+ return true;
156
+ }
157
+
158
+ clear() {
159
+ this.value = '';
160
+ this.focus();
161
+ }
162
+
163
+ focus() {
164
+ this.#input?.focus();
165
+ }
166
+
167
+ /** Grow the box with its content, up to the theme's row limit. */
168
+ #grow() {
169
+ if (SUPPORTS_FIELD_SIZING) return;
170
+ const input = this.#input;
171
+ if (!input) return;
172
+ input.style.height = 'auto';
173
+ input.style.height = `${input.scrollHeight}px`;
174
+ }
175
+
176
+ /**
177
+ * Update the parts that depend on the typed text without a re-render.
178
+ */
179
+ #syncDerived() {
180
+ const root = this.#host.renderRoot;
181
+
182
+ const send = /** @type {HTMLButtonElement | null} */ (
183
+ root.querySelector('[part~="send-button"]')
184
+ );
185
+ if (send) send.disabled = !this.canSend;
186
+
187
+ const input = this.#input;
188
+ const counter = /** @type {HTMLElement | null} */ (root.querySelector('[part~="counter"]'));
189
+ const limit = this.#host.maxLength;
190
+ if (limit === undefined) return;
191
+
192
+ const remaining = limit - this.length;
193
+ const over = remaining < 0;
194
+ input?.setAttribute('aria-invalid', over ? 'true' : 'false');
195
+
196
+ if (!counter) return;
197
+ counter.textContent = String(remaining);
198
+ counter.toggleAttribute('data-over', over);
199
+ // The digits alone ("-14") say nothing out loud, so the counter carries a
200
+ // sentence for assistive tech while showing just the number.
201
+ const labels = this.#host.currentLabels;
202
+ const template = over ? labels.overLimit : labels.charactersLeft;
203
+ counter.setAttribute('aria-label', template.replace('{n}', String(Math.abs(remaining))));
204
+ counter.hidden = this.length <= limit * 0.8;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * @typedef {Object} ComposerHost
210
+ * @property {boolean} busy
211
+ * @property {boolean} inputDisabled
212
+ * @property {boolean} sendOnEnter
213
+ * @property {number | undefined} maxLength
214
+ * @property {import('../i18n/labels.js').Labels} currentLabels
215
+ */
@@ -0,0 +1,252 @@
1
+ // @ts-check
2
+ import { Events, emit } from '../events.js';
3
+
4
+ /** @import { ReactiveController, LitElement } from 'lit' */
5
+ /** @import { Message } from '../types.js' */
6
+
7
+ /** How close to the end still counts as "at the bottom", in pixels. */
8
+ const BOTTOM_SLACK = 24;
9
+
10
+ /**
11
+ * How long to keep treating the viewport as "ours" after starting a smooth
12
+ * scroll, when the browser has no scrollend event to tell us it finished.
13
+ */
14
+ const SMOOTH_GRACE_MS = 700;
15
+
16
+ /**
17
+ * Keeps the conversation pinned to the newest message while the reader is at
18
+ * the bottom, and gets out of the way the moment they scroll up.
19
+ *
20
+ * Two things follow from that rule. A reply arriving while the reader is up in
21
+ * the history must not yank them down — it raises the "jump to latest" button
22
+ * instead. And their own message always scrolls into view, because they just
23
+ * pressed send and expect to see it.
24
+ *
25
+ * @implements {ReactiveController}
26
+ */
27
+ export class ScrollController {
28
+ /** @type {LitElement & { messages: Message[] }} */
29
+ #host;
30
+ /** @type {() => 'smooth' | 'instant'} */
31
+ #preferredBehavior;
32
+ /** @type {HTMLElement | null} */
33
+ #viewport = null;
34
+ /** @type {ResizeObserver | null} */
35
+ #resize = null;
36
+
37
+ #atBottom = true;
38
+ #hasUnseen = false;
39
+ #atTop = false;
40
+ #followAfterRender = true;
41
+
42
+ /**
43
+ * True while a scroll we started is still animating. Scroll events during
44
+ * that window report positions we caused, not where the reader went.
45
+ */
46
+ #selfScrolling = false;
47
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
48
+ #selfScrollTimer;
49
+ /** The first jump after the panel opens has nothing to animate from. */
50
+ #openingJump = true;
51
+
52
+ /** @type {string | undefined} */
53
+ #lastId;
54
+ #lastCount = 0;
55
+
56
+ /**
57
+ * @param {LitElement & { messages: Message[] }} host
58
+ * @param {{ behavior: () => 'smooth' | 'instant' }} options
59
+ */
60
+ constructor(host, { behavior }) {
61
+ this.#host = host;
62
+ this.#preferredBehavior = behavior;
63
+ host.addController(this);
64
+ }
65
+
66
+ /** @returns {boolean} True when newer content arrived while scrolled up. */
67
+ get hasUnseen() {
68
+ return this.#hasUnseen;
69
+ }
70
+
71
+ /** @returns {boolean} */
72
+ get atBottom() {
73
+ return this.#atBottom;
74
+ }
75
+
76
+ hostDisconnected() {
77
+ this.#unbind();
78
+ clearTimeout(this.#selfScrollTimer);
79
+ }
80
+
81
+ /**
82
+ * Runs before the render, so a decision made here lands in the same frame.
83
+ * Whether the conversation grew is knowable from the array alone; only the
84
+ * scrolling itself has to wait for the DOM.
85
+ */
86
+ hostUpdate() {
87
+ const messages = this.#host.messages;
88
+ const last = messages[messages.length - 1];
89
+ const grew = messages.length > this.#lastCount;
90
+ const newTail = last?.id !== this.#lastId;
91
+
92
+ this.#lastId = last?.id;
93
+ this.#lastCount = messages.length;
94
+
95
+ if (!grew && !newTail) return;
96
+
97
+ // Their own message always scrolls into view: they just pressed send.
98
+ if (this.#atBottom || last?.role === 'user') this.#followAfterRender = true;
99
+ else this.#hasUnseen = true;
100
+ }
101
+
102
+ hostUpdated() {
103
+ const viewport = /** @type {HTMLElement | null} */ (
104
+ this.#host.renderRoot.querySelector('[part~="messages"]')
105
+ );
106
+
107
+ if (viewport !== this.#viewport) {
108
+ this.#unbind();
109
+ this.#viewport = viewport;
110
+ this.#bind();
111
+ // A freshly opened panel starts at the newest message, with no animation
112
+ // to watch: there was nothing on screen to move away from.
113
+ if (viewport) {
114
+ this.#atBottom = true;
115
+ this.#hasUnseen = false;
116
+ this.#followAfterRender = true;
117
+ this.#openingJump = true;
118
+ }
119
+ }
120
+
121
+ if (this.#followAfterRender) {
122
+ const behavior = this.#followBehavior;
123
+ this.#followAfterRender = false;
124
+ this.#scrollNow({ behavior });
125
+ }
126
+ }
127
+
128
+ #bind() {
129
+ if (!this.#viewport) return;
130
+ this.#viewport.addEventListener('scroll', this.#onScroll, { passive: true });
131
+ // Precise end-of-scroll where it exists (Chrome 114, Firefox 109,
132
+ // Safari 17.4); the timer below covers the rest.
133
+ this.#viewport.addEventListener('scrollend', this.#onScrollEnd);
134
+
135
+ // Images finishing and streamed text growing both change the height
136
+ // without a scroll event, so the follow has to react to size too.
137
+ const inner = this.#viewport.firstElementChild;
138
+ if (inner && typeof ResizeObserver !== 'undefined') {
139
+ this.#resize = new ResizeObserver(() => {
140
+ if (!this.#atBottom) return;
141
+ // Content settling (an image loading, a tall block laying out) right
142
+ // after a message arrived must not cut the follow short, so while our
143
+ // own animation is running it is re-aimed rather than replaced. With
144
+ // nothing in flight this is a streamed reply growing, and that is
145
+ // followed instantly: an animation restarted every few tens of
146
+ // milliseconds never lands.
147
+ this.#scrollNow({ behavior: this.#selfScrolling ? 'smooth' : 'instant' });
148
+ });
149
+ this.#resize.observe(inner);
150
+ }
151
+ }
152
+
153
+ #unbind() {
154
+ this.#viewport?.removeEventListener('scroll', this.#onScroll);
155
+ this.#viewport?.removeEventListener('scrollend', this.#onScrollEnd);
156
+ this.#resize?.disconnect();
157
+ this.#resize = null;
158
+ this.#viewport = null;
159
+ }
160
+
161
+ #onScrollEnd = () => {
162
+ this.#endSelfScroll();
163
+ };
164
+
165
+ #onScroll = () => {
166
+ const viewport = this.#viewport;
167
+ if (!viewport) return;
168
+
169
+ const distance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
170
+ const atBottom = distance <= BOTTOM_SLACK;
171
+
172
+ // Our own animation passing through the middle of the list is not the
173
+ // reader scrolling up.
174
+ if (this.#selfScrolling) {
175
+ if (atBottom) this.#endSelfScroll();
176
+ return;
177
+ }
178
+
179
+ if (atBottom !== this.#atBottom) {
180
+ this.#atBottom = atBottom;
181
+ if (atBottom && this.#hasUnseen) {
182
+ this.#hasUnseen = false;
183
+ this.#host.requestUpdate();
184
+ }
185
+ }
186
+
187
+ // One event per visit to the top, so a consumer loading older messages is
188
+ // not asked again while the reader sits there.
189
+ const atTop = viewport.scrollTop <= 0;
190
+ if (atTop && !this.#atTop) emit(this.#host, Events.SCROLL_TOP, {});
191
+ this.#atTop = atTop;
192
+ };
193
+
194
+ /**
195
+ * @param {{ smooth?: boolean }} [options] Defaults to the theme's setting.
196
+ */
197
+ scrollToBottom({ smooth } = {}) {
198
+ const behavior =
199
+ smooth === undefined ? this.#followBehavior : smooth ? 'smooth' : 'instant';
200
+ this.#scrollNow({ behavior });
201
+
202
+ if (this.#hasUnseen) {
203
+ this.#hasUnseen = false;
204
+ this.#host.requestUpdate();
205
+ }
206
+ }
207
+
208
+ /**
209
+ * What the next follow should look like: the theme's choice, unless the
210
+ * reader asked for less motion, or this is the jump that happens as the
211
+ * panel opens.
212
+ *
213
+ * @returns {'smooth' | 'instant'}
214
+ */
215
+ get #followBehavior() {
216
+ if (this.#openingJump) return 'instant';
217
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return 'instant';
218
+ return this.#preferredBehavior();
219
+ }
220
+
221
+ /** @param {{ behavior?: 'smooth' | 'instant' }} [options] */
222
+ #scrollNow({ behavior = 'instant' } = {}) {
223
+ const viewport = this.#viewport;
224
+ if (!viewport) return;
225
+
226
+ this.#openingJump = false;
227
+ if (behavior === 'smooth') this.#beginSelfScroll();
228
+
229
+ viewport.scrollTo({ top: viewport.scrollHeight, behavior });
230
+ this.#atBottom = true;
231
+ this.#atTop = false;
232
+ this.#hasUnseen = false;
233
+ }
234
+
235
+ #beginSelfScroll() {
236
+ this.#selfScrolling = true;
237
+ clearTimeout(this.#selfScrollTimer);
238
+ this.#selfScrollTimer = setTimeout(() => this.#endSelfScroll(), SMOOTH_GRACE_MS);
239
+ }
240
+
241
+ #endSelfScroll() {
242
+ if (!this.#selfScrolling) return;
243
+ this.#selfScrolling = false;
244
+ clearTimeout(this.#selfScrollTimer);
245
+
246
+ // Take a reading now that the viewport has settled.
247
+ const viewport = this.#viewport;
248
+ if (!viewport) return;
249
+ const distance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
250
+ this.#atBottom = distance <= BOTTOM_SLACK;
251
+ }
252
+ }