@hidemikimura/chit-ui 0.3.0 → 0.3.1

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.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Keeps a touch that lands on the open panel from scrolling the page behind
3
+ * it.
4
+ *
5
+ * `overscroll-behavior: contain` is on the message list already, and it does
6
+ * the job for the case it covers: a list that can scroll, dragged past its
7
+ * own end. It has nothing to say about the cases that actually leak. A short
8
+ * conversation does not overflow, so the list is not a scroll container at
9
+ * all and the touch goes straight through to the document — which is what a
10
+ * reader on an iPhone sees as the page sliding around underneath a chat that
11
+ * looks like it should be holding still. The same happens for a touch that
12
+ * starts on the title bar or beside the input.
13
+ *
14
+ * So the rule is decided here instead: a touch on the panel may scroll
15
+ * something inside the panel, and otherwise it does nothing. On the first
16
+ * move of each gesture the controller looks along the touch's own path —
17
+ * `composedPath`, so a scroller inside a consumer's component counts too —
18
+ * for something that can still travel in the direction the finger is going.
19
+ * Finding one, it stands aside for the rest of the gesture; finding none, it
20
+ * cancels the move, and iOS then takes the whole gesture as "not a scroll".
21
+ *
22
+ * Two touches are left alone: that is a pinch, and zooming is the reader's.
23
+ * A move that is no longer cancelable is left alone too — the browser has
24
+ * already committed to scrolling, and `overscroll-behavior` is what keeps
25
+ * that inside the list.
26
+ *
27
+ * @implements {ReactiveController}
28
+ */
29
+ export class ScrollLockController implements ReactiveController {
30
+ /** @param {LitElement} host */
31
+ constructor(host: LitElement);
32
+ hostUpdated(): void;
33
+ hostDisconnected(): void;
34
+ #private;
35
+ }
36
+ import type { ReactiveController } from 'lit';
37
+ import type { LitElement } from 'lit';
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Keeps the full-screen panel inside the part of the screen the reader can
3
+ * actually see.
4
+ *
5
+ * On a phone the panel is `position: fixed` across the whole viewport, and
6
+ * that viewport is not what is on screen once a keyboard slides up: the
7
+ * layout viewport keeps its full height, so the composer — and with it the
8
+ * last few messages — ends up behind the keyboard. iOS then scrolls the page
9
+ * to chase the focused field, which moves the fixed panel out of frame
10
+ * instead of helping. `dvh` is no use here either; it tracks the browser's
11
+ * own chrome collapsing, not the keyboard.
12
+ *
13
+ * `visualViewport` is the one thing that does report the visible box, so the
14
+ * panel is pinned to it: its top to `offsetTop`, its height to `height`. The
15
+ * composer lands just above the keyboard, and the conversation gets whatever
16
+ * is left.
17
+ *
18
+ * A pinch is left alone. Zooming is how a reader gets a closer look, and a
19
+ * panel that re-fitted itself to the magnified box would take that away by
20
+ * reflowing the text back to the same apparent size.
21
+ *
22
+ * @implements {ReactiveController}
23
+ */
24
+ export class ViewportController implements ReactiveController {
25
+ /**
26
+ * @param {LitElement} host
27
+ * @param {{ enabled: () => boolean }} options
28
+ */
29
+ constructor(host: LitElement, { enabled }: {
30
+ enabled: () => boolean;
31
+ });
32
+ hostConnected(): void;
33
+ hostDisconnected(): void;
34
+ hostUpdated(): void;
35
+ #private;
36
+ }
37
+ import type { ReactiveController } from 'lit';
38
+ import type { LitElement } from 'lit';
@@ -1,4 +1,166 @@
1
1
  /// <reference path="./global.d.ts" />
2
2
  export { ChitUI };
3
3
  export { Events } from "./events.js";
