@dorsk/tsumikit 0.57.0 → 0.58.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 (50) hide show
  1. package/README.md +125 -10
  2. package/dist/avatar.d.ts +6 -0
  3. package/dist/avatar.js +17 -0
  4. package/dist/components/atoms/Avatar.svelte +140 -0
  5. package/dist/components/atoms/Avatar.svelte.d.ts +25 -0
  6. package/dist/components/atoms/Badge.svelte +30 -8
  7. package/dist/components/atoms/Badge.svelte.d.ts +7 -0
  8. package/dist/components/atoms/Button.svelte +72 -1
  9. package/dist/components/atoms/Button.svelte.d.ts +2 -0
  10. package/dist/components/atoms/Input.svelte +22 -4
  11. package/dist/components/atoms/Swatch.svelte +114 -0
  12. package/dist/components/atoms/Swatch.svelte.d.ts +23 -0
  13. package/dist/components/atoms/Textarea.svelte +33 -6
  14. package/dist/components/molecules/Accordion.svelte +80 -62
  15. package/dist/components/molecules/Accordion.svelte.d.ts +15 -2
  16. package/dist/components/molecules/Combobox.svelte +299 -0
  17. package/dist/components/molecules/Combobox.svelte.d.ts +108 -0
  18. package/dist/components/molecules/Composer.svelte +32 -29
  19. package/dist/components/molecules/Composer.svelte.d.ts +2 -0
  20. package/dist/components/molecules/Disclosure.svelte +155 -0
  21. package/dist/components/molecules/Disclosure.svelte.d.ts +26 -0
  22. package/dist/components/molecules/IconButton.svelte +8 -0
  23. package/dist/components/molecules/IconButton.svelte.d.ts +4 -0
  24. package/dist/components/molecules/InputGroup.svelte +159 -0
  25. package/dist/components/molecules/InputGroup.svelte.d.ts +23 -0
  26. package/dist/components/molecules/Menu.svelte +28 -19
  27. package/dist/components/molecules/Menu.svelte.d.ts +9 -1
  28. package/dist/components/molecules/OptionButton.svelte +42 -1
  29. package/dist/components/molecules/OptionButton.svelte.d.ts +8 -1
  30. package/dist/components/molecules/Popover.svelte +61 -2
  31. package/dist/components/molecules/Popover.svelte.d.ts +7 -1
  32. package/dist/components/molecules/RadioGroup.svelte +59 -7
  33. package/dist/components/molecules/RadioGroup.svelte.d.ts +4 -0
  34. package/dist/components/molecules/SplitButton.svelte +175 -0
  35. package/dist/components/molecules/SplitButton.svelte.d.ts +41 -0
  36. package/dist/components/molecules/Tabs.svelte +155 -27
  37. package/dist/components/molecules/Tabs.svelte.d.ts +16 -1
  38. package/dist/components/molecules/combobox-keyboard.d.ts +100 -0
  39. package/dist/components/molecules/combobox-keyboard.js +170 -0
  40. package/dist/components/molecules/menu-item.d.ts +11 -0
  41. package/dist/components/molecules/menu-item.js +9 -0
  42. package/dist/count.d.ts +2 -0
  43. package/dist/count.js +7 -0
  44. package/dist/floating.d.ts +4 -0
  45. package/dist/floating.js +4 -1
  46. package/dist/index.d.ts +9 -0
  47. package/dist/index.js +9 -0
  48. package/dist/input-group-context.d.ts +10 -0
  49. package/dist/input-group-context.js +8 -0
  50. package/package.json +1 -1
