@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.
- package/README.md +125 -10
- package/dist/avatar.d.ts +6 -0
- package/dist/avatar.js +17 -0
- package/dist/components/atoms/Avatar.svelte +140 -0
- package/dist/components/atoms/Avatar.svelte.d.ts +25 -0
- package/dist/components/atoms/Badge.svelte +30 -8
- package/dist/components/atoms/Badge.svelte.d.ts +7 -0
- package/dist/components/atoms/Button.svelte +72 -1
- package/dist/components/atoms/Button.svelte.d.ts +2 -0
- package/dist/components/atoms/Input.svelte +22 -4
- package/dist/components/atoms/Swatch.svelte +114 -0
- package/dist/components/atoms/Swatch.svelte.d.ts +23 -0
- package/dist/components/atoms/Textarea.svelte +33 -6
- package/dist/components/molecules/Accordion.svelte +80 -62
- package/dist/components/molecules/Accordion.svelte.d.ts +15 -2
- package/dist/components/molecules/Combobox.svelte +299 -0
- package/dist/components/molecules/Combobox.svelte.d.ts +108 -0
- package/dist/components/molecules/Composer.svelte +32 -29
- package/dist/components/molecules/Composer.svelte.d.ts +2 -0
- package/dist/components/molecules/Disclosure.svelte +155 -0
- package/dist/components/molecules/Disclosure.svelte.d.ts +26 -0
- package/dist/components/molecules/IconButton.svelte +8 -0
- package/dist/components/molecules/IconButton.svelte.d.ts +4 -0
- package/dist/components/molecules/InputGroup.svelte +159 -0
- package/dist/components/molecules/InputGroup.svelte.d.ts +23 -0
- package/dist/components/molecules/Menu.svelte +28 -19
- package/dist/components/molecules/Menu.svelte.d.ts +9 -1
- package/dist/components/molecules/OptionButton.svelte +42 -1
- package/dist/components/molecules/OptionButton.svelte.d.ts +8 -1
- package/dist/components/molecules/Popover.svelte +61 -2
- package/dist/components/molecules/Popover.svelte.d.ts +7 -1
- package/dist/components/molecules/RadioGroup.svelte +59 -7
- package/dist/components/molecules/RadioGroup.svelte.d.ts +4 -0
- package/dist/components/molecules/SplitButton.svelte +175 -0
- package/dist/components/molecules/SplitButton.svelte.d.ts +41 -0
- package/dist/components/molecules/Tabs.svelte +155 -27
- package/dist/components/molecules/Tabs.svelte.d.ts +16 -1
- package/dist/components/molecules/combobox-keyboard.d.ts +100 -0
- package/dist/components/molecules/combobox-keyboard.js +170 -0
- package/dist/components/molecules/menu-item.d.ts +11 -0
- package/dist/components/molecules/menu-item.js +9 -0
- package/dist/count.d.ts +2 -0
- package/dist/count.js +7 -0
- package/dist/floating.d.ts +4 -0
- package/dist/floating.js +4 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/input-group-context.d.ts +10 -0
- package/dist/input-group-context.js +8 -0
- 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
|
|
3
|
-
// shortcuts, prompt history on ↑/↓ at the text edges,
|
|
4
|
-
// drag-over state. Attachments render as removable chips
|
|
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
|
-
<
|
|
142
|
-
|
|
143
|
-
{
|
|
144
|
-
|
|
145
|
-
{
|
|
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
|
|
154
|
-
grow
|
|
173
|
+
{resize}
|
|
155
174
|
aria-label={placeholder}
|
|
156
175
|
disabled={disabled || busy}
|
|
157
176
|
{onkeydown}
|
|
158
177
|
{onpaste}
|
|
159
178
|
/>
|
|
160
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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);
|
|
@@ -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>
|