@hidemikimura/chit-ui 0.3.0 → 0.3.2
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 +39 -0
- package/README.md +53 -3
- package/dist/chit-ui.iife.min.js +44 -20
- package/dist/chit-ui.iife.min.js.map +1 -1
- package/dist/chit-ui.min.js +44 -20
- package/dist/chit-ui.min.js.map +1 -1
- package/dist/types/controllers/scroll-lock-controller.d.ts +37 -0
- package/dist/types/controllers/viewport-controller.d.ts +38 -0
- package/dist/types/index.d.ts +162 -0
- package/package.json +2 -1
- package/skills/README.md +34 -0
- package/skills/chit-ui/SKILL.md +148 -0
- package/skills/chit-ui/references/api.md +189 -0
- package/skills/chit-ui/references/recipes.md +204 -0
- package/src/chit-ui.js +6 -0
- package/src/controllers/scroll-controller.js +21 -1
- package/src/controllers/scroll-lock-controller.js +185 -0
- package/src/controllers/viewport-controller.js +102 -0
- package/src/index.js +34 -0
- package/src/styles/composer.css.js +18 -0
- package/src/styles/panel.css.js +7 -1
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Chit UI レシピ
|
|
2
|
+
|
|
3
|
+
よくある組み方。どれも「発言を持っているのは利用者側」という前提の上に乗っている。
|
|
4
|
+
|
|
5
|
+
## 送信から返事まで(基本形)
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
chat.addEventListener('chat-submit', async (event) => {
|
|
9
|
+
const text = event.detail.text.trim();
|
|
10
|
+
if (!text) { event.preventDefault(); return; }
|
|
11
|
+
|
|
12
|
+
const mine = { id: crypto.randomUUID(), role: 'user', text, status: 'sending' };
|
|
13
|
+
chat.messages = [...chat.messages, mine];
|
|
14
|
+
chat.busy = true;
|
|
15
|
+
chat.loading = true;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const reply = await api.send(text);
|
|
19
|
+
chat.messages = chat.messages
|
|
20
|
+
.map((m) => (m.id === mine.id ? { ...m, status: 'sent' } : m))
|
|
21
|
+
.concat({ id: crypto.randomUUID(), role: 'assistant', text: reply });
|
|
22
|
+
} catch {
|
|
23
|
+
chat.messages = chat.messages.map((m) =>
|
|
24
|
+
m.id === mine.id ? { ...m, status: 'error' } : m,
|
|
25
|
+
);
|
|
26
|
+
chat.messages = [...chat.messages,
|
|
27
|
+
{ id: crypto.randomUUID(), role: 'system', text: '送信できませんでした' }];
|
|
28
|
+
} finally {
|
|
29
|
+
chat.loading = false;
|
|
30
|
+
chat.busy = false;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`open.loading.auto: true` にしておけば `chat.loading` の上げ下げは要らない。送信から
|
|
36
|
+
相手側の発言が増えるまで自動で出て、`timeout`(ms)を過ぎれば自分で引っ込む。
|
|
37
|
+
|
|
38
|
+
## ストリーミング
|
|
39
|
+
|
|
40
|
+
同じ `id` の発言を書き換えていく。`streaming: true` の間は末尾にカーソルが出て、
|
|
41
|
+
`aria-busy` が立つので 1 文字ずつ読み上げられない。
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
const id = crypto.randomUUID();
|
|
45
|
+
chat.messages = [...chat.messages, { id, role: 'assistant', text: '', streaming: true }];
|
|
46
|
+
|
|
47
|
+
for await (const chunk of stream) {
|
|
48
|
+
chat.messages = chat.messages.map((m) =>
|
|
49
|
+
m.id === id ? { ...m, text: m.text + chunk } : m,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
chat.messages = chat.messages.map((m) =>
|
|
54
|
+
m.id === id ? { ...m, streaming: false } : m,
|
|
55
|
+
);
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
最下部にいる読み手は自動で追従する。上にさかのぼっている読み手は動かされず、
|
|
59
|
+
「最新へ」のボタンが出る。
|
|
60
|
+
|
|
61
|
+
## 履歴の追い読み
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
chat.addEventListener('chat-scroll-top', async () => {
|
|
65
|
+
if (loadingOlder || !cursor) return;
|
|
66
|
+
loadingOlder = true;
|
|
67
|
+
const older = await api.history(cursor);
|
|
68
|
+
chat.messages = [...older, ...chat.messages]; // 前に足す
|
|
69
|
+
cursor = older.at(0)?.cursor ?? null;
|
|
70
|
+
loadingOlder = false;
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
このイベントは最上部に着くたびに 1 回だけ出る(居座っても繰り返さない)。
|
|
75
|
+
|
|
76
|
+
## シナリオ型(ホームボタンで最初に戻す)
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
chat.theme = { open: { header: { home: true } } };
|
|
80
|
+
|
|
81
|
+
chat.addEventListener('chat-home', async (event) => {
|
|
82
|
+
if (event.detail.trigger === 'user' && !confirm('最初からやり直しますか?')) return;
|
|
83
|
+
chat.messages = [firstMessage()];
|
|
84
|
+
scenario.reset();
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
ボタンを押してもウィジェットは何も消さない。どこまで戻すかは利用者が決める。
|
|
89
|
+
|
|
90
|
+
## 発言の中にコンポーネントを置く
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
class OrderCard extends HTMLElement { /* props を受け取って描く */ }
|
|
94
|
+
customElements.define('order-card', OrderCard);
|
|
95
|
+
|
|
96
|
+
chat.messages = [...chat.messages, {
|
|
97
|
+
id: 'order-1001',
|
|
98
|
+
role: 'assistant',
|
|
99
|
+
component: 'order-card',
|
|
100
|
+
props: { orderId: '1001', total: 4800 },
|
|
101
|
+
meta: { orderId: '1001' },
|
|
102
|
+
}];
|
|
103
|
+
|
|
104
|
+
chat.addEventListener('chat-message-click', (event) => {
|
|
105
|
+
const action = event.detail.target.closest('[data-action]')?.dataset.action;
|
|
106
|
+
if (action === 'detail') showOrder(event.detail.message.meta.orderId);
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
同じ `id` の間はインスタンスが再利用され、変わった `props` だけ再代入される。
|
|
111
|
+
`chat-message-render` の `detail.instance` でインスタンスを直接触ることもできる。
|
|
112
|
+
|
|
113
|
+
## 未読バッジ
|
|
114
|
+
|
|
115
|
+
```html
|
|
116
|
+
<chit-ui id="chat">
|
|
117
|
+
<span slot="launcher" class="badge" hidden></span>
|
|
118
|
+
</chit-ui>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
function pushFromServer(message) {
|
|
123
|
+
chat.messages = [...chat.messages, message];
|
|
124
|
+
if (chat.state !== 'open') {
|
|
125
|
+
unread += 1;
|
|
126
|
+
badge.textContent = String(unread);
|
|
127
|
+
badge.hidden = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
chat.addEventListener('chat-open', () => { unread = 0; badge.hidden = true; });
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
パネルの中で上にさかのぼっている読み手に新着が届いたかどうかは `chat.hasUnseen` でわかる。
|
|
135
|
+
|
|
136
|
+
## 添付ファイル
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
chat.theme = { open: { input: { attach: true, accept: 'image/*', multiple: false } } };
|
|
140
|
+
|
|
141
|
+
chat.addEventListener('chat-attach', async (event) => {
|
|
142
|
+
const [file] = event.detail.files;
|
|
143
|
+
const url = await api.upload(file);
|
|
144
|
+
chat.messages = [...chat.messages, {
|
|
145
|
+
id: crypto.randomUUID(), role: 'user',
|
|
146
|
+
html: `<img src="${url}" alt="添付した画像">`,
|
|
147
|
+
}];
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
ライブラリはアップロードもプレビューもしない。同じファイルを続けて選んでも毎回発火する。
|
|
152
|
+
|
|
153
|
+
## LINE LIFF など、外側がタイトルを持つ画面
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
chat.theme = { open: { header: { visible: false } } };
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
タイトルバーごと消えるので閉じるボタンも消える。Esc とランチャーでは閉じられる。
|
|
160
|
+
`title` は描かれなくても `role="dialog"` の読み上げ名には使われる。
|
|
161
|
+
|
|
162
|
+
## ドラッグで動かす
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
chat.theme = { closed: { draggable: true }, open: { draggable: true } };
|
|
166
|
+
|
|
167
|
+
chat.addEventListener('chat-move', (event) => {
|
|
168
|
+
localStorage.setItem('chit-offset', JSON.stringify(event.detail.displacement));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const saved = localStorage.getItem('chit-offset');
|
|
172
|
+
if (saved) chat.dragOffset = JSON.parse(saved);
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
閉じた状態と開いた状態は同じずれを共有するので、片方を動かせばもう片方も動く。
|
|
176
|
+
`resetPosition()` でテーマの位置に戻る。保存するかどうかは利用者の判断。
|
|
177
|
+
|
|
178
|
+
## ダークモード
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
const dark = matchMedia('(prefers-color-scheme: dark)');
|
|
182
|
+
const apply = () => { chat.theme = dark.matches ? darkTheme : lightTheme; };
|
|
183
|
+
dark.addEventListener('change', apply);
|
|
184
|
+
apply();
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
テーマは実行中に差し替えても即座に反映される。
|
|
188
|
+
|
|
189
|
+
## 入力欄を消して選択肢だけで進める
|
|
190
|
+
|
|
191
|
+
```js
|
|
192
|
+
chat.inputHidden = true;
|
|
193
|
+
chat.messages = [...chat.messages, {
|
|
194
|
+
id: 'q1', role: 'assistant',
|
|
195
|
+
html: '<p>どちらにしますか?</p><button data-choice="a">A</button><button data-choice="b">B</button>',
|
|
196
|
+
}];
|
|
197
|
+
|
|
198
|
+
chat.addEventListener('chat-message-click', (event) => {
|
|
199
|
+
const choice = event.detail.target.closest('[data-choice]')?.dataset.choice;
|
|
200
|
+
if (choice) chat.submit(choice === 'a' ? 'A' : 'B');
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
入力欄を消しても `submit(text)` は使えるので、選択肢からの送信で会話を進められる。
|
package/src/chit-ui.js
CHANGED
|
@@ -9,6 +9,8 @@ import { ThemeController } from './controllers/theme-controller.js';
|
|
|
9
9
|
import { ScrollController } from './controllers/scroll-controller.js';
|
|
10
10
|
import { ComposerController } from './controllers/composer-controller.js';
|
|
11
11
|
import { DragController } from './controllers/drag-controller.js';
|
|
12
|
+
import { ScrollLockController } from './controllers/scroll-lock-controller.js';
|
|
13
|
+
import { ViewportController } from './controllers/viewport-controller.js';
|
|
12
14
|
import { renderLauncher } from './render/launcher.js';
|
|
13
15
|
import { renderPanel } from './render/panel.js';
|
|
14
16
|
import { resolveLabels, resolveLocale } from './i18n/labels.js';
|
|
@@ -180,6 +182,10 @@ export class ChitUI extends LitElement {
|
|
|
180
182
|
behavior: () => this.currentTheme.open.animation.scroll,
|
|
181
183
|
});
|
|
182
184
|
this.#composer = new ComposerController(this);
|
|
185
|
+
// Both register themselves with the host and need nothing from us
|
|
186
|
+
// afterwards.
|
|
187
|
+
new ScrollLockController(this);
|
|
188
|
+
new ViewportController(this, { enabled: () => this.device === 'mobile' });
|
|
183
189
|
|
|
184
190
|
this.#launcherDrag = new DragController(this, {
|
|
185
191
|
name: 'launcher',
|
|
@@ -49,6 +49,13 @@ export class ScrollController {
|
|
|
49
49
|
/** The first jump after the panel opens has nothing to animate from. */
|
|
50
50
|
#openingJump = true;
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The viewport's height as of the last reading. A scroll event that arrives
|
|
54
|
+
* with a different height is the box having been resized under the reader —
|
|
55
|
+
* a keyboard opening, say — and says nothing about where they chose to be.
|
|
56
|
+
*/
|
|
57
|
+
#viewportHeight = 0;
|
|
58
|
+
|
|
52
59
|
/** @type {string | undefined} */
|
|
53
60
|
#lastId;
|
|
54
61
|
#lastCount = 0;
|
|
@@ -127,16 +134,21 @@ export class ScrollController {
|
|
|
127
134
|
|
|
128
135
|
#bind() {
|
|
129
136
|
if (!this.#viewport) return;
|
|
137
|
+
this.#viewportHeight = this.#viewport.clientHeight;
|
|
130
138
|
this.#viewport.addEventListener('scroll', this.#onScroll, { passive: true });
|
|
131
139
|
// Precise end-of-scroll where it exists (Chrome 114, Firefox 109,
|
|
132
140
|
// Safari 17.4); the timer below covers the rest.
|
|
133
141
|
this.#viewport.addEventListener('scrollend', this.#onScrollEnd);
|
|
134
142
|
|
|
135
143
|
// Images finishing and streamed text growing both change the height
|
|
136
|
-
// without a scroll event, so the follow has to react to size too.
|
|
144
|
+
// without a scroll event, so the follow has to react to size too. The
|
|
145
|
+
// viewport is watched alongside the content because a keyboard opening
|
|
146
|
+
// shortens the list without touching what is in it, and the newest
|
|
147
|
+
// message would otherwise slide out of sight.
|
|
137
148
|
const inner = this.#viewport.firstElementChild;
|
|
138
149
|
if (inner && typeof ResizeObserver !== 'undefined') {
|
|
139
150
|
this.#resize = new ResizeObserver(() => {
|
|
151
|
+
this.#viewportHeight = this.#viewport?.clientHeight ?? 0;
|
|
140
152
|
if (!this.#atBottom) return;
|
|
141
153
|
// Content settling (an image loading, a tall block laying out) right
|
|
142
154
|
// after a message arrived must not cut the follow short, so while our
|
|
@@ -147,6 +159,7 @@ export class ScrollController {
|
|
|
147
159
|
this.#scrollNow({ behavior: this.#selfScrolling ? 'smooth' : 'instant' });
|
|
148
160
|
});
|
|
149
161
|
this.#resize.observe(inner);
|
|
162
|
+
this.#resize.observe(this.#viewport);
|
|
150
163
|
}
|
|
151
164
|
}
|
|
152
165
|
|
|
@@ -166,6 +179,13 @@ export class ScrollController {
|
|
|
166
179
|
const viewport = this.#viewport;
|
|
167
180
|
if (!viewport) return;
|
|
168
181
|
|
|
182
|
+
// The box changed size under this scroll, so the reader did not move:
|
|
183
|
+
// the resize handler below puts them back where they were.
|
|
184
|
+
if (viewport.clientHeight !== this.#viewportHeight) {
|
|
185
|
+
this.#viewportHeight = viewport.clientHeight;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
169
189
|
const distance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
|
170
190
|
const atBottom = distance <= BOTTOM_SLACK;
|
|
171
191
|
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/** @import { ReactiveController, LitElement } from 'lit' */
|
|
4
|
+
|
|
5
|
+
/** How far a finger must travel before the gesture's direction is trusted. */
|
|
6
|
+
const THRESHOLD = 3;
|
|
7
|
+
|
|
8
|
+
/** Slack on the "is there anywhere left to scroll" reading, in pixels. */
|
|
9
|
+
const EDGE_SLACK = 1;
|
|
10
|
+
|
|
11
|
+
/** Overflow values that make an element a scroll container. */
|
|
12
|
+
const SCROLLABLE = /^(auto|scroll|overlay)$/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Keeps a touch that lands on the open panel from scrolling the page behind
|
|
16
|
+
* it.
|
|
17
|
+
*
|
|
18
|
+
* `overscroll-behavior: contain` is on the message list already, and it does
|
|
19
|
+
* the job for the case it covers: a list that can scroll, dragged past its
|
|
20
|
+
* own end. It has nothing to say about the cases that actually leak. A short
|
|
21
|
+
* conversation does not overflow, so the list is not a scroll container at
|
|
22
|
+
* all and the touch goes straight through to the document — which is what a
|
|
23
|
+
* reader on an iPhone sees as the page sliding around underneath a chat that
|
|
24
|
+
* looks like it should be holding still. The same happens for a touch that
|
|
25
|
+
* starts on the title bar or beside the input.
|
|
26
|
+
*
|
|
27
|
+
* So the rule is decided here instead: a touch on the panel may scroll
|
|
28
|
+
* something inside the panel, and otherwise it does nothing. On the first
|
|
29
|
+
* move of each gesture the controller looks along the touch's own path —
|
|
30
|
+
* `composedPath`, so a scroller inside a consumer's component counts too —
|
|
31
|
+
* for something that can still travel in the direction the finger is going.
|
|
32
|
+
* Finding one, it stands aside for the rest of the gesture; finding none, it
|
|
33
|
+
* cancels the move, and iOS then takes the whole gesture as "not a scroll".
|
|
34
|
+
*
|
|
35
|
+
* Two touches are left alone: that is a pinch, and zooming is the reader's.
|
|
36
|
+
* A move that is no longer cancelable is left alone too — the browser has
|
|
37
|
+
* already committed to scrolling, and `overscroll-behavior` is what keeps
|
|
38
|
+
* that inside the list.
|
|
39
|
+
*
|
|
40
|
+
* @implements {ReactiveController}
|
|
41
|
+
*/
|
|
42
|
+
export class ScrollLockController {
|
|
43
|
+
/** @type {LitElement} */
|
|
44
|
+
#host;
|
|
45
|
+
|
|
46
|
+
/** @type {HTMLElement | null} */
|
|
47
|
+
#panel = null;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The gesture in flight: where it started, and what was decided about it.
|
|
51
|
+
*
|
|
52
|
+
* @type {{ x: number, y: number, verdict: 'undecided' | 'allow' | 'block' } | undefined}
|
|
53
|
+
*/
|
|
54
|
+
#touch;
|
|
55
|
+
|
|
56
|
+
/** @param {LitElement} host */
|
|
57
|
+
constructor(host) {
|
|
58
|
+
this.#host = host;
|
|
59
|
+
host.addController(this);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
hostUpdated() {
|
|
63
|
+
const panel = /** @type {HTMLElement | null} */ (
|
|
64
|
+
this.#host.renderRoot?.querySelector('[part~="panel"]') ?? null
|
|
65
|
+
);
|
|
66
|
+
if (panel === this.#panel) return;
|
|
67
|
+
|
|
68
|
+
this.#unbind();
|
|
69
|
+
this.#panel = panel;
|
|
70
|
+
this.#bind();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
hostDisconnected() {
|
|
74
|
+
this.#unbind();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#bind() {
|
|
78
|
+
const panel = this.#panel;
|
|
79
|
+
if (!panel) return;
|
|
80
|
+
panel.addEventListener('touchstart', this.#onStart, { passive: true });
|
|
81
|
+
// Cancelling a move is the whole point, so this one cannot be passive.
|
|
82
|
+
panel.addEventListener('touchmove', this.#onMove, { passive: false });
|
|
83
|
+
panel.addEventListener('touchend', this.#onEnd, { passive: true });
|
|
84
|
+
panel.addEventListener('touchcancel', this.#onEnd, { passive: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#unbind() {
|
|
88
|
+
const panel = this.#panel;
|
|
89
|
+
if (!panel) return;
|
|
90
|
+
panel.removeEventListener('touchstart', this.#onStart);
|
|
91
|
+
panel.removeEventListener('touchmove', this.#onMove);
|
|
92
|
+
panel.removeEventListener('touchend', this.#onEnd);
|
|
93
|
+
panel.removeEventListener('touchcancel', this.#onEnd);
|
|
94
|
+
this.#panel = null;
|
|
95
|
+
this.#touch = undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {TouchEvent} event */
|
|
99
|
+
#onStart = (event) => {
|
|
100
|
+
if (event.touches.length !== 1) {
|
|
101
|
+
this.#touch = undefined;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const touch = event.touches[0];
|
|
105
|
+
this.#touch = { x: touch.clientX, y: touch.clientY, verdict: 'undecided' };
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
#onEnd = () => {
|
|
109
|
+
this.#touch = undefined;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** @param {TouchEvent} event */
|
|
113
|
+
#onMove = (event) => {
|
|
114
|
+
const from = this.#touch;
|
|
115
|
+
if (!from) return;
|
|
116
|
+
|
|
117
|
+
// A second finger arriving turns this into a pinch. Hands off for the
|
|
118
|
+
// rest of the gesture, zoom included.
|
|
119
|
+
if (event.touches.length > 1) {
|
|
120
|
+
this.#touch = undefined;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (from.verdict === 'block') {
|
|
125
|
+
if (event.cancelable) event.preventDefault();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (from.verdict === 'allow') return;
|
|
129
|
+
|
|
130
|
+
const touch = event.touches[0];
|
|
131
|
+
const dx = touch.clientX - from.x;
|
|
132
|
+
const dy = touch.clientY - from.y;
|
|
133
|
+
// Too small to read a direction from, and too small for the browser to
|
|
134
|
+
// have started scrolling either.
|
|
135
|
+
if (Math.abs(dx) < THRESHOLD && Math.abs(dy) < THRESHOLD) return;
|
|
136
|
+
|
|
137
|
+
const vertical = Math.abs(dy) >= Math.abs(dx);
|
|
138
|
+
const allowed = this.#canScroll(event, vertical, vertical ? dy : dx);
|
|
139
|
+
from.verdict = allowed ? 'allow' : 'block';
|
|
140
|
+
if (!allowed && event.cancelable) event.preventDefault();
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Is there something between the touch and the panel that can still scroll
|
|
145
|
+
* the way the finger is going?
|
|
146
|
+
*
|
|
147
|
+
* @param {TouchEvent} event
|
|
148
|
+
* @param {boolean} vertical
|
|
149
|
+
* @param {number} delta Positive when the finger moves down, or right.
|
|
150
|
+
* @returns {boolean}
|
|
151
|
+
*/
|
|
152
|
+
#canScroll(event, vertical, delta) {
|
|
153
|
+
const panel = this.#panel;
|
|
154
|
+
if (!panel) return true;
|
|
155
|
+
|
|
156
|
+
for (const node of event.composedPath()) {
|
|
157
|
+
if (!(node instanceof HTMLElement)) continue;
|
|
158
|
+
|
|
159
|
+
if (this.#hasRoom(node, vertical, delta)) return true;
|
|
160
|
+
if (node === panel) break;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {HTMLElement} element
|
|
167
|
+
* @param {boolean} vertical
|
|
168
|
+
* @param {number} delta
|
|
169
|
+
* @returns {boolean}
|
|
170
|
+
*/
|
|
171
|
+
#hasRoom(element, vertical, delta) {
|
|
172
|
+
const style = getComputedStyle(element);
|
|
173
|
+
const overflow = vertical ? style.overflowY : style.overflowX;
|
|
174
|
+
if (!SCROLLABLE.test(overflow)) return false;
|
|
175
|
+
|
|
176
|
+
const position = vertical ? element.scrollTop : element.scrollLeft;
|
|
177
|
+
const size = vertical ? element.clientHeight : element.clientWidth;
|
|
178
|
+
const content = vertical ? element.scrollHeight : element.scrollWidth;
|
|
179
|
+
if (content <= size + EDGE_SLACK) return false;
|
|
180
|
+
|
|
181
|
+
// A finger moving down pulls the content down, which means reading from
|
|
182
|
+
// further up: there has to be something above the current position.
|
|
183
|
+
return delta > 0 ? position > EDGE_SLACK : position < content - size - EDGE_SLACK;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/** @import { ReactiveController, LitElement } from 'lit' */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Past this the reader has pinched to zoom, and the widget stays out of it.
|
|
7
|
+
* A little over 1 because browsers report scales like 1.0000002.
|
|
8
|
+
*/
|
|
9
|
+
const ZOOMED = 1.01;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Keeps the full-screen panel inside the part of the screen the reader can
|
|
13
|
+
* actually see.
|
|
14
|
+
*
|
|
15
|
+
* On a phone the panel is `position: fixed` across the whole viewport, and
|
|
16
|
+
* that viewport is not what is on screen once a keyboard slides up: the
|
|
17
|
+
* layout viewport keeps its full height, so the composer — and with it the
|
|
18
|
+
* last few messages — ends up behind the keyboard. iOS then scrolls the page
|
|
19
|
+
* to chase the focused field, which moves the fixed panel out of frame
|
|
20
|
+
* instead of helping. `dvh` is no use here either; it tracks the browser's
|
|
21
|
+
* own chrome collapsing, not the keyboard.
|
|
22
|
+
*
|
|
23
|
+
* `visualViewport` is the one thing that does report the visible box, so the
|
|
24
|
+
* panel is pinned to it: its top to `offsetTop`, its height to `height`. The
|
|
25
|
+
* composer lands just above the keyboard, and the conversation gets whatever
|
|
26
|
+
* is left.
|
|
27
|
+
*
|
|
28
|
+
* A pinch is left alone. Zooming is how a reader gets a closer look, and a
|
|
29
|
+
* panel that re-fitted itself to the magnified box would take that away by
|
|
30
|
+
* reflowing the text back to the same apparent size.
|
|
31
|
+
*
|
|
32
|
+
* @implements {ReactiveController}
|
|
33
|
+
*/
|
|
34
|
+
export class ViewportController {
|
|
35
|
+
/** @type {LitElement} */
|
|
36
|
+
#host;
|
|
37
|
+
|
|
38
|
+
/** @type {() => boolean} */
|
|
39
|
+
#enabled;
|
|
40
|
+
|
|
41
|
+
/** @type {(() => void) | undefined} */
|
|
42
|
+
#onChange;
|
|
43
|
+
|
|
44
|
+
/** True while the custom properties are set, to avoid pointless writes. */
|
|
45
|
+
#applied = false;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {LitElement} host
|
|
49
|
+
* @param {{ enabled: () => boolean }} options
|
|
50
|
+
*/
|
|
51
|
+
constructor(host, { enabled }) {
|
|
52
|
+
this.#host = host;
|
|
53
|
+
this.#enabled = enabled;
|
|
54
|
+
host.addController(this);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
hostConnected() {
|
|
58
|
+
const viewport = window.visualViewport;
|
|
59
|
+
if (!viewport) return;
|
|
60
|
+
|
|
61
|
+
this.#onChange = () => this.#apply();
|
|
62
|
+
// Resize is the keyboard arriving and leaving; scroll is iOS shifting the
|
|
63
|
+
// visible box around while it is up.
|
|
64
|
+
viewport.addEventListener('resize', this.#onChange);
|
|
65
|
+
viewport.addEventListener('scroll', this.#onChange);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
hostDisconnected() {
|
|
69
|
+
const viewport = window.visualViewport;
|
|
70
|
+
if (viewport && this.#onChange) {
|
|
71
|
+
viewport.removeEventListener('resize', this.#onChange);
|
|
72
|
+
viewport.removeEventListener('scroll', this.#onChange);
|
|
73
|
+
}
|
|
74
|
+
this.#onChange = undefined;
|
|
75
|
+
this.#clear();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
hostUpdated() {
|
|
79
|
+
this.#apply();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#apply() {
|
|
83
|
+
const viewport = window.visualViewport;
|
|
84
|
+
const panel = this.#host.renderRoot?.querySelector('[part~="panel"]');
|
|
85
|
+
|
|
86
|
+
if (!viewport || !panel || !this.#enabled() || viewport.scale > ZOOMED) {
|
|
87
|
+
this.#clear();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.#host.style.setProperty('--_chit-vv-top', `${Math.round(viewport.offsetTop)}px`);
|
|
92
|
+
this.#host.style.setProperty('--_chit-vv-height', `${Math.round(viewport.height)}px`);
|
|
93
|
+
this.#applied = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#clear() {
|
|
97
|
+
if (!this.#applied) return;
|
|
98
|
+
this.#host.style.removeProperty('--_chit-vv-top');
|
|
99
|
+
this.#host.style.removeProperty('--_chit-vv-height');
|
|
100
|
+
this.#applied = false;
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/index.js
CHANGED
|
@@ -7,3 +7,37 @@ if (!customElements.get('chit-ui')) {
|
|
|
7
7
|
|
|
8
8
|
export { ChitUI };
|
|
9
9
|
export { Events } from './events.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The public types, re-exported so a consumer can write
|
|
13
|
+
* `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
|
|
14
|
+
* into `dist/types/` for them.
|
|
15
|
+
*
|
|
16
|
+
* @typedef {import('./types.js').Role} Role
|
|
17
|
+
* @typedef {import('./types.js').MessageStatus} MessageStatus
|
|
18
|
+
* @typedef {import('./types.js').ChatState} ChatState
|
|
19
|
+
* @typedef {import('./types.js').Trigger} Trigger
|
|
20
|
+
* @typedef {import('./types.js').Device} Device
|
|
21
|
+
* @typedef {import('./types.js').Effect} Effect
|
|
22
|
+
* @typedef {import('./types.js').Position} Position
|
|
23
|
+
* @typedef {import('./types.js').BubbleTail} BubbleTail
|
|
24
|
+
* @typedef {import('./types.js').LoadingStyle} LoadingStyle
|
|
25
|
+
* @typedef {import('./types.js').MessageBase} MessageBase
|
|
26
|
+
* @typedef {import('./types.js').TextMessage} TextMessage
|
|
27
|
+
* @typedef {import('./types.js').HtmlMessage} HtmlMessage
|
|
28
|
+
* @typedef {import('./types.js').TemplateMessage} TemplateMessage
|
|
29
|
+
* @typedef {import('./types.js').ElementMessage} ElementMessage
|
|
30
|
+
* @typedef {import('./types.js').ComponentMessage} ComponentMessage
|
|
31
|
+
* @typedef {import('./types.js').Message} Message
|
|
32
|
+
* @typedef {import('./types.js').Animation} Animation
|
|
33
|
+
* @typedef {import('./types.js').Offset} Offset
|
|
34
|
+
* @typedef {import('./types.js').ClosedTheme} ClosedTheme
|
|
35
|
+
* @typedef {import('./types.js').OpenColors} OpenColors
|
|
36
|
+
* @typedef {import('./types.js').SpeakerTheme} SpeakerTheme
|
|
37
|
+
* @typedef {import('./types.js').LoadingTheme} LoadingTheme
|
|
38
|
+
* @typedef {import('./types.js').OpenTheme} OpenTheme
|
|
39
|
+
* @typedef {import('./types.js').Theme} Theme
|
|
40
|
+
* @typedef {import('./types.js').ResolvedClosed} ResolvedClosed
|
|
41
|
+
* @typedef {import('./types.js').ResolvedOpen} ResolvedOpen
|
|
42
|
+
* @typedef {import('./types.js').ResolvedTheme} ResolvedTheme
|
|
43
|
+
*/
|
|
@@ -29,6 +29,24 @@ export const composerStyles = css`
|
|
|
29
29
|
overflow-y: auto;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/*
|
|
33
|
+
* iOS Safari zooms the page in whenever a field smaller than 16px takes
|
|
34
|
+
* focus, and it does not zoom back out when the field is done with — the
|
|
35
|
+
* reader is left with a widget wider than the screen and no obvious way
|
|
36
|
+
* back. Nothing in the page can undo that zoom short of pinning the
|
|
37
|
+
* viewport's scale, which would also take pinch-zoom away from everyone.
|
|
38
|
+
* So the field is kept at the size that never triggers it, and larger
|
|
39
|
+
* still if the theme's font is larger.
|
|
40
|
+
*
|
|
41
|
+
* Coarse pointers only: on a desktop the same rule would make the field
|
|
42
|
+
* stand out from the rest of the widget for no reason.
|
|
43
|
+
*/
|
|
44
|
+
@media (pointer: coarse) {
|
|
45
|
+
[part~='input'] {
|
|
46
|
+
font-size: max(16px, 1em);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
[part~='input']::placeholder {
|
|
33
51
|
color: var(--chit-color-input-placeholder);
|
|
34
52
|
}
|
package/src/styles/panel.css.js
CHANGED
|
@@ -56,12 +56,18 @@ export const panelStyles = css`
|
|
|
56
56
|
* A phone gets the whole screen. dvh rather than vh so the browser chrome
|
|
57
57
|
* collapsing does not leave a gap, and the safe-area insets keep the header
|
|
58
58
|
* clear of the notch.
|
|
59
|
+
*
|
|
60
|
+
* While a keyboard is up, the two --_chit-vv-* properties carry what is
|
|
61
|
+
* actually on screen (ViewportController fills them in from
|
|
62
|
+
* visualViewport); dvh knows nothing about a keyboard and would leave the
|
|
63
|
+
* composer behind it.
|
|
59
64
|
*/
|
|
60
65
|
:host([data-device='mobile']) [part~='panel'],
|
|
61
66
|
:host([data-device='mobile'][data-panel-position]) [part~='panel'] {
|
|
62
67
|
inset: 0;
|
|
68
|
+
top: var(--_chit-vv-top, 0px);
|
|
63
69
|
width: 100vw;
|
|
64
|
-
height: 100dvh;
|
|
70
|
+
height: var(--_chit-vv-height, 100dvh);
|
|
65
71
|
max-width: none;
|
|
66
72
|
max-height: none;
|
|
67
73
|
border-radius: 0;
|