@hidemikimura/chit-ui 0.1.0 → 0.2.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.
@@ -1,4 +1,5 @@
1
1
  export * from "./index.js";
2
+ export * from "./themes/index.js";
2
3
  export { repeat } from "lit/directives/repeat.js";
3
4
  export { unsafeHTML } from "lit/directives/unsafe-html.js";
4
5
  export { classMap } from "lit/directives/class-map.js";
@@ -213,6 +213,18 @@ export class ChitUI extends LitElement {
213
213
  handleKeydown(event: KeyboardEvent): void;
214
214
  /** @param {boolean} started */
215
215
  handleComposition(started: boolean): void;
216
+ /**
217
+ * Report files the reader picked with the attach button.
218
+ *
219
+ * Nothing is uploaded, previewed or added to the conversation here: the
220
+ * widget does not own `messages` and has nowhere to send bytes.
221
+ *
222
+ * @param {File[]} files
223
+ * @returns {void}
224
+ */
225
+ handleAttach(files: File[]): void;
226
+ /** Open the file picker from code, as the attach button does. */
227
+ openAttach(): void;
216
228
  /**
217
229
  * Render a consumer-supplied typing bubble.
218
230
  *
@@ -234,6 +246,18 @@ export class ChitUI extends LitElement {
234
246
  toggleFromUser(): Promise<boolean>;
235
247
  /** Called by the close button and by Esc. */
236
248
  closeFromUser(): Promise<boolean>;
249
+ /**
250
+ * Ask to start the conversation over.
251
+ *
252
+ * The widget only announces it: `messages` belongs to the consumer, so
253
+ * clearing it, replaying a scenario or asking for confirmation first are all
254
+ * theirs to do. Cancelling the event is not offered for the same reason —
255
+ * there is nothing here to cancel.
256
+ *
257
+ * @param {Trigger} [trigger='api']
258
+ * @returns {void}
259
+ */
260
+ home(trigger?: Trigger): void;
237
261
  /** @override */
238
262
  override willUpdate(): void;
239
263
  /** @override */
@@ -249,3 +273,4 @@ import type { Labels } from './i18n/labels.js';
249
273
  import type { ChatState } from './types.js';
250
274
  import type { Device } from './types.js';
251
275
  import type { ResolvedTheme } from './types.js';
276
+ import type { Trigger } from './types.js';
@@ -25,4 +25,6 @@ export const Events: Readonly<{
25
25
  MESSAGE_CLICK: "chat-message-click";
26
26
  SCROLL_TOP: "chat-scroll-top";
27
27
  BREAKPOINT_CHANGE: "chat-breakpoint-change";
28
+ HOME: "chat-home";
29
+ ATTACH: "chat-attach";
28
30
  }>;
@@ -15,6 +15,8 @@ export interface ChitUIEventMap {
15
15
  'chat-message-click': CustomEvent<{ message: Message; target: Element; originalEvent: MouseEvent }>;
16
16
  'chat-scroll-top': CustomEvent<Record<string, never>>;
17
17
  'chat-breakpoint-change': CustomEvent<{ device: Device }>;
18
+ 'chat-home': CustomEvent<{ trigger: Trigger }>;
19
+ 'chat-attach': CustomEvent<{ files: File[] }>;
18
20
  }
19
21
 
20
22
  declare global {
@@ -33,10 +33,18 @@ export type Labels = {
33
33
  * Close button.
34
34
  */
35
35
  close: string;
36
+ /**
37
+ * Home button: back to the start of the conversation.
38
+ */
39
+ home: string;
36
40
  /**
37
41
  * Send button.
38
42
  */
39
43
  send: string;
44
+ /**
45
+ * Attach button beside the composer.
46
+ */
47
+ attach: string;
40
48
  /**
41
49
  * Composer textarea.
42
50
  */
@@ -1,4 +1,3 @@
1
- /** @import { ChitUI } from '../chit-ui.js' */
2
1
  /**
3
2
  * The composer: a textarea that grows with its content, and a send button.
4
3
  *
@@ -20,9 +20,10 @@ export namespace defaultTheme {
20
20
  let radius: number;
21
21
  let launcher: "hidden";
22
22
  namespace header {
23
+ let visible: boolean;
23
24
  let title: null;
24
25
  let logo: null;
25
- let avatar: null;
26
+ let home: boolean;
26
27
  }
27
28
  namespace background {
28
29
  let image: null;
@@ -58,6 +59,23 @@ export namespace defaultTheme {
58
59
  import inputPlaceholder = PALETTE.muted;
59
60
  export { inputPlaceholder };
60
61
  }
62
+ namespace bubble {
63
+ let radius_1: number;
64
+ export { radius_1 as radius };
65
+ export let tail: "none";
66
+ }
67
+ namespace speaker {
68
+ namespace assistant {
69
+ let name: null;
70
+ let avatar: null;
71
+ }
72
+ namespace user {
73
+ let name_1: null;
74
+ export { name_1 as name };
75
+ let avatar_1: null;
76
+ export { avatar_1 as avatar };
77
+ }
78
+ }
61
79
  namespace animation {
62
80
  let enter: "scale";
63
81
  let exit: "fade";
@@ -67,6 +85,9 @@ export namespace defaultTheme {
67
85
  namespace input {
68
86
  let maxRows: number;
69
87
  let placeholder: null;
88
+ let attach: boolean;
89
+ let accept: string;
90
+ let multiple: boolean;
70
91
  }
71
92
  }
72
93
  namespace hidden {
@@ -0,0 +1,16 @@
1
+ /** @import { Theme } from '../types.js' */
2
+ /**
3
+ * A green messenger look: a pale blue-grey conversation background, white
4
+ * bubbles for them, bright green for you, tails on the top corner.
5
+ *
6
+ * This is Chit UI's own palette in that familiar shape. It carries no other
7
+ * product's logo, wordmark or artwork, and the greens are Chit UI's own: the
8
+ * deep green is dark enough that white text and the send icon clear WCAG AA
9
+ * on it, which the bright green of the bubbles could not do.
10
+ * `test/theme.test.js` re-checks every pair here, so a later tweak cannot
11
+ * quietly drop below AA.
12
+ *
13
+ * @type {Theme}
14
+ */
15
+ export const greenTheme: Theme;
16
+ import type { Theme } from '../types.js';
@@ -0,0 +1 @@
1
+ export { greenTheme } from "./green.js";
@@ -33,6 +33,11 @@ export type Effect = "fade" | "scale" | "slide" | "none";
33
33
  * every other file can `@import` from one place and so `tsc` can emit .d.ts.
34
34
  */
35
35
  export type Position = "bottom-right" | "bottom-left" | "top-right" | "top-left";
36
+ /**
37
+ * Public types for Chit UI. This module has no runtime value; it exists so that
38
+ * every other file can `@import` from one place and so `tsc` can emit .d.ts.
39
+ */
40
+ export type BubbleTail = "none" | "top" | "bottom";
36
41
  export type MessageBase = {
37
42
  /**
38
43
  * Unique within the array; used as the diffing key.
@@ -40,8 +45,14 @@ export type MessageBase = {
40
45
  id: string;
41
46
  role: Role;
42
47
  time?: string | Date | undefined;
43
- name?: string | undefined;
44
- avatar?: string | undefined;
48
+ /**
49
+ * Overrides the theme's speaker name; null hides it.
50
+ */
51
+ name?: string | null | undefined;
52
+ /**
53
+ * Overrides the theme's speaker image; null hides it.
54
+ */
55
+ avatar?: string | null | undefined;
45
56
  status?: MessageStatus | undefined;
46
57
  streaming?: boolean | undefined;
47
58
  /**
@@ -108,6 +119,20 @@ export type ClosedTheme = {
108
119
  idle?: "none" | "pulse" | "bounce";
109
120
  }) | undefined;
110
121
  };
122
+ /**
123
+ * Who is talking, as the theme describes them. A message that carries its own
124
+ * `name` or `avatar` wins; `null` on the message hides what the theme set.
125
+ */
126
+ export type SpeakerTheme = {
127
+ /**
128
+ * Shown above the bubble.
129
+ */
130
+ name?: string | null | undefined;
131
+ /**
132
+ * Image URL for the speaker's icon.
133
+ */
134
+ avatar?: string | null | undefined;
135
+ };
111
136
  export type OpenColors = {
112
137
  background?: string | undefined;
113
138
  text?: string | undefined;
@@ -141,21 +166,45 @@ export type OpenTheme = {
141
166
  * Keep the launcher visible while open.
142
167
  */
143
168
  launcher?: "hidden" | "visible" | undefined;
169
+ /**
170
+ * The title bar: whether it is there at all, its text, its image, and whether it offers a way back to the start.
171
+ */
144
172
  header?: {
173
+ visible?: boolean;
145
174
  title?: string | null;
146
175
  logo?: string | null;
147
- avatar?: string | null;
176
+ home?: boolean;
148
177
  } | undefined;
149
178
  background?: {
150
179
  image?: string | null;
151
180
  } | undefined;
152
181
  colors?: OpenColors | undefined;
182
+ /**
183
+ * Bubble corner radius, and where the tail points from.
184
+ */
185
+ bubble?: {
186
+ radius?: number;
187
+ tail?: BubbleTail;
188
+ } | undefined;
189
+ /**
190
+ * Default name and icon per side.
191
+ */
192
+ speaker?: {
193
+ assistant?: SpeakerTheme;
194
+ user?: SpeakerTheme;
195
+ } | undefined;
153
196
  animation?: (Animation & {
154
197
  scroll?: "smooth" | "instant";
155
198
  }) | undefined;
199
+ /**
200
+ * Composer: rows, placeholder, and the attach button.
201
+ */
156
202
  input?: {
157
203
  maxRows?: number;
158
204
  placeholder?: string | null;
205
+ attach?: boolean;
206
+ accept?: string;
207
+ multiple?: boolean;
159
208
  } | undefined;
160
209
  };
161
210
  export type Theme = {
@@ -223,14 +272,23 @@ export type ResolvedOpen = {
223
272
  radius: number;
224
273
  launcher: "hidden" | "visible";
225
274
  header: {
275
+ visible: boolean;
226
276
  title: string | null;
227
277
  logo: string | null;
228
- avatar: string | null;
278
+ home: boolean;
229
279
  };
230
280
  background: {
231
281
  image: string | null;
232
282
  };
233
283
  colors: Required<OpenColors>;
284
+ bubble: {
285
+ radius: number;
286
+ tail: BubbleTail;
287
+ };
288
+ speaker: {
289
+ assistant: Required<SpeakerTheme>;
290
+ user: Required<SpeakerTheme>;
291
+ };
234
292
  animation: {
235
293
  enter: Effect;
236
294
  exit: Effect;
@@ -240,6 +298,9 @@ export type ResolvedOpen = {
240
298
  input: {
241
299
  maxRows: number;
242
300
  placeholder: string | null;
301
+ attach: boolean;
302
+ accept: string;
303
+ multiple: boolean;
243
304
  };
244
305
  };
245
306
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hidemikimura/chit-ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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",
@@ -16,6 +16,10 @@
16
16
  "types": "./dist/types/element.d.ts",
17
17
  "default": "./src/element.js"
18
18
  },
19
+ "./themes.js": {
20
+ "types": "./dist/types/themes/index.d.ts",
21
+ "default": "./src/themes/index.js"
22
+ },
19
23
  "./dist/*": "./dist/*",
20
24
  "./package.json": "./package.json"
21
25
  },
@@ -23,6 +27,7 @@
23
27
  "src",
24
28
  "dist",
25
29
  "README.md",
30
+ "CHANGELOG.md",
26
31
  "LICENSE"
27
32
  ],
28
33
  "sideEffects": [
package/src/bundle.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // against, so consumers of the bundle define their message components with the
4
4
  // very same Lit instance.
5
5
  export * from './index.js';
6
+ export * from './themes/index.js';
6
7
  export { LitElement, html, css, svg, nothing, render } from 'lit';
7
8
  export { repeat } from 'lit/directives/repeat.js';
8
9
  export { unsafeHTML } from 'lit/directives/unsafe-html.js';
package/src/chit-ui.js CHANGED
@@ -387,6 +387,27 @@ export class ChitUI extends LitElement {
387
387
  else this.#composer.onCompositionEnd();
388
388
  }
389
389
 
390
+ /**
391
+ * Report files the reader picked with the attach button.
392
+ *
393
+ * Nothing is uploaded, previewed or added to the conversation here: the
394
+ * widget does not own `messages` and has nowhere to send bytes.
395
+ *
396
+ * @param {File[]} files
397
+ * @returns {void}
398
+ */
399
+ handleAttach(files) {
400
+ emit(this, Events.ATTACH, { files });
401
+ }
402
+
403
+ /** Open the file picker from code, as the attach button does. */
404
+ openAttach() {
405
+ const field = /** @type {HTMLInputElement | null} */ (
406
+ this.renderRoot.querySelector('[part~="attach-input"]')
407
+ );
408
+ field?.click();
409
+ }
410
+
390
411
  // --- internal ---------------------------------------------------------
391
412
 
392
413
  /**
@@ -442,6 +463,21 @@ export class ChitUI extends LitElement {
442
463
  return this.#go('closed', 'user');
443
464
  }
444
465
 
466
+ /**
467
+ * Ask to start the conversation over.
468
+ *
469
+ * The widget only announces it: `messages` belongs to the consumer, so
470
+ * clearing it, replaying a scenario or asking for confirmation first are all
471
+ * theirs to do. Cancelling the event is not offered for the same reason —
472
+ * there is nothing here to cancel.
473
+ *
474
+ * @param {Trigger} [trigger='api']
475
+ * @returns {void}
476
+ */
477
+ home(trigger = 'api') {
478
+ emit(this, Events.HOME, { trigger });
479
+ }
480
+
445
481
  /**
446
482
  * @param {ChatState} to
447
483
  * @param {Trigger} trigger
@@ -493,6 +529,7 @@ export class ChitUI extends LitElement {
493
529
  this.dataset.launcherPosition = theme.closed.position;
494
530
  this.dataset.panelPosition = theme.open.position;
495
531
  this.dataset.idle = theme.closed.animation.idle;
532
+ this.dataset.bubbleTail = theme.open.bubble.tail;
496
533
  }
497
534
 
498
535
  /** @override */
package/src/events.js CHANGED
@@ -15,6 +15,8 @@ export const Events = Object.freeze({
15
15
  MESSAGE_CLICK: 'chat-message-click',
16
16
  SCROLL_TOP: 'chat-scroll-top',
17
17
  BREAKPOINT_CHANGE: 'chat-breakpoint-change',
18
+ HOME: 'chat-home',
19
+ ATTACH: 'chat-attach',
18
20
  });
19
21
 
20
22
  /**
package/src/global.d.ts CHANGED
@@ -15,6 +15,8 @@ export interface ChitUIEventMap {
15
15
  'chat-message-click': CustomEvent<{ message: Message; target: Element; originalEvent: MouseEvent }>;
16
16
  'chat-scroll-top': CustomEvent<Record<string, never>>;
17
17
  'chat-breakpoint-change': CustomEvent<{ device: Device }>;
18
+ 'chat-home': CustomEvent<{ trigger: Trigger }>;
19
+ 'chat-attach': CustomEvent<{ files: File[] }>;
18
20
  }
19
21
 
20
22
  declare global {
@@ -6,7 +6,9 @@
6
6
  * @property {string} panel Accessible name of the dialog.
7
7
  * @property {string} conversation Accessible name of the message log.
8
8
  * @property {string} close Close button.
9
+ * @property {string} home Home button: back to the start of the conversation.
9
10
  * @property {string} send Send button.
11
+ * @property {string} attach Attach button beside the composer.
10
12
  * @property {string} input Composer textarea.
11
13
  * @property {string} typing Announced while the other side is typing.
12
14
  * @property {string} toLatest "Jump to newest" button.
@@ -22,7 +24,9 @@ const BUILT_IN = {
22
24
  panel: 'チャット',
23
25
  conversation: '会話',
24
26
  close: 'チャットを閉じる',
27
+ home: '最初に戻る',
25
28
  send: '送信',
29
+ attach: '画像や動画を添付',
26
30
  input: 'メッセージを入力',
27
31
  typing: '入力中',
28
32
  toLatest: '最新へ',
@@ -35,7 +39,9 @@ const BUILT_IN = {
35
39
  panel: 'Chat',
36
40
  conversation: 'Conversation',
37
41
  close: 'Close chat',
42
+ home: 'Back to the start',
38
43
  send: 'Send',
44
+ attach: 'Attach an image or video',
39
45
  input: 'Type a message',
40
46
  typing: 'Typing',
41
47
  toLatest: 'Jump to latest',
@@ -4,6 +4,62 @@ import { live } from 'lit/directives/live.js';
4
4
 
5
5
  /** @import { ChitUI } from '../chit-ui.js' */
6
6
 
7
+ /**
8
+ * The attach button and the file input it opens.
9
+ *
10
+ * The widget owns the picking and nothing else: it hands the chosen files to
11
+ * the consumer through `chat-attach` and forgets them. Uploading them, showing
12
+ * a preview and adding a message are all things only the consumer can do, and
13
+ * a half-done version here would be in the way of the real one.
14
+ *
15
+ * The input is reset after every pick so that choosing the same file twice in
16
+ * a row still reports the second time.
17
+ *
18
+ * @param {ChitUI} host
19
+ * @param {boolean} disabled
20
+ * @returns {import('lit').TemplateResult}
21
+ */
22
+ function renderAttach(host, disabled) {
23
+ const labels = host.currentLabels;
24
+ const input = host.currentTheme.open.input;
25
+
26
+ return html`
27
+ <button
28
+ part="attach-button"
29
+ type="button"
30
+ aria-label=${labels.attach}
31
+ title=${labels.attach}
32
+ ?disabled=${disabled}
33
+ @click=${() => host.openAttach()}
34
+ >
35
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
36
+ <path
37
+ fill="none"
38
+ stroke="currentColor"
39
+ stroke-width="2"
40
+ stroke-linecap="round"
41
+ stroke-linejoin="round"
42
+ d="M20 11.5 12.2 19.3a4.6 4.6 0 0 1-6.5-6.5l7.8-7.8a3 3 0 0 1 4.3 4.3l-7.8 7.8a1.5 1.5 0 0 1-2.1-2.1l7.1-7.1"
43
+ />
44
+ </svg>
45
+ </button>
46
+ <input
47
+ part="attach-input"
48
+ type="file"
49
+ tabindex="-1"
50
+ aria-hidden="true"
51
+ accept=${input.accept}
52
+ ?multiple=${input.multiple}
53
+ @change=${(/** @type {Event} */ event) => {
54
+ const field = /** @type {HTMLInputElement} */ (event.target);
55
+ const files = Array.from(field.files ?? []);
56
+ field.value = '';
57
+ if (files.length) host.handleAttach(files);
58
+ }}
59
+ />
60
+ `;
61
+ }
62
+
7
63
  /**
8
64
  * The composer: a textarea that grows with its content, and a send button.
9
65
  *
@@ -32,6 +88,8 @@ export function renderComposer(host) {
32
88
  }}
33
89
  >
34
90
  <slot name="composer">
91
+ ${theme.input.attach ? renderAttach(host, disabled) : nothing}
92
+
35
93
  <slot name="input-before"></slot>
36
94
 
37
95
  <textarea
@@ -45,6 +45,13 @@ export function renderMessage(host, message) {
45
45
 
46
46
  const meta = message.time || message.status;
47
47
 
48
+ // The theme says who is talking; a message overrides either half. `null` on
49
+ // the message hides what the theme set, which is how a run of replies shows
50
+ // the icon only on the first of them.
51
+ const speaker = host.currentTheme.open.speaker[message.role];
52
+ const avatar = message.avatar === undefined ? speaker.avatar : message.avatar;
53
+ const name = message.name === undefined ? speaker.name : message.name;
54
+
48
55
  return html`
49
56
  <article
50
57
  part="message message-${message.role}"
@@ -53,11 +60,13 @@ export function renderMessage(host, message) {
53
60
  ?data-streaming=${!!message.streaming}
54
61
  aria-busy=${message.streaming ? 'true' : 'false'}
55
62
  >
56
- ${message.avatar
57
- ? html`<img part="avatar" src=${message.avatar} alt="" loading="lazy" />`
58
- : nothing}
63
+ ${avatar
64
+ ? html`<img part="avatar" src=${avatar} alt="" loading="lazy" />`
65
+ : speaker.avatar
66
+ ? html`<span part="avatar" data-placeholder aria-hidden="true"></span>`
67
+ : nothing}
59
68
  <div class="body">
60
- ${message.name ? html`<span part="name">${message.name}</span>` : nothing}
69
+ ${name ? html`<span part="name">${name}</span>` : nothing}
61
70
  <div part="bubble">
62
71
  <div part="message-content" data-content=${contentField(message)}>${renderContent(host, message)}</div>
63
72
  ${message.streaming ? html`<span part="cursor" aria-hidden="true"></span>` : nothing}
@@ -15,7 +15,7 @@ import { renderComposer } from './composer.js';
15
15
  export function renderPanel(host) {
16
16
  const open = host.currentTheme.open;
17
17
  const labels = host.currentLabels;
18
- const title = open.header?.title ?? labels.panel;
18
+ const title = open.header.title ?? labels.panel;
19
19
 
20
20
  return html`
21
21
  <section
@@ -26,16 +26,67 @@ export function renderPanel(host) {
26
26
  tabindex="-1"
27
27
  @keydown=${(/** @type {KeyboardEvent} */ event) => onKeydown(host, event)}
28
28
  >
29
+ ${open.header.visible ? renderHeader(host, title) : nothing}
30
+
31
+ ${renderMessageList(host)} ${renderComposer(host)}
32
+
33
+ <slot name="footer"></slot>
34
+ </section>
35
+ `;
36
+ }
37
+
38
+ /**
39
+ * The title bar.
40
+ *
41
+ * It can be left out entirely (`open.header.visible = false`), which is what a
42
+ * widget embedded in a host that already draws its own title bar wants — LINE
43
+ * LIFF, for one — rather than two stacked titles. The dialog keeps its
44
+ * accessible name either way: that name is read, not seen.
45
+ *
46
+ * Note that hiding it takes the close button with it. Esc and the launcher
47
+ * still close the panel, and the host's own chrome usually supplies the rest.
48
+ *
49
+ * @param {ChitUI} host
50
+ * @param {string} title
51
+ * @returns {import('lit').TemplateResult}
52
+ */
53
+ function renderHeader(host, title) {
54
+ const open = host.currentTheme.open;
55
+ const labels = host.currentLabels;
56
+
57
+ return html`
29
58
  <header part="header">
30
59
  <slot name="header">
31
60
  <slot name="header-title">
32
- ${open.header?.logo
33
- ? html`<img part="header-logo" src=${open.header.logo} alt="" />`
34
- : nothing}
35
- <span part="header-heading">${title}</span>
61
+ <span part="header-title">
62
+ ${open.header.logo
63
+ ? html`<img part="header-logo" src=${open.header.logo} alt="" />`
64
+ : nothing}
65
+ <span part="header-heading">${title}</span>
66
+ </span>
36
67
  </slot>
37
68
  <span part="header-actions">
38
69
  <slot name="header-actions"></slot>
70
+ ${open.header.home
71
+ ? html`<button
72
+ part="home-button"
73
+ type="button"
74
+ aria-label=${labels.home}
75
+ title=${labels.home}
76
+ @click=${() => host.home('user')}
77
+ >
78
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
79
+ <path
80
+ fill="none"
81
+ stroke="currentColor"
82
+ stroke-width="2"
83
+ stroke-linejoin="round"
84
+ stroke-linecap="round"
85
+ d="M4 11.2 12 4l8 7.2M6.5 9.8V19h11V9.8M10 19v-4.6h4V19"
86
+ />
87
+ </svg>
88
+ </button>`
89
+ : nothing}
39
90
  <button
40
91
  part="close-button"
41
92
  type="button"
@@ -55,11 +106,6 @@ export function renderPanel(host) {
55
106
  </span>
56
107
  </slot>
57
108
  </header>
58
-
59
- ${renderMessageList(host)} ${renderComposer(host)}
60
-
61
- <slot name="footer"></slot>
62
- </section>
63
109
  `;
64
110
  }
65
111
 
@@ -55,6 +55,45 @@ export const composerStyles = css`
55
55
  font-weight: 600;
56
56
  }
57
57
 
58
+ /* Out of sight, but still clickable from the button beside it. */
59
+ [part~='attach-input'] {
60
+ display: none;
61
+ }
62
+
63
+ [part~='attach-button'] {
64
+ display: grid;
65
+ flex: none;
66
+ place-items: center;
67
+ width: 2.4em;
68
+ height: 2.4em;
69
+ padding: 0;
70
+ border: 0;
71
+ border-radius: 50%;
72
+ background: transparent;
73
+ color: var(--chit-color-system-text);
74
+ cursor: pointer;
75
+ transition: opacity 120ms ease;
76
+ }
77
+
78
+ [part~='attach-button'] svg {
79
+ width: 1.3em;
80
+ height: 1.3em;
81
+ }
82
+
83
+ [part~='attach-button']:hover:not(:disabled) {
84
+ background: color-mix(in srgb, currentColor 10%, transparent);
85
+ }
86
+
87
+ [part~='attach-button']:focus-visible {
88
+ outline: 2px solid var(--chit-color-accent);
89
+ outline-offset: 2px;
90
+ }
91
+
92
+ [part~='attach-button']:disabled {
93
+ opacity: 0.4;
94
+ cursor: default;
95
+ }
96
+
58
97
  [part~='send-button'] {
59
98
  display: grid;
60
99
  flex: none;