@@ -0,0 +1,299 @@
1
+ <script lang="ts" generics="T">
2
+ // Listbox of suggestions for a text field the consumer renders as `children`
3
+ // (an Input for plain autocomplete, a Textarea for `@`/`#` mentions). The kit
4
+ // owns the listbox, its placement, the field's combobox ARIA (applied to the
5
+ // wrapped element imperatively, since the field is not ours) and the keys;
6
+ // the consumer decides *when* it is open and what the options are.
7
+ import { tick, type Snippet } from 'svelte';
8
+ import type { HTMLAttributes } from 'svelte/elements';
9
+ import { type AnchorRect, placeAt } from '../../floating';
10
+ import { caretRect, comboboxAction } from './combobox-keyboard.js';
11
+
12
+ type FieldEl = HTMLInputElement | HTMLTextAreaElement;
13
+ type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
14
+ type CloseReason = 'escape' | 'blur' | 'select';
15
+
16
+ type Own = {
17
+ options: T[];
18
+ /** Whether the listbox shows. The kit only ever sets it to `false`. */
19
+ open?: boolean;
20
+ /** Highlighted option. */
21
+ index?: number;
22
+ /** Accessible name of the listbox. */
23
+ label: string;
24
+ /** The wrapped field; defaults to the first `input`/`textarea` in `children`. */
25
+ el?: FieldEl | null;
26
+ /** Float under the field's edge, or under the text caret. */
27
+ anchor?: 'field' | 'caret';
28
+ placement?: Placement;
29
+ gap?: number;
30
+ /** Arrow keys wrap at the ends. */
31
+ loop?: boolean;
32
+ /** Home/End jump the highlight instead of moving the caret. */
33
+ homeEnd?: boolean;
34
+ /** Keys that pick the highlighted option. */
35
+ selectOn?: readonly ('Enter' | 'Tab')[];
36
+ /** Close when focus leaves the field and the listbox. */
37
+ closeOnBlur?: boolean;
38
+ /** `role="combobox"` on the field: `auto` applies it to an `input` only,
39
+ * since ARIA in HTML forbids it on a `textarea`. */
40
+ fieldRole?: 'auto' | 'combobox' | 'none';
41
+ getKey?: (option: T, index: number) => string | number;
42
+ /** Default row text when no `option` snippet is given. */
43
+ getLabel?: (option: T) => string;
44
+ onselect?: (option: T, index: number) => void;
45
+ onclose?: (reason: CloseReason) => void;
46
+ option?: Snippet<[T, { active: boolean; index: number }]>;
47
+ /** Shown instead of rows when `options` is empty; without it the listbox hides. */
48
+ empty?: string | Snippet;
49
+ children: Snippet;
50
+ class?: string;
51
+ style?: string;
52
+ panelClass?: string;
53
+ panelStyle?: string;
54
+ };
55
+
56
+ let {
57
+ options,
58
+ open = $bindable(false),
59
+ index = $bindable(0),
60
+ label,
61
+ el = null,
62
+ anchor = 'field',
63
+ placement = 'bottom-start',
64
+ gap = 4,
65
+ loop = true,
66
+ homeEnd = false,
67
+ selectOn = ['Enter', 'Tab'],
68
+ closeOnBlur = true,
69
+ fieldRole = 'auto',
70
+ getKey,
71
+ getLabel = (o) => String(o),
72
+ onselect,
73
+ onclose,
74
+ option,
75
+ empty,
76
+ children,
77
+ class: klass = '',
78
+ style: styleProp = '',
79
+ panelClass = '',
80
+ panelStyle = '',
81
+ ...rest
82
+ }: Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own = $props();
83
+
84
+ const id = `cb-${Math.random().toString(36).slice(2, 8)}`;
85
+ const optionId = (i: number) => `${id}-o${i}`;
86
+
87
+ let wrapEl = $state<HTMLDivElement | null>(null);
88
+ let panelEl = $state<HTMLDivElement | null>(null);
89
+ const found = $derived(wrapEl?.querySelector<FieldEl>('input, textarea') ?? null);
90
+ const field = $derived(el ?? found);
91
+ const visible = $derived(open && (options.length > 0 || empty !== undefined));
92
+ const active = $derived(options.length ? Math.min(Math.max(index, 0), options.length - 1) : -1);
93
+
94
+ const FIELD_ATTRS = [
95
+ 'role',
96
+ 'aria-autocomplete',
97
+ 'aria-haspopup',
98
+ 'aria-expanded',
99
+ 'aria-controls',
100
+ 'aria-activedescendant',
101
+ ];
102
+
103
+ $effect(() => {
104
+ const f = field;
105
+ if (!f) return;
106
+ return () => {
107
+ for (const a of FIELD_ATTRS) f.removeAttribute(a);
108
+ };
109
+ });
110
+
111
+ $effect(() => {
112
+ const f = field;
113
+ if (!f) return;
114
+ const role =
115
+ fieldRole === 'combobox' || (fieldRole === 'auto' && f.tagName === 'INPUT') ? 'combobox' : null;
116
+ if (role) f.setAttribute('role', role);
117
+ else f.removeAttribute('role');
118
+ f.setAttribute('aria-autocomplete', 'list');
119
+ f.setAttribute('aria-haspopup', 'listbox');
120
+ f.setAttribute('aria-expanded', String(visible));
121
+ if (visible) f.setAttribute('aria-controls', id);
122
+ else f.removeAttribute('aria-controls');
123
+ if (visible && active >= 0) f.setAttribute('aria-activedescendant', optionId(active));
124
+ else f.removeAttribute('aria-activedescendant');
125
+ });
126
+
127
+ function anchorRect(f: FieldEl): AnchorRect {
128
+ return anchor === 'caret' ? caretRect(f) : f.getBoundingClientRect();
129
+ }
130
+
131
+ function reposition() {
132
+ const f = field;
133
+ if (!visible || !f || !panelEl) return;
134
+ const r = anchorRect(f);
135
+ panelEl.style.width = anchor === 'field' ? `${r.width}px` : '';
136
+ placeAt(r, panelEl, placement, gap);
137
+ }
138
+
139
+ $effect(() => {
140
+ const p = panelEl;
141
+ if (!visible || !p) return;
142
+ try {
143
+ p.showPopover();
144
+ } catch {}
145
+ reposition();
146
+ addEventListener('scroll', reposition, true);
147
+ addEventListener('resize', reposition);
148
+ return () => {
149
+ removeEventListener('scroll', reposition, true);
150
+ removeEventListener('resize', reposition);
151
+ try {
152
+ p.hidePopover();
153
+ } catch {}
154
+ };
155
+ });
156
+
157
+ $effect(() => {
158
+ void options.length;
159
+ void anchor;
160
+ void placement;
161
+ if (visible) tick().then(reposition);
162
+ });
163
+
164
+ $effect(() => {
165
+ if (!visible || active < 0) return;
166
+ const row = panelEl?.children[active] as HTMLElement | undefined;
167
+ row?.scrollIntoView?.({ block: 'nearest' });
168
+ });
169
+
170
+ function close(reason: CloseReason) {
171
+ if (!open) return;
172
+ open = false;
173
+ onclose?.(reason);
174
+ }
175
+
176
+ function select(i: number) {
177
+ const o = options[i];
178
+ if (o === undefined) return;
179
+ onselect?.(o, i);
180
+ close('select');
181
+ }
182
+
183
+ function onKeydown(e: KeyboardEvent) {
184
+ const action = comboboxAction(e, {
185
+ open: visible,
186
+ count: options.length,
187
+ index: active,
188
+ loop,
189
+ homeEnd,
190
+ selectOn,
191
+ });
192
+ if (!action) return;
193
+ e.preventDefault();
194
+ e.stopPropagation();
195
+ if (action.type === 'move') index = action.index;
196
+ else if (action.type === 'select') select(active);
197
+ else close('escape');
198
+ }
199
+
200
+ function onFocusOut(e: FocusEvent) {
201
+ if (!closeOnBlur) return;
202
+ const to = e.relatedTarget as Node | null;
203
+ if (to && wrapEl?.contains(to)) return;
204
+ close('blur');
205
+ }
206
+ </script>
207
+
208
+ <div
209
+ bind:this={wrapEl}
210
+ {...rest}
211
+ data-tsu="Combobox"
212
+ class="cb {klass}"
213
+ style={styleProp}
214
+ onkeydowncapture={onKeydown}
215
+ onfocusout={onFocusOut}
216
+ oninput={reposition}
217
+ onclick={reposition}
218
+ >
219
+ {@render children()}
220
+ {#if visible}
221
+ <div
222
+ bind:this={panelEl}
223
+ {id}
224
+ popover="manual"
225
+ role="listbox"
226
+ aria-label={label}
227
+ class="cb-panel {panelClass}"
228
+ class:cb-caret={anchor === 'caret'}
229
+ style={panelStyle}
230
+ >
231
+ {#each options as o, i (getKey ? getKey(o, i) : i)}
232
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
233
+ <div
234
+ id={optionId(i)}
235
+ role="option"
236
+ tabindex={-1}
237
+ aria-selected={i === active}
238
+ class="cb-option"
239
+ class:active={i === active}
240
+ onpointerdown={(e) => e.preventDefault()}
241
+ onpointerenter={() => (index = i)}
242
+ onclick={() => select(i)}
243
+ >
244
+ {#if option}{@render option(o, { active: i === active, index: i })}{:else}{getLabel(o)}{/if}
245
+ </div>
246
+ {:else}
247
+ <div class="cb-empty">
248
+ {#if typeof empty === 'string'}{empty}{:else if empty}{@render empty()}{/if}
249
+ </div>
250
+ {/each}
251
+ </div>
252
+ {/if}
253
+ </div>
254
+
255
+ <style>
256
+ .cb {
257
+ position: relative;
258
+ min-width: 0;
259
+ }
260
+ .cb-panel {
261
+ position: fixed;
262
+ margin: 0;
263
+ inset: auto;
264
+ box-sizing: border-box;
265
+ display: flex;
266
+ flex-direction: column;
267
+ gap: 1px;
268
+ min-width: var(--cb-min-width, 10rem);
269
+ max-width: calc(100vw - 2 * var(--sp-3));
270
+ max-height: var(--cb-max-height, min(16rem, 40vh));
271
+ overflow-y: auto;
272
+ padding: var(--sp-1);
273
+ background: var(--cb-bg, var(--bg-elevated));
274
+ color: var(--text);
275
+ border: 1px solid var(--cb-border, var(--border-strong));
276
+ border-radius: var(--cb-radius, var(--r-md));
277
+ box-shadow: var(--shadow-md);
278
+ }
279
+ .cb-panel.cb-caret {
280
+ max-width: min(22rem, calc(100vw - 2 * var(--sp-3)));
281
+ }
282
+ .cb-option {
283
+ padding: var(--sp-1) var(--sp-2);
284
+ border-radius: var(--r-sm);
285
+ font-size: var(--fs-sm);
286
+ cursor: pointer;
287
+ overflow: hidden;
288
+ text-overflow: ellipsis;
289
+ white-space: nowrap;
290
+ }
291
+ .cb-option.active {
292
+ background: var(--cb-active-bg, var(--bg-elevated-2));
293
+ }
294
+ .cb-empty {
295
+ padding: var(--sp-1) var(--sp-2);
296
+ font-size: var(--fs-sm);
297
+ color: var(--text-muted);
298
+ }
299
+ </style>
@@ -0,0 +1,108 @@
1
+ import { type Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ declare function $$render<T>(): {
4
+ props: Omit<HTMLAttributes<HTMLDivElement>, keyof {
5
+ options: T[];
6
+ /** Whether the listbox shows. The kit only ever sets it to `false`. */
7
+ open?: boolean;
8
+ /** Highlighted option. */
9
+ index?: number;
10
+ /** Accessible name of the listbox. */
11
+ label: string;
12
+ /** The wrapped field; defaults to the first `input`/`textarea` in `children`. */
13
+ el?: (HTMLTextAreaElement | HTMLInputElement) | null;
14
+ /** Float under the field's edge, or under the text caret. */
15
+ anchor?: "field" | "caret";
16
+ placement?: "top-start" | "top-end" | "bottom-start" | "bottom-end";
17
+ gap?: number;
18
+ /** Arrow keys wrap at the ends. */
19
+ loop?: boolean;
20
+ /** Home/End jump the highlight instead of moving the caret. */
21
+ homeEnd?: boolean;
22
+ /** Keys that pick the highlighted option. */
23
+ selectOn?: readonly ("Enter" | "Tab")[];
24
+ /** Close when focus leaves the field and the listbox. */
25
+ closeOnBlur?: boolean;
26
+ /** `role="combobox"` on the field: `auto` applies it to an `input` only,
27
+ * since ARIA in HTML forbids it on a `textarea`. */
28
+ fieldRole?: "auto" | "combobox" | "none";
29
+ getKey?: (option: T, index: number) => string | number;
30
+ /** Default row text when no `option` snippet is given. */
31
+ getLabel?: (option: T) => string;
32
+ onselect?: (option: T, index: number) => void;
33
+ onclose?: (reason: "blur" | "select" | "escape") => void;
34
+ option?: Snippet<[T, {
35
+ active: boolean;
36
+ index: number;
37
+ }]>;
38
+ /** Shown instead of rows when `options` is empty; without it the listbox hides. */
39
+ empty?: string | Snippet;
40
+ children: Snippet;
41
+ class?: string;
42
+ style?: string;
43
+ panelClass?: string;
44
+ panelStyle?: string;
45
+ }> & {
46
+ options: T[];
47
+ /** Whether the listbox shows. The kit only ever sets it to `false`. */
48
+ open?: boolean;
49
+ /** Highlighted option. */
50
+ index?: number;
51
+ /** Accessible name of the listbox. */
52
+ label: string;
53
+ /** The wrapped field; defaults to the first `input`/`textarea` in `children`. */
54
+ el?: (HTMLTextAreaElement | HTMLInputElement) | null;
55
+ /** Float under the field's edge, or under the text caret. */
56
+ anchor?: "field" | "caret";
57
+ placement?: "top-start" | "top-end" | "bottom-start" | "bottom-end";
58
+ gap?: number;
59
+ /** Arrow keys wrap at the ends. */
60
+ loop?: boolean;
61
+ /** Home/End jump the highlight instead of moving the caret. */
62
+ homeEnd?: boolean;
63
+ /** Keys that pick the highlighted option. */
64
+ selectOn?: readonly ("Enter" | "Tab")[];
65
+ /** Close when focus leaves the field and the listbox. */
66
+ closeOnBlur?: boolean;
67
+ /** `role="combobox"` on the field: `auto` applies it to an `input` only,
68
+ * since ARIA in HTML forbids it on a `textarea`. */
69
+ fieldRole?: "auto" | "combobox" | "none";
70
+ getKey?: (option: T, index: number) => string | number;
71
+ /** Default row text when no `option` snippet is given. */
72
+ getLabel?: (option: T) => string;
73
+ onselect?: (option: T, index: number) => void;
74
+ onclose?: (reason: "blur" | "select" | "escape") => void;
75
+ option?: Snippet<[T, {
76
+ active: boolean;
77
+ index: number;
78
+ }]>;
79
+ /** Shown instead of rows when `options` is empty; without it the listbox hides. */
80
+ empty?: string | Snippet;
81
+ children: Snippet;
82
+ class?: string;
83
+ style?: string;
84
+ panelClass?: string;
85
+ panelStyle?: string;
86
+ };
87
+ exports: {};
88
+ bindings: "open" | "index";
89
+ slots: {};
90
+ events: {};
91
+ };
92
+ declare class __sveltets_Render<T> {
93
+ props(): ReturnType<typeof $$render<T>>['props'];
94
+ events(): ReturnType<typeof $$render<T>>['events'];
95
+ slots(): ReturnType<typeof $$render<T>>['slots'];
96
+ bindings(): "open" | "index";
97
+ exports(): {};
98
+ }
99
+ interface $$IsomorphicComponent {
100
+ new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
101
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
102
+ } & ReturnType<__sveltets_Render<T>['exports']>;
103
+ <T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
104
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
105
+ }
106
+ declare const Combobox: $$IsomorphicComponent;
107
+ type Combobox<T> = InstanceType<typeof Combobox<T>>;
108
+ export default Combobox;
@@ -1,12 +1,14 @@
1
1
  <script lang="ts">
2
- // Chat composer: autoresizing Textarea with attach + send controls, submit
3
- // shortcuts, prompt history on ↑/↓ at the text edges, paste-to-attach and
4
- // drag-over state. Attachments render as removable chips above the field.
2
+ // Chat composer: autoresizing Textarea with attach + send controls fused into
3
+ // one InputGroup, submit shortcuts, prompt history on ↑/↓ at the text edges,
4
+ // paste-to-attach and drag-over state. Attachments render as removable chips
5
+ // above the field.
5
6
  import type { Snippet } from 'svelte';
6
7
  import Button from '../atoms/Button.svelte';
7
8
  import Textarea from '../atoms/Textarea.svelte';
8
9
  import AttachmentList from './AttachmentList.svelte';
9
10
  import FileButton from './FileButton.svelte';
11
+ import InputGroup from './InputGroup.svelte';
10
12
 
11
13
  let {
12
14
  value = $bindable(''),
@@ -22,6 +24,7 @@
22
24
  disabled = false,
23
25
  maxHeight = '40vh',
24
26
  rows = 1,
27
+ resize = 'none',
25
28
  sendLabel = 'Send',
26
29
  attachLabel = 'Attach',
27
30
  leading,
@@ -44,6 +47,8 @@
44
47
  disabled?: boolean;
45
48
  maxHeight?: string;
46
49
  rows?: number;
50
+ /** Manual drag handle on the top edge; `none` hides it. */
51
+ resize?: 'none' | 'top';
47
52
  sendLabel?: string;
48
53
  attachLabel?: string;
49
54
  leading?: Snippet;
@@ -60,6 +65,7 @@
60
65
  const fine = () => typeof matchMedia === 'function' && matchMedia('(pointer: fine)').matches;
61
66
  const mode = $derived(submitOn === 'auto' ? (fine() ? 'enter' : 'mod-enter') : submitOn);
62
67
  const canSend = $derived(!busy && !disabled && (value.trim().length > 0 || attachments.length > 0));
68
+ const hasAttach = $derived(!!onfiles || accept !== undefined);
63
69
 
64
70
  function submit() {
65
71
  if (!canSend) return;
@@ -123,6 +129,19 @@
123
129
  }
124
130
  </script>
125
131
 
132
+ {#snippet groupLeading()}
133
+ {#if leading}{@render leading()}{/if}
134
+ {#if hasAttach}
135
+ <FileButton onfiles={addFiles} {accept} multiple iconOnly label={attachLabel} variant="ghost" box="sm" {disabled} />
136
+ {/if}
137
+ {/snippet}
138
+ {#snippet groupTrailing()}
139
+ {#if trailing}{@render trailing()}{/if}
140
+ <Button variant="primary" box="sm" loading={busy} disabled={!canSend} aria-label={sendLabel} title={sendLabel} onclick={submit}>
141
+ <span aria-hidden="true">➤</span>
142
+ </Button>
143
+ {/snippet}
144
+
126
145
  <div
127
146
  data-tsu="Composer"
128
147
  class="composer {klass}"
@@ -138,11 +157,12 @@
138
157
  {ondrop}
139
158
  >
140
159
  <AttachmentList files={attachments} onremove={removeAt} />
141
- <div class="row">
142
- {#if leading}{@render leading()}{/if}
143
- {#if onfiles || accept !== undefined}
144
- <FileButton onfiles={addFiles} {accept} multiple iconOnly label={attachLabel} variant="ghost" box="md" {disabled} />
145
- {/if}
160
+ <InputGroup
161
+ align="end"
162
+ {disabled}
163
+ leading={leading || hasAttach ? groupLeading : undefined}
164
+ trailing={groupTrailing}
165
+ >
146
166
  <Textarea
147
167
  bind:value
148
168
  bind:el
@@ -150,18 +170,13 @@
150
170
  {rows}
151
171
  {maxHeight}
152
172
  {placeholder}
153
- resize="none"
154
- grow
173
+ {resize}
155
174
  aria-label={placeholder}
156
175
  disabled={disabled || busy}
157
176
  {onkeydown}
158
177
  {onpaste}
159
178
  />
160
- {#if trailing}{@render trailing()}{/if}
161
- <Button variant="primary" square loading={busy} disabled={!canSend} aria-label={sendLabel} title={sendLabel} onclick={submit}>
162
- <span aria-hidden="true">➤</span>
163
- </Button>
164
- </div>
179
+ </InputGroup>
165
180
  <span class="hint">{mode === 'enter' ? 'Enter to send · Shift+Enter for a new line' : 'Ctrl/⌘+Enter to send'}</span>
166
181
  </div>
167
182
 
@@ -170,23 +185,11 @@
170
185
  display: flex;
171
186
  flex-direction: column;
172
187
  gap: var(--sp-2);
173
- padding: var(--sp-2);
174
- border: 1px solid var(--border-strong);
175
188
  border-radius: var(--r-lg);
176
- background: var(--surface);
177
- transition: border-color 0.12s var(--ease);
178
- }
179
- .composer:focus-within {
180
- border-color: var(--accent);
181
189
  }
182
190
  .composer.dragging {
183
- border-style: dashed;
184
- border-color: var(--accent);
185
- }
186
- .row {
187
- display: flex;
188
- align-items: flex-end;
189
- gap: var(--sp-2);
191
+ outline: 2px dashed var(--accent);
192
+ outline-offset: var(--sp-1);
190
193
  }
191
194
  .hint {
192
195
  font-size: var(--fs-xs);
@@ -15,6 +15,8 @@ type $$ComponentProps = {
15
15
  disabled?: boolean;
16
16
  maxHeight?: string;
17
17
  rows?: number;
18
+ /** Manual drag handle on the top edge; `none` hides it. */
19
+ resize?: 'none' | 'top';
18
20
  sendLabel?: string;
19
21
  attachLabel?: string;
20
22
  leading?: Snippet;
@@ -0,0 +1,155 @@
1
+ <script lang="ts" module>
2
+ export type DisclosureChevron = 'start' | 'end' | false;
3
+ export interface DisclosureHeaderContext {
4
+ open: boolean;
5
+ }
6
+ </script>
7
+
8
+ <script lang="ts">
9
+ // Single collapsible: a native <button aria-expanded aria-controls> (so Enter
10
+ // and Space activate it without a keydown handler) toggling a region panel.
11
+ // `open` is bindable; an unbound `open` plus `onchange` gives controlled mode.
12
+ import type { Snippet } from 'svelte';
13
+ import type { HTMLAttributes } from 'svelte/elements';
14
+ import Icon from '../atoms/Icon.svelte';
15
+
16
+ let {
17
+ open = $bindable(false),
18
+ header,
19
+ children,
20
+ chevron = 'end',
21
+ id,
22
+ onchange,
23
+ disabled = false,
24
+ class: klass = '',
25
+ style: styleProp = '',
26
+ buttonClass = '',
27
+ panelClass = '',
28
+ ...rest
29
+ }: Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own = $props();
30
+
31
+ type Own = {
32
+ open?: boolean;
33
+ /** Rich header content; receives the live open state. */
34
+ header: Snippet<[DisclosureHeaderContext]>;
35
+ children: Snippet;
36
+ /** Chevron placement; `false` hides it. */
37
+ chevron?: DisclosureChevron;
38
+ /** Base for the button/panel ids the ARIA wiring uses. */
39
+ id?: string;
40
+ onchange?: (open: boolean) => void;
41
+ disabled?: boolean;
42
+ class?: string;
43
+ style?: string;
44
+ buttonClass?: string;
45
+ panelClass?: string;
46
+ };
47
+
48
+ const uid = $props.id();
49
+ const baseId = $derived(id ?? `disclosure-${uid}`);
50
+ const buttonId = $derived(`${baseId}-button`);
51
+ const panelId = $derived(`${baseId}-panel`);
52
+
53
+ function toggle() {
54
+ open = !open;
55
+ onchange?.(open);
56
+ }
57
+ </script>
58
+
59
+ <div
60
+ {...rest}
61
+ class="disclosure {klass}"
62
+ class:disclosure--open={open}
63
+ style={styleProp}
64
+ data-tsu="Disclosure"
65
+ >
66
+ <button
67
+ type="button"
68
+ id={buttonId}
69
+ class="disclosure__button {buttonClass}"
70
+ aria-expanded={open}
71
+ aria-controls={panelId}
72
+ {disabled}
73
+ onclick={toggle}
74
+ >
75
+ {#if chevron === 'start'}
76
+ <span class="disclosure__chevron"><Icon name="chevron-down" /></span>
77
+ {/if}
78
+ <span class="disclosure__header">{@render header({ open })}</span>
79
+ {#if chevron === 'end'}
80
+ <span class="disclosure__chevron"><Icon name="chevron-down" /></span>
81
+ {/if}
82
+ </button>
83
+ <div
84
+ id={panelId}
85
+ class="disclosure__panel {panelClass}"
86
+ role="region"
87
+ aria-labelledby={buttonId}
88
+ hidden={!open}
89
+ >
90
+ {@render children()}
91
+ </div>
92
+ </div>
93
+
94
+ <style>
95
+ .disclosure {
96
+ display: block;
97
+ }
98
+ .disclosure__button {
99
+ display: flex;
100
+ align-items: center;
101
+ justify-content: space-between;
102
+ gap: var(--sp-2);
103
+ width: 100%;
104
+ padding: var(--sp-3) var(--sp-4);
105
+ border: 0;
106
+ background: none;
107
+ color: inherit;
108
+ font: inherit;
109
+ font-weight: var(--fw-medium);
110
+ font-size: var(--fs-sm);
111
+ text-align: start;
112
+ cursor: pointer;
113
+ user-select: none;
114
+ }
115
+ .disclosure__button:hover:not(:disabled) {
116
+ background: var(--bg-elevated-2);
117
+ }
118
+ .disclosure__button:focus-visible {
119
+ outline: 2px solid var(--accent);
120
+ outline-offset: -2px;
121
+ }
122
+ .disclosure__button:disabled {
123
+ cursor: not-allowed;
124
+ opacity: 0.6;
125
+ }
126
+ .disclosure__header {
127
+ display: flex;
128
+ align-items: center;
129
+ gap: var(--sp-2);
130
+ min-width: 0;
131
+ flex: 1 1 auto;
132
+ }
133
+ .disclosure__chevron {
134
+ display: inline-flex;
135
+ flex: none;
136
+ color: var(--text-muted);
137
+ transition: transform 0.15s var(--ease);
138
+ }
139
+ .disclosure--open .disclosure__chevron {
140
+ transform: rotate(180deg);
141
+ }
142
+ @media (prefers-reduced-motion: reduce) {
143
+ .disclosure__chevron {
144
+ transition: none;
145
+ }
146
+ }
147
+ .disclosure__panel {
148
+ padding: 0 var(--sp-4) var(--sp-4);
149
+ font-size: var(--fs-sm);
150
+ color: var(--text-muted);
151
+ }
152
+ .disclosure__panel[hidden] {
153
+ display: none;
154
+ }
155
+ </style>