4
+ /**
5
+ * The public types, re-exported so a consumer can write
6
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
7
+ * into `dist/types/` for them.
8
+ */
9
+ export type Role = import("./types.js").Role;
10
+ /**
11
+ * The public types, re-exported so a consumer can write
12
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
13
+ * into `dist/types/` for them.
14
+ */
15
+ export type MessageStatus = import("./types.js").MessageStatus;
16
+ /**
17
+ * The public types, re-exported so a consumer can write
18
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
19
+ * into `dist/types/` for them.
20
+ */
21
+ export type ChatState = import("./types.js").ChatState;
22
+ /**
23
+ * The public types, re-exported so a consumer can write
24
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
25
+ * into `dist/types/` for them.
26
+ */
27
+ export type Trigger = import("./types.js").Trigger;
28
+ /**
29
+ * The public types, re-exported so a consumer can write
30
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
31
+ * into `dist/types/` for them.
32
+ */
33
+ export type Device = import("./types.js").Device;
34
+ /**
35
+ * The public types, re-exported so a consumer can write
36
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
37
+ * into `dist/types/` for them.
38
+ */
39
+ export type Effect = import("./types.js").Effect;
40
+ /**
41
+ * The public types, re-exported so a consumer can write
42
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
43
+ * into `dist/types/` for them.
44
+ */
45
+ export type Position = import("./types.js").Position;
46
+ /**
47
+ * The public types, re-exported so a consumer can write
48
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
49
+ * into `dist/types/` for them.
50
+ */
51
+ export type BubbleTail = import("./types.js").BubbleTail;
52
+ /**
53
+ * The public types, re-exported so a consumer can write
54
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
55
+ * into `dist/types/` for them.
56
+ */
57
+ export type LoadingStyle = import("./types.js").LoadingStyle;
58
+ /**
59
+ * The public types, re-exported so a consumer can write
60
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
61
+ * into `dist/types/` for them.
62
+ */
63
+ export type MessageBase = import("./types.js").MessageBase;
64
+ /**
65
+ * The public types, re-exported so a consumer can write
66
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
67
+ * into `dist/types/` for them.
68
+ */
69
+ export type TextMessage = import("./types.js").TextMessage;
70
+ /**
71
+ * The public types, re-exported so a consumer can write
72
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
73
+ * into `dist/types/` for them.
74
+ */
75
+ export type HtmlMessage = import("./types.js").HtmlMessage;
76
+ /**
77
+ * The public types, re-exported so a consumer can write
78
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
79
+ * into `dist/types/` for them.
80
+ */
81
+ export type TemplateMessage = import("./types.js").TemplateMessage;
82
+ /**
83
+ * The public types, re-exported so a consumer can write
84
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
85
+ * into `dist/types/` for them.
86
+ */
87
+ export type ElementMessage = import("./types.js").ElementMessage;
88
+ /**
89
+ * The public types, re-exported so a consumer can write
90
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
91
+ * into `dist/types/` for them.
92
+ */
93
+ export type ComponentMessage = import("./types.js").ComponentMessage;
94
+ /**
95
+ * The public types, re-exported so a consumer can write
96
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
97
+ * into `dist/types/` for them.
98
+ */
99
+ export type Message = import("./types.js").Message;
100
+ /**
101
+ * The public types, re-exported so a consumer can write
102
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
103
+ * into `dist/types/` for them.
104
+ */
105
+ export type Animation = import("./types.js").Animation;
106
+ /**
107
+ * The public types, re-exported so a consumer can write
108
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
109
+ * into `dist/types/` for them.
110
+ */
111
+ export type Offset = import("./types.js").Offset;
112
+ /**
113
+ * The public types, re-exported so a consumer can write
114
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
115
+ * into `dist/types/` for them.
116
+ */
117
+ export type ClosedTheme = import("./types.js").ClosedTheme;
118
+ /**
119
+ * The public types, re-exported so a consumer can write
120
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
121
+ * into `dist/types/` for them.
122
+ */
123
+ export type OpenColors = import("./types.js").OpenColors;
124
+ /**
125
+ * The public types, re-exported so a consumer can write
126
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
127
+ * into `dist/types/` for them.
128
+ */
129
+ export type SpeakerTheme = import("./types.js").SpeakerTheme;
130
+ /**
131
+ * The public types, re-exported so a consumer can write
132
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
133
+ * into `dist/types/` for them.
134
+ */
135
+ export type LoadingTheme = import("./types.js").LoadingTheme;
136
+ /**
137
+ * The public types, re-exported so a consumer can write
138
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
139
+ * into `dist/types/` for them.
140
+ */
141
+ export type OpenTheme = import("./types.js").OpenTheme;
142
+ /**
143
+ * The public types, re-exported so a consumer can write
144
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
145
+ * into `dist/types/` for them.
146
+ */
147
+ export type Theme = import("./types.js").Theme;
148
+ /**
149
+ * The public types, re-exported so a consumer can write
150
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
151
+ * into `dist/types/` for them.
152
+ */
153
+ export type ResolvedClosed = import("./types.js").ResolvedClosed;
154
+ /**
155
+ * The public types, re-exported so a consumer can write
156
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
157
+ * into `dist/types/` for them.
158
+ */
159
+ export type ResolvedOpen = import("./types.js").ResolvedOpen;
160
+ /**
161
+ * The public types, re-exported so a consumer can write
162
+ * `import type { Message } from '@hidemikimura/chit-ui'` instead of reaching
163
+ * into `dist/types/` for them.
164
+ */
165
+ export type ResolvedTheme = import("./types.js").ResolvedTheme;
4
166
  import { ChitUI } from './chit-ui.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hidemikimura/chit-ui",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Lit-based chat widget UI: launcher, panel, themable, any HTML or Lit component as a message",
5
5
  "license": "MIT",
6
6
  "author": "Hidemi Kimura",
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
  }