@hidemikimura/chit-ui 0.2.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +148 -3
  3. package/dist/chit-ui.iife.min.js +218 -99
  4. package/dist/chit-ui.iife.min.js.map +1 -1
  5. package/dist/chit-ui.min.js +209 -90
  6. package/dist/chit-ui.min.js.map +1 -1
  7. package/dist/types/chit-ui.d.ts +64 -4
  8. package/dist/types/controllers/composer-controller.d.ts +4 -0
  9. package/dist/types/controllers/drag-controller.d.ts +118 -0
  10. package/dist/types/controllers/scroll-lock-controller.d.ts +37 -0
  11. package/dist/types/controllers/viewport-controller.d.ts +38 -0
  12. package/dist/types/events.d.ts +1 -0
  13. package/dist/types/global.d.ts +7 -1
  14. package/dist/types/i18n/labels.d.ts +8 -0
  15. package/dist/types/index.d.ts +162 -0
  16. package/dist/types/render/message-list.d.ts +0 -1
  17. package/dist/types/theme/default-theme.d.ts +8 -0
  18. package/dist/types/types.d.ts +40 -0
  19. package/package.json +1 -1
  20. package/src/chit-ui.js +261 -4
  21. package/src/controllers/composer-controller.js +2 -0
  22. package/src/controllers/drag-controller.js +319 -0
  23. package/src/controllers/scroll-controller.js +21 -1
  24. package/src/controllers/scroll-lock-controller.js +185 -0
  25. package/src/controllers/viewport-controller.js +102 -0
  26. package/src/events.js +1 -0
  27. package/src/global.d.ts +7 -1
  28. package/src/i18n/labels.js +6 -0
  29. package/src/index.js +34 -0
  30. package/src/render/launcher.js +9 -1
  31. package/src/render/message-list.js +40 -10
  32. package/src/render/panel.js +17 -1
  33. package/src/styles/composer.css.js +18 -0
  34. package/src/styles/launcher.css.js +13 -0
  35. package/src/styles/message.css.js +60 -15
  36. package/src/styles/panel.css.js +30 -3
  37. package/src/theme/default-theme.js +16 -0
  38. package/src/types.js +18 -0
@@ -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/events.js CHANGED
@@ -17,6 +17,7 @@ export const Events = Object.freeze({
17
17
  BREAKPOINT_CHANGE: 'chat-breakpoint-change',
18
18
  HOME: 'chat-home',
19
19
  ATTACH: 'chat-attach',
20
+ MOVE: 'chat-move',
20
21
  });
21
22
 
22
23
  /**
package/src/global.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ChitUI } from './chit-ui.js';
2
- import type { Message, ChatState, Trigger, Device } from './types.js';
2
+ import type { Message, ChatState, Trigger, Device, Position } from './types.js';
3
3
 
4
4
  export interface ChitUIEventMap {
5
5
  'chat-submit': CustomEvent<{ text: string }>;
@@ -17,6 +17,12 @@ export interface ChitUIEventMap {
17
17
  'chat-breakpoint-change': CustomEvent<{ device: Device }>;
18
18
  'chat-home': CustomEvent<{ trigger: Trigger }>;
19
19
  'chat-attach': CustomEvent<{ files: File[] }>;
20
+ 'chat-move': CustomEvent<{
21
+ target: 'launcher' | 'panel';
22
+ position: Position;
23
+ offset: { x: number; y: number };
24
+ displacement: { x: number; y: number };
25
+ }>;
20
26
  }
21
27
 
22
28
  declare global {
@@ -11,6 +11,8 @@
11
11
  * @property {string} attach Attach button beside the composer.
12
12
  * @property {string} input Composer textarea.
13
13
  * @property {string} typing Announced while the other side is typing.
14
+ * @property {string} loading Announced while waiting for an answer.
15
+ * @property {string} move The panel's drag handle.
14
16
  * @property {string} toLatest "Jump to newest" button.
15
17
  * @property {{ sending: string, sent: string, error: string }} status Delivery state of one's own message.
16
18
  * @property {string} charactersLeft Counter, with {n} for the number remaining.
@@ -29,6 +31,8 @@ const BUILT_IN = {
29
31
  attach: '画像や動画を添付',
30
32
  input: 'メッセージを入力',
31
33
  typing: '入力中',
34
+ loading: '応答を待っています',
35
+ move: 'チャットの位置を移動(矢印キー)',
32
36
  toLatest: '最新へ',
33
37
  status: { sending: '送信中', sent: '送信済み', error: '送信できませんでした' },
34
38
  charactersLeft: '残り {n} 文字',
@@ -44,6 +48,8 @@ const BUILT_IN = {
44
48
  attach: 'Attach an image or video',
45
49
  input: 'Type a message',
46
50
  typing: 'Typing',
51
+ loading: 'Waiting for a reply',
52
+ move: 'Move the chat (arrow keys)',
47
53
  toLatest: 'Jump to latest',
48
54
  status: { sending: 'Sending', sent: 'Sent', error: 'Not delivered' },
49
55
  charactersLeft: '{n} characters left',
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
+ */
@@ -34,7 +34,15 @@ export function renderLauncher(host) {
34
34
  aria-expanded=${host.state === 'open'}
35
35
  ?data-has-image=${!custom && !!closed.image}
36
36
  ?data-custom=${!!custom}
37
- @click=${() => host.toggleFromUser()}
37
+ ?data-draggable=${closed.draggable}
38
+ @pointerdown=${(/** @type {PointerEvent} */ event) => host.launcherDrag.start(event)}
39
+ @keydown=${(/** @type {KeyboardEvent} */ event) => host.launcherDrag.nudge(event)}
40
+ @click=${() => {
41
+ // The pointer-up that ends a drag is followed by a click; opening the
42
+ // panel then would punish the reader for having moved the button.
43
+ if (host.launcherDrag.consumeDrag()) return;
44
+ host.toggleFromUser();
45
+ }}
38
46
  >
39
47
  ${custom ?? html`<slot name="launcher">${defaultContent(closed)}</slot>`}
40
48
  </button>
@@ -5,6 +5,34 @@ import { renderMessage } from './message.js';
5
5
 
6
6
  /** @import { ChitUI } from '../chit-ui.js' */
7
7
 
8
+ /**
9
+ * The wait for an answer, at the end of the conversation.
10
+ *
11
+ * It stands where the typing bubble stands and replaces it while it is up:
12
+ * "typing" and "waiting for the server" are the same moment of the
13
+ * conversation, and two indicators for one wait would only ask the reader to
14
+ * work out the difference.
15
+ *
16
+ * @param {ChitUI} host
17
+ * @returns {import('lit').TemplateResult}
18
+ */
19
+ function renderLoading(host) {
20
+ const { style, text } = host.currentTheme.open.loading;
21
+ const label = text ?? host.currentLabels.loading;
22
+
23
+ return html`
24
+ <div part="loading" data-style=${style} role="status" aria-label=${label}>
25
+ ${style === 'dots'
26
+ ? html`<span class="dots" aria-hidden="true"><i></i><i></i><i></i></span>`
27
+ : nothing}
28
+ ${style === 'spinner' ? html`<span part="spinner" aria-hidden="true"></span>` : nothing}
29
+ ${style === 'text' || text
30
+ ? html`<span part="loading-text">${label}</span>`
31
+ : nothing}
32
+ </div>
33
+ `;
34
+ }
35
+
8
36
  /**
9
37
  * The scrolling conversation.
10
38
  *
@@ -22,7 +50,7 @@ import { renderMessage } from './message.js';
22
50
  export function renderMessageList(host) {
23
51
  const labels = host.currentLabels;
24
52
  const typing = host.typing;
25
- const empty = host.messages.length === 0 && !typing;
53
+ const empty = host.messages.length === 0 && !typing && !host.loading;
26
54
 
27
55
  return html`
28
56
  <div
@@ -40,15 +68,17 @@ export function renderMessageList(host) {
40
68
  (message) => message.id,
41
69
  (message) => renderMessage(host, message),
42
70
  )}
43
- ${typing
44
- ? html`
45
- <div part="typing" role="status" aria-label=${labels.typing}>
46
- ${typeof typing === 'object' && typing.html
47
- ? host.renderTypingHtml(typing.html)
48
- : html`<span class="dots" aria-hidden="true"><i></i><i></i><i></i></span>`}
49
- </div>
50
- `
51
- : nothing}
71
+ ${host.loading
72
+ ? renderLoading(host)
73
+ : typing
74
+ ? html`
75
+ <div part="typing" role="status" aria-label=${labels.typing}>
76
+ ${typeof typing === 'object' && typing.html
77
+ ? host.renderTypingHtml(typing.html)
78
+ : html`<span class="dots" aria-hidden="true"><i></i><i></i><i></i></span>`}
79
+ </div>
80
+ `
81
+ : nothing}
52
82
  </div>
53
83
  </div>
54
84
 
@@ -53,9 +53,25 @@ export function renderPanel(host) {
53
53
  function renderHeader(host, title) {
54
54
  const open = host.currentTheme.open;
55
55
  const labels = host.currentLabels;
56
+ const draggable = open.draggable && host.device === 'pc';
56
57
 
57
58
  return html`
58
- <header part="header">
59
+ <header
60
+ part="header"
61
+ ?data-draggable=${draggable}
62
+ tabindex=${draggable ? '0' : nothing}
63
+ aria-label=${draggable ? labels.move : nothing}
64
+ @pointerdown=${(/** @type {PointerEvent} */ event) => {
65
+ // Only the bar itself is a handle: a drag that starts on the close
66
+ // button would swallow the click that closes the panel.
67
+ if (/** @type {HTMLElement} */ (event.target).closest('button')) return;
68
+ host.panelDrag.start(event);
69
+ }}
70
+ @keydown=${(/** @type {KeyboardEvent} */ event) => {
71
+ if (event.target !== event.currentTarget) return;
72
+ host.panelDrag.nudge(event);
73
+ }}
74
+ >
59
75
  <slot name="header">
60
76
  <slot name="header-title">
61
77
  <span part="header-title">
@@ -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
  }
@@ -23,6 +23,19 @@ export const launcherStyles = css`
23
23
  transition: filter 120ms ease;
24
24
  }
25
25
 
26
+ /*
27
+ * A draggable launcher takes the touch gestures for itself, so a finger on
28
+ * it moves the button instead of scrolling the page behind it.
29
+ */
30
+ [part~='launcher'][data-draggable] {
31
+ cursor: grab;
32
+ touch-action: none;
33
+ }
34
+
35
+ [part~='launcher'][data-draggable]:active {
36
+ cursor: grabbing;
37
+ }
38
+
26
39
  /* The image comes from the theme, so it is a background rather than an <img>. */
27
40
  [part~='launcher'][data-has-image] {
28
41
  background-image: var(--chit-launcher-image);