@dorsk/tsumikit 0.75.0 → 0.77.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.
- package/dist/components/atoms/Divider.svelte +2 -1
- package/dist/components/atoms/Input.svelte +1 -1
- package/dist/components/atoms/Input.svelte.d.ts +1 -1
- package/dist/components/atoms/Select.svelte +3 -0
- package/dist/components/atoms/Textarea.svelte +1 -1
- package/dist/components/atoms/Textarea.svelte.d.ts +1 -1
- package/dist/components/molecules/CopyButton.svelte +5 -1
- package/dist/components/molecules/Field.svelte +1 -0
- package/dist/components/molecules/FileButton.svelte +7 -1
- package/dist/components/molecules/GlyphStack.svelte +8 -0
- package/dist/components/molecules/RadioGroup.svelte +3 -2
- package/dist/components/molecules/RichSelect.svelte +592 -0
- package/dist/components/molecules/RichSelect.svelte.d.ts +68 -0
- package/dist/components/molecules/SelectButton.svelte +12 -1
- package/dist/components/molecules/SelectButton.svelte.d.ts +1 -0
- package/dist/components/molecules/Toggle.svelte +11 -1
- package/dist/components/molecules/rich-select-keyboard.d.ts +82 -0
- package/dist/components/molecules/rich-select-keyboard.js +114 -0
- package/dist/components/organisms/ConversationFeed.svelte +20 -1
- package/dist/floating.js +7 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/styles/reset.css +3 -3
- package/package.json +1 -1
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
// `size` shadows the native char-width attribute (unused in token-sized
|
|
14
14
|
// layouts) to expose a height preset instead.
|
|
15
|
-
type Props = Omit<HTMLInputAttributes, 'size'> & {
|
|
15
|
+
type Props = Omit<HTMLInputAttributes, 'size' | 'onsubmit'> & {
|
|
16
16
|
mono?: boolean;
|
|
17
17
|
size?: ControlSize;
|
|
18
18
|
/** Fill the available width of a flex/Cluster row (flex: 1) instead of
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ControlSize } from '../../size';
|
|
2
2
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
3
3
|
import { type IconName } from './Icon.svelte';
|
|
4
|
-
type Props = Omit<HTMLInputAttributes, 'size'> & {
|
|
4
|
+
type Props = Omit<HTMLInputAttributes, 'size' | 'onsubmit'> & {
|
|
5
5
|
mono?: boolean;
|
|
6
6
|
size?: ControlSize;
|
|
7
7
|
/** Fill the available width of a flex/Cluster row (flex: 1) instead of
|
|
@@ -199,6 +199,7 @@
|
|
|
199
199
|
.select-wrap.w-auto {
|
|
200
200
|
display: inline-block;
|
|
201
201
|
width: auto;
|
|
202
|
+
max-width: 100%;
|
|
202
203
|
}
|
|
203
204
|
.select-wrap.select-grow {
|
|
204
205
|
flex: 1 1 0;
|
|
@@ -253,6 +254,8 @@
|
|
|
253
254
|
}
|
|
254
255
|
.select.w-auto {
|
|
255
256
|
width: auto;
|
|
257
|
+
max-width: 100%;
|
|
258
|
+
text-overflow: ellipsis;
|
|
256
259
|
}
|
|
257
260
|
.select.embedded {
|
|
258
261
|
background: var(--select-bg, var(--bg-elevated-2));
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
import { getFieldContext, warnUnlabelled } from '../../field-context';
|
|
24
24
|
import { getInputGroupContext } from '../../input-group-context';
|
|
25
25
|
|
|
26
|
-
type Props = HTMLTextareaAttributes & {
|
|
26
|
+
type Props = Omit<HTMLTextareaAttributes, 'onsubmit'> & {
|
|
27
27
|
mono?: boolean;
|
|
28
28
|
autoresize?: boolean;
|
|
29
29
|
size?: ControlSize;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ControlSize } from '../../size';
|
|
2
2
|
import type { HTMLTextareaAttributes } from 'svelte/elements';
|
|
3
|
-
type Props = HTMLTextareaAttributes & {
|
|
3
|
+
type Props = Omit<HTMLTextareaAttributes, 'onsubmit'> & {
|
|
4
4
|
mono?: boolean;
|
|
5
5
|
autoresize?: boolean;
|
|
6
6
|
size?: ControlSize;
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
{variant}
|
|
61
61
|
{box}
|
|
62
62
|
{hitArea}
|
|
63
|
-
class="copy-btn {klass}"
|
|
63
|
+
class="copy-btn {labelShown && box !== undefined ? 'copy-labelled' : ''} {klass}"
|
|
64
64
|
sound={false}
|
|
65
65
|
onclick={copy}
|
|
66
66
|
aria-label={copied ? copiedLabel : label}
|
|
@@ -78,6 +78,10 @@
|
|
|
78
78
|
/* Both glyphs share one grid cell so the swap never changes the width. The
|
|
79
79
|
check springs in (the kit's one bounce: a rare confirmation); the copy
|
|
80
80
|
glyph fades out fast and back in over the base duration on revert. */
|
|
81
|
+
:global(.copy-btn.btn-box.copy-labelled) {
|
|
82
|
+
width: auto;
|
|
83
|
+
padding-inline: var(--sp-2);
|
|
84
|
+
}
|
|
81
85
|
.glyphs {
|
|
82
86
|
display: inline-grid;
|
|
83
87
|
place-items: center;
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
{:else if emoji}
|
|
94
94
|
<span class="emoji" aria-hidden="true">{emoji}</span>
|
|
95
95
|
{/if}
|
|
96
|
-
<span class:sr-only={onlyIcon}>{label}</span>
|
|
96
|
+
<span class="text" class:sr-only={onlyIcon}>{label}</span>
|
|
97
97
|
</label>
|
|
98
98
|
|
|
99
99
|
<style>
|
|
@@ -114,6 +114,7 @@
|
|
|
114
114
|
font-weight: var(--fw-medium);
|
|
115
115
|
font-size: var(--control-fs-md);
|
|
116
116
|
line-height: var(--lh-none);
|
|
117
|
+
max-width: 100%;
|
|
117
118
|
white-space: nowrap;
|
|
118
119
|
cursor: pointer;
|
|
119
120
|
user-select: none;
|
|
@@ -179,6 +180,11 @@
|
|
|
179
180
|
}
|
|
180
181
|
}
|
|
181
182
|
/* An emoji reads small next to an SVG glyph of the same size: one icon tier up. */
|
|
183
|
+
.text:not(.sr-only) {
|
|
184
|
+
min-width: 0;
|
|
185
|
+
overflow: hidden;
|
|
186
|
+
text-overflow: ellipsis;
|
|
187
|
+
}
|
|
182
188
|
.emoji {
|
|
183
189
|
display: inline-flex;
|
|
184
190
|
font-size: var(--icon-lg);
|
|
@@ -321,9 +321,17 @@
|
|
|
321
321
|
font-size: var(--fs-xs);
|
|
322
322
|
line-height: var(--lh-none);
|
|
323
323
|
}
|
|
324
|
+
.stacked > :global(*) :global(:focus-visible),
|
|
325
|
+
.stacked > :global(:focus-visible) {
|
|
326
|
+
outline-offset: var(--focus-ring-offset-inset);
|
|
327
|
+
}
|
|
324
328
|
.glyph-stack[data-expand] {
|
|
325
329
|
cursor: pointer;
|
|
326
330
|
}
|
|
331
|
+
/* An expandable stack is one target: presses land on the stack, not on 1rem cells. */
|
|
332
|
+
.glyph-stack[data-expand] > .cell {
|
|
333
|
+
pointer-events: none;
|
|
334
|
+
}
|
|
327
335
|
.glyph-stack[data-expand='slide'] {
|
|
328
336
|
touch-action: none;
|
|
329
337
|
user-select: none;
|
|
@@ -219,6 +219,7 @@
|
|
|
219
219
|
|
|
220
220
|
.rows {
|
|
221
221
|
gap: var(--sp-1);
|
|
222
|
+
min-width: 0;
|
|
222
223
|
}
|
|
223
224
|
.row-wrap {
|
|
224
225
|
min-width: 0;
|
|
@@ -250,8 +251,9 @@
|
|
|
250
251
|
}
|
|
251
252
|
.label-line {
|
|
252
253
|
display: flex;
|
|
254
|
+
flex-wrap: wrap;
|
|
253
255
|
align-items: baseline;
|
|
254
|
-
gap: var(--sp-2);
|
|
256
|
+
gap: var(--sp-px) var(--sp-2);
|
|
255
257
|
min-width: 0;
|
|
256
258
|
}
|
|
257
259
|
.row .label-text {
|
|
@@ -260,7 +262,6 @@
|
|
|
260
262
|
.note {
|
|
261
263
|
font-size: var(--fs-xs);
|
|
262
264
|
color: var(--text-faint);
|
|
263
|
-
white-space: nowrap;
|
|
264
265
|
}
|
|
265
266
|
.description {
|
|
266
267
|
font-size: var(--fs-xs);
|
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type RichSelectOptionContext<T> = {
|
|
3
|
+
option: T;
|
|
4
|
+
index: number;
|
|
5
|
+
selected: boolean;
|
|
6
|
+
active: boolean;
|
|
7
|
+
/** True for the copy rendered inside the closed trigger. */
|
|
8
|
+
inTrigger: boolean;
|
|
9
|
+
};
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<script lang="ts" generics="T">
|
|
13
|
+
import { tick, type Snippet } from 'svelte';
|
|
14
|
+
import Icon from '../atoms/Icon.svelte';
|
|
15
|
+
import { playSound } from '../../sound';
|
|
16
|
+
import Popover from './Popover.svelte';
|
|
17
|
+
import { createTypeahead, richSelectAction, typeaheadIndex } from './rich-select-keyboard.js';
|
|
18
|
+
import { slotForTop } from './sortable-list-order.js';
|
|
19
|
+
|
|
20
|
+
type Key = string | number;
|
|
21
|
+
|
|
22
|
+
type Props = {
|
|
23
|
+
options: readonly T[];
|
|
24
|
+
/** Key of the selected option (`getKey`). */
|
|
25
|
+
value?: Key;
|
|
26
|
+
onchange?: (value: Key, option: T) => void;
|
|
27
|
+
getKey?: (option: T) => Key;
|
|
28
|
+
/** Text used by typeahead and announcements. */
|
|
29
|
+
getLabel?: (option: T) => string;
|
|
30
|
+
/** The card, rendered in the closed trigger and for every listbox row.
|
|
31
|
+
* It sits inside the trigger button, so it must not contain controls. */
|
|
32
|
+
option: Snippet<[RichSelectOptionContext<T>]>;
|
|
33
|
+
/** Per-row actions beside the card. They never select the option. */
|
|
34
|
+
trailing?: Snippet<[{ option: T; index: number; close: () => void }]>;
|
|
35
|
+
footer?: Snippet<[{ close: () => void }]>;
|
|
36
|
+
/** Enables the drag handles and Alt+ArrowUp/Down; reorder `options` in it. */
|
|
37
|
+
onreorder?: (from: number, to: number) => void;
|
|
38
|
+
placeholder?: Snippet | string;
|
|
39
|
+
/** Accessible name of the select. */
|
|
40
|
+
label: string;
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
box?: 'sm' | 'md' | 'lg';
|
|
43
|
+
placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
|
|
44
|
+
open?: boolean;
|
|
45
|
+
/** Play UI sounds when enabled globally; false silences this instance. */
|
|
46
|
+
sound?: boolean;
|
|
47
|
+
/** Spoken after a keyboard reorder. */
|
|
48
|
+
movedMessage?: (label: string, position: number, total: number) => string;
|
|
49
|
+
class?: string;
|
|
50
|
+
style?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const fallbackKey = (o: T): Key => {
|
|
54
|
+
const r = o as { id?: Key; value?: Key; key?: Key };
|
|
55
|
+
return r?.key ?? r?.id ?? r?.value ?? String(o);
|
|
56
|
+
};
|
|
57
|
+
const fallbackLabel = (o: T): string => {
|
|
58
|
+
const r = o as { label?: string; name?: string };
|
|
59
|
+
return r?.label ?? r?.name ?? String(o);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
let {
|
|
63
|
+
options,
|
|
64
|
+
value = $bindable(),
|
|
65
|
+
onchange,
|
|
66
|
+
getKey = fallbackKey,
|
|
67
|
+
getLabel = fallbackLabel,
|
|
68
|
+
option,
|
|
69
|
+
trailing,
|
|
70
|
+
footer,
|
|
71
|
+
onreorder,
|
|
72
|
+
placeholder = 'Select…',
|
|
73
|
+
label,
|
|
74
|
+
disabled = false,
|
|
75
|
+
box = 'md',
|
|
76
|
+
placement = 'bottom-start',
|
|
77
|
+
open = $bindable(false),
|
|
78
|
+
sound = true,
|
|
79
|
+
movedMessage = (name, position, total) => `${name} moved to position ${position} of ${total}`,
|
|
80
|
+
class: klass = '',
|
|
81
|
+
style = '',
|
|
82
|
+
}: Props = $props();
|
|
83
|
+
|
|
84
|
+
const uid = Math.random().toString(36).slice(2, 8);
|
|
85
|
+
const listId = `rs-list-${uid}`;
|
|
86
|
+
const optionId = (i: number) => `rs-opt-${uid}-${i}`;
|
|
87
|
+
|
|
88
|
+
let root = $state<HTMLDivElement | null>(null);
|
|
89
|
+
let active = $state(-1);
|
|
90
|
+
let announcement = $state('');
|
|
91
|
+
let triggerWidth = $state(0);
|
|
92
|
+
let dragFrom = $state(-1);
|
|
93
|
+
let dragTo = $state(-1);
|
|
94
|
+
let dragDy = $state(0);
|
|
95
|
+
let dragHeight = 0;
|
|
96
|
+
|
|
97
|
+
const selectedIndex = $derived(options.findIndex((o) => getKey(o) === value));
|
|
98
|
+
const selectedOption = $derived(selectedIndex >= 0 ? options[selectedIndex] : undefined);
|
|
99
|
+
const typeahead = createTypeahead();
|
|
100
|
+
|
|
101
|
+
const triggerEl = () => root?.querySelector<HTMLElement>('.rs-trigger') ?? null;
|
|
102
|
+
const rowEls = () => [...(root?.querySelectorAll<HTMLElement>('.rs-row') ?? [])];
|
|
103
|
+
|
|
104
|
+
$effect(() => {
|
|
105
|
+
const el = triggerEl();
|
|
106
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
107
|
+
const ro = new ResizeObserver(() => (triggerWidth = el.offsetWidth));
|
|
108
|
+
ro.observe(el);
|
|
109
|
+
return () => ro.disconnect();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
$effect(() => {
|
|
113
|
+
triggerEl()?.setAttribute('role', 'combobox');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
$effect(() => {
|
|
117
|
+
if (!open) return;
|
|
118
|
+
if (active < 0 || active >= options.length) active = selectedIndex >= 0 ? selectedIndex : 0;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
$effect(() => {
|
|
122
|
+
if (open && active >= 0) revealActive();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Scrolls the listbox only: `scrollIntoView` on a top-layer panel also
|
|
126
|
+
// scrolls the document in Chromium.
|
|
127
|
+
function revealActive() {
|
|
128
|
+
const list = root?.querySelector<HTMLElement>('.rs-list');
|
|
129
|
+
const row = rowEls()[active];
|
|
130
|
+
if (!list || !row) return;
|
|
131
|
+
const top = row.offsetTop;
|
|
132
|
+
const bottom = top + row.offsetHeight;
|
|
133
|
+
if (top < list.scrollTop) list.scrollTop = top;
|
|
134
|
+
else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function close() {
|
|
138
|
+
open = false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function focusTrigger() {
|
|
142
|
+
triggerEl()?.focus();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function choose(index: number, closeAfter: boolean) {
|
|
146
|
+
const next = options[index];
|
|
147
|
+
if (next === undefined) return;
|
|
148
|
+
const key = getKey(next);
|
|
149
|
+
active = index;
|
|
150
|
+
if (key !== value) {
|
|
151
|
+
value = key;
|
|
152
|
+
onchange?.(key, next);
|
|
153
|
+
playSound(closeAfter ? 'click' : 'tick', sound);
|
|
154
|
+
}
|
|
155
|
+
if (closeAfter) {
|
|
156
|
+
close();
|
|
157
|
+
focusTrigger();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function reorder(from: number, to: number) {
|
|
162
|
+
if (!onreorder || from === to) return;
|
|
163
|
+
const moved = options[from];
|
|
164
|
+
onreorder(from, to);
|
|
165
|
+
active = to;
|
|
166
|
+
if (moved !== undefined) announcement = movedMessage(getLabel(moved), to + 1, options.length);
|
|
167
|
+
playSound('tick', sound);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function trailingFocusables(index: number) {
|
|
171
|
+
const row = rowEls()[index];
|
|
172
|
+
return [
|
|
173
|
+
...(row?.querySelectorAll<HTMLElement>(
|
|
174
|
+
'.rs-trailing :is(button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])):not(:disabled)'
|
|
175
|
+
) ?? []),
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function onTriggerKey(e: KeyboardEvent) {
|
|
180
|
+
const action = richSelectAction(e, {
|
|
181
|
+
open,
|
|
182
|
+
count: options.length,
|
|
183
|
+
index: open ? active : selectedIndex,
|
|
184
|
+
reorder: onreorder !== undefined,
|
|
185
|
+
trailing: open && active >= 0 && trailingFocusables(active).length > 0,
|
|
186
|
+
});
|
|
187
|
+
if (!action) return;
|
|
188
|
+
if (action.type === 'close' && e.key === 'Tab') {
|
|
189
|
+
close();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
e.stopPropagation();
|
|
194
|
+
switch (action.type) {
|
|
195
|
+
case 'open':
|
|
196
|
+
active = selectedIndex >= 0 ? selectedIndex : 0;
|
|
197
|
+
open = true;
|
|
198
|
+
break;
|
|
199
|
+
case 'close':
|
|
200
|
+
close();
|
|
201
|
+
focusTrigger();
|
|
202
|
+
break;
|
|
203
|
+
case 'select':
|
|
204
|
+
choose(active, true);
|
|
205
|
+
break;
|
|
206
|
+
case 'move':
|
|
207
|
+
active = action.index;
|
|
208
|
+
break;
|
|
209
|
+
case 'change':
|
|
210
|
+
choose(action.index, false);
|
|
211
|
+
break;
|
|
212
|
+
case 'reorder':
|
|
213
|
+
reorder(action.from, action.to);
|
|
214
|
+
break;
|
|
215
|
+
case 'trailing':
|
|
216
|
+
trailingFocusables(active)[0]?.focus();
|
|
217
|
+
break;
|
|
218
|
+
case 'type': {
|
|
219
|
+
const from = open ? active : selectedIndex;
|
|
220
|
+
const hit = typeaheadIndex(options.map(getLabel), typeahead.push(action.char), from);
|
|
221
|
+
if (hit === undefined) break;
|
|
222
|
+
if (open) active = hit;
|
|
223
|
+
else choose(hit, false);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function onPanelKey(e: KeyboardEvent, target: HTMLElement) {
|
|
230
|
+
if (!target.closest('.rs-trailing')) {
|
|
231
|
+
if (e.key === 'Escape') {
|
|
232
|
+
e.preventDefault();
|
|
233
|
+
e.stopPropagation();
|
|
234
|
+
close();
|
|
235
|
+
focusTrigger();
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const row = target.closest<HTMLElement>('.rs-row');
|
|
240
|
+
const index = row ? rowEls().indexOf(row) : -1;
|
|
241
|
+
const items = index >= 0 ? trailingFocusables(index) : [];
|
|
242
|
+
const at = items.indexOf(target);
|
|
243
|
+
const back = e.key === 'ArrowLeft' || (e.key === 'Tab' && e.shiftKey && at <= 0);
|
|
244
|
+
if (e.key === 'Escape' || back || e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
245
|
+
e.preventDefault();
|
|
246
|
+
e.stopPropagation();
|
|
247
|
+
if (e.key === 'ArrowLeft' && at > 0) {
|
|
248
|
+
items[at - 1].focus();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (e.key === 'Escape') close();
|
|
252
|
+
else if (e.key === 'ArrowUp') active = Math.max(index - 1, 0);
|
|
253
|
+
else if (e.key === 'ArrowDown') active = Math.min(index + 1, options.length - 1);
|
|
254
|
+
focusTrigger();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (e.key === 'ArrowRight' && at >= 0 && at < items.length - 1) {
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
e.stopPropagation();
|
|
260
|
+
items[at + 1].focus();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function onKeydownCapture(e: KeyboardEvent) {
|
|
265
|
+
if (disabled) return;
|
|
266
|
+
const target = e.target as HTMLElement;
|
|
267
|
+
if (target === triggerEl()) onTriggerKey(e);
|
|
268
|
+
else if (target.closest('.rs-panel')) onPanelKey(e, target);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function onGripDown(e: PointerEvent, index: number) {
|
|
272
|
+
if (!onreorder || e.button !== 0) return;
|
|
273
|
+
e.preventDefault();
|
|
274
|
+
const grip = e.currentTarget as HTMLElement;
|
|
275
|
+
grip.setPointerCapture?.(e.pointerId);
|
|
276
|
+
const rows = rowEls();
|
|
277
|
+
const startY = e.clientY;
|
|
278
|
+
const top = (rows[index]?.offsetTop ?? 0) - (rows[0]?.offsetTop ?? 0);
|
|
279
|
+
const others = rows.filter((_, i) => i !== index).map((r) => r.offsetHeight);
|
|
280
|
+
dragHeight = rows[index]?.offsetHeight ?? 0;
|
|
281
|
+
dragFrom = index;
|
|
282
|
+
dragTo = index;
|
|
283
|
+
dragDy = 0;
|
|
284
|
+
active = index;
|
|
285
|
+
const move = (ev: PointerEvent) => {
|
|
286
|
+
dragDy = ev.clientY - startY;
|
|
287
|
+
dragTo = slotForTop(top + dragDy, others);
|
|
288
|
+
};
|
|
289
|
+
const end = (ev: PointerEvent) => {
|
|
290
|
+
grip.removeEventListener('pointermove', move);
|
|
291
|
+
grip.removeEventListener('pointerup', end);
|
|
292
|
+
grip.removeEventListener('pointercancel', end);
|
|
293
|
+
const from = dragFrom;
|
|
294
|
+
const to = dragTo;
|
|
295
|
+
dragFrom = -1;
|
|
296
|
+
dragTo = -1;
|
|
297
|
+
dragDy = 0;
|
|
298
|
+
if (ev.type === 'pointerup') reorder(from, to);
|
|
299
|
+
};
|
|
300
|
+
grip.addEventListener('pointermove', move);
|
|
301
|
+
grip.addEventListener('pointerup', end);
|
|
302
|
+
grip.addEventListener('pointercancel', end);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function shiftFor(i: number) {
|
|
306
|
+
if (dragFrom < 0) return undefined;
|
|
307
|
+
if (i === dragFrom) return `0 ${dragDy}px`;
|
|
308
|
+
if (dragFrom < i && i <= dragTo) return `0 ${-dragHeight}px`;
|
|
309
|
+
if (dragTo <= i && i < dragFrom) return `0 ${dragHeight}px`;
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function onOpen() {
|
|
314
|
+
typeahead.reset();
|
|
315
|
+
await tick();
|
|
316
|
+
if (active >= 0) revealActive();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function onClose() {
|
|
320
|
+
typeahead.reset();
|
|
321
|
+
dragFrom = -1;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const panelStyle = $derived(
|
|
325
|
+
triggerWidth > 0
|
|
326
|
+
? `min-width: max(10rem, ${triggerWidth}px); max-width: min(max(22rem, ${triggerWidth}px), calc(100vw - 2 * var(--sp-3)))`
|
|
327
|
+
: ''
|
|
328
|
+
);
|
|
329
|
+
</script>
|
|
330
|
+
|
|
331
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
332
|
+
<div
|
|
333
|
+
bind:this={root}
|
|
334
|
+
data-tsu="RichSelect"
|
|
335
|
+
class="rich-select box-{box} {klass}"
|
|
336
|
+
class:is-disabled={disabled}
|
|
337
|
+
class:is-dragging={dragFrom >= 0}
|
|
338
|
+
class:is-open={open}
|
|
339
|
+
{style}
|
|
340
|
+
onkeydowncapture={onKeydownCapture}
|
|
341
|
+
>
|
|
342
|
+
<Popover
|
|
343
|
+
bind:open
|
|
344
|
+
bare
|
|
345
|
+
{label}
|
|
346
|
+
{placement}
|
|
347
|
+
{disabled}
|
|
348
|
+
{sound}
|
|
349
|
+
role="group"
|
|
350
|
+
haspopup="listbox"
|
|
351
|
+
triggerClass="rs-trigger"
|
|
352
|
+
panelClass="rs-panel"
|
|
353
|
+
{panelStyle}
|
|
354
|
+
onopen={onOpen}
|
|
355
|
+
onclose={onClose}
|
|
356
|
+
aria-controls={listId}
|
|
357
|
+
aria-activedescendant={open && active >= 0 ? optionId(active) : undefined}
|
|
358
|
+
>
|
|
359
|
+
{#snippet trigger()}
|
|
360
|
+
<span class="rs-value">
|
|
361
|
+
{#if selectedOption !== undefined}
|
|
362
|
+
{@render option({ option: selectedOption, index: selectedIndex, selected: true, active: false, inTrigger: true })}
|
|
363
|
+
{:else if typeof placeholder === 'string'}
|
|
364
|
+
<span class="rs-placeholder">{placeholder}</span>
|
|
365
|
+
{:else}
|
|
366
|
+
{@render placeholder()}
|
|
367
|
+
{/if}
|
|
368
|
+
</span>
|
|
369
|
+
<span class="rs-chevron" aria-hidden="true"><Icon name="chevron-down" /></span>
|
|
370
|
+
{/snippet}
|
|
371
|
+
<div class="rs-list" role="listbox" id={listId} aria-label={label}>
|
|
372
|
+
{#each options as opt, i (getKey(opt))}
|
|
373
|
+
{@const selected = i === selectedIndex}
|
|
374
|
+
<div
|
|
375
|
+
class="rs-row"
|
|
376
|
+
role="presentation"
|
|
377
|
+
data-active={i === active ? '' : undefined}
|
|
378
|
+
data-selected={selected ? '' : undefined}
|
|
379
|
+
data-dragging={i === dragFrom ? '' : undefined}
|
|
380
|
+
style:translate={shiftFor(i)}
|
|
381
|
+
>
|
|
382
|
+
{#if onreorder}
|
|
383
|
+
<span class="rs-grip" aria-hidden="true" onpointerdown={(e) => onGripDown(e, i)}></span>
|
|
384
|
+
{/if}
|
|
385
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
386
|
+
<div
|
|
387
|
+
class="rs-option"
|
|
388
|
+
role="option"
|
|
389
|
+
id={optionId(i)}
|
|
390
|
+
aria-selected={selected}
|
|
391
|
+
tabindex="-1"
|
|
392
|
+
onclick={() => choose(i, true)}
|
|
393
|
+
onmousedown={(e) => e.preventDefault()}
|
|
394
|
+
onpointermove={() => {
|
|
395
|
+
if (dragFrom < 0) active = i;
|
|
396
|
+
}}
|
|
397
|
+
>
|
|
398
|
+
{@render option({ option: opt, index: i, selected, active: i === active, inTrigger: false })}
|
|
399
|
+
</div>
|
|
400
|
+
{#if trailing}
|
|
401
|
+
<div class="rs-trailing">{@render trailing({ option: opt, index: i, close })}</div>
|
|
402
|
+
{/if}
|
|
403
|
+
</div>
|
|
404
|
+
{/each}
|
|
405
|
+
</div>
|
|
406
|
+
{#if footer}
|
|
407
|
+
<div class="rs-footer">{@render footer({ close })}</div>
|
|
408
|
+
{/if}
|
|
409
|
+
</Popover>
|
|
410
|
+
<span class="sr-only" aria-live="polite">{announcement}</span>
|
|
411
|
+
</div>
|
|
412
|
+
|
|
413
|
+
<style>
|
|
414
|
+
.rich-select {
|
|
415
|
+
display: block;
|
|
416
|
+
min-width: 0;
|
|
417
|
+
--rs-height: var(--control-md);
|
|
418
|
+
}
|
|
419
|
+
.rich-select.box-sm {
|
|
420
|
+
--rs-height: var(--control-sm);
|
|
421
|
+
}
|
|
422
|
+
.rich-select.box-lg {
|
|
423
|
+
--rs-height: var(--control-lg);
|
|
424
|
+
}
|
|
425
|
+
.rich-select :global(.pop-trigger.rs-trigger) {
|
|
426
|
+
display: flex;
|
|
427
|
+
align-items: center;
|
|
428
|
+
gap: var(--sp-2);
|
|
429
|
+
box-sizing: border-box;
|
|
430
|
+
width: 100%;
|
|
431
|
+
min-width: 0;
|
|
432
|
+
min-height: var(--rs-height);
|
|
433
|
+
padding: var(--sp-1) var(--sp-2) var(--sp-1) var(--sp-1);
|
|
434
|
+
border: 1px solid var(--border-strong);
|
|
435
|
+
border-radius: var(--r-md);
|
|
436
|
+
background: var(--surface);
|
|
437
|
+
color: var(--text);
|
|
438
|
+
text-align: start;
|
|
439
|
+
cursor: pointer;
|
|
440
|
+
transition:
|
|
441
|
+
border-color var(--dur-fast) var(--ease),
|
|
442
|
+
scale var(--dur-fast) var(--ease-out);
|
|
443
|
+
}
|
|
444
|
+
.rich-select :global(.pop-trigger.rs-trigger:focus-visible) {
|
|
445
|
+
outline: var(--focus-ring);
|
|
446
|
+
outline-offset: var(--focus-ring-offset);
|
|
447
|
+
}
|
|
448
|
+
.rich-select :global(.pop-trigger.rs-trigger:active:not(:disabled)) {
|
|
449
|
+
scale: var(--press-scale-lg);
|
|
450
|
+
transition-duration: var(--dur-press);
|
|
451
|
+
}
|
|
452
|
+
.rich-select :global(.pop-trigger.rs-trigger[aria-expanded='true']) {
|
|
453
|
+
border-color: var(--accent);
|
|
454
|
+
}
|
|
455
|
+
.rich-select :global(.pop-trigger.rs-trigger:disabled) {
|
|
456
|
+
opacity: var(--disabled-opacity);
|
|
457
|
+
cursor: not-allowed;
|
|
458
|
+
}
|
|
459
|
+
@media (hover: hover) and (pointer: fine) {
|
|
460
|
+
.rich-select :global(.pop-trigger.rs-trigger:hover:not(:disabled)) {
|
|
461
|
+
border-color: var(--accent);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
.rs-value {
|
|
465
|
+
flex: 1 1 auto;
|
|
466
|
+
min-width: 0;
|
|
467
|
+
overflow: hidden;
|
|
468
|
+
}
|
|
469
|
+
.rs-placeholder {
|
|
470
|
+
display: block;
|
|
471
|
+
color: var(--text-muted);
|
|
472
|
+
font-size: var(--control-fs-md);
|
|
473
|
+
line-height: var(--lh-tight);
|
|
474
|
+
overflow: hidden;
|
|
475
|
+
text-overflow: ellipsis;
|
|
476
|
+
white-space: nowrap;
|
|
477
|
+
}
|
|
478
|
+
.rs-chevron {
|
|
479
|
+
display: inline-flex;
|
|
480
|
+
flex: none;
|
|
481
|
+
color: var(--text-muted);
|
|
482
|
+
--icon-size: var(--control-icon-md);
|
|
483
|
+
transition: rotate var(--dur-fast) var(--ease-out);
|
|
484
|
+
}
|
|
485
|
+
.rich-select.is-open .rs-chevron {
|
|
486
|
+
rotate: 180deg;
|
|
487
|
+
}
|
|
488
|
+
.rich-select :global(.pop-panel.rs-panel) {
|
|
489
|
+
display: flex;
|
|
490
|
+
flex-direction: column;
|
|
491
|
+
gap: var(--sp-1);
|
|
492
|
+
box-sizing: border-box;
|
|
493
|
+
max-height: min(70vh, 28rem);
|
|
494
|
+
padding: var(--sp-1);
|
|
495
|
+
}
|
|
496
|
+
.rs-list {
|
|
497
|
+
position: relative;
|
|
498
|
+
display: flex;
|
|
499
|
+
flex-direction: column;
|
|
500
|
+
gap: var(--sp-0-5);
|
|
501
|
+
min-height: 0;
|
|
502
|
+
overflow-y: auto;
|
|
503
|
+
overscroll-behavior: contain;
|
|
504
|
+
}
|
|
505
|
+
.rs-row {
|
|
506
|
+
position: relative;
|
|
507
|
+
display: flex;
|
|
508
|
+
align-items: center;
|
|
509
|
+
gap: var(--sp-1);
|
|
510
|
+
min-height: var(--control-sm);
|
|
511
|
+
border-radius: var(--r-sm);
|
|
512
|
+
transition:
|
|
513
|
+
background-color var(--dur-fast) var(--ease),
|
|
514
|
+
translate var(--dur-fast) var(--ease-out);
|
|
515
|
+
}
|
|
516
|
+
.rs-row[data-active] {
|
|
517
|
+
background: var(--bg-elevated-2);
|
|
518
|
+
transition-duration: var(--dur-instant), var(--dur-fast);
|
|
519
|
+
}
|
|
520
|
+
.rs-row[data-selected] {
|
|
521
|
+
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
522
|
+
}
|
|
523
|
+
.rs-row[data-selected][data-active] {
|
|
524
|
+
background: color-mix(in srgb, var(--accent) 18%, var(--bg-elevated-2));
|
|
525
|
+
}
|
|
526
|
+
.rs-row[data-selected]::before {
|
|
527
|
+
content: '';
|
|
528
|
+
position: absolute;
|
|
529
|
+
inset-block: var(--sp-1);
|
|
530
|
+
inset-inline-start: 0;
|
|
531
|
+
width: 3px;
|
|
532
|
+
border-radius: var(--r-pill);
|
|
533
|
+
background: var(--accent);
|
|
534
|
+
}
|
|
535
|
+
.rs-row[data-dragging] {
|
|
536
|
+
z-index: 1;
|
|
537
|
+
box-shadow: var(--shadow-md);
|
|
538
|
+
background: var(--bg-elevated);
|
|
539
|
+
transition: none;
|
|
540
|
+
}
|
|
541
|
+
.rs-option {
|
|
542
|
+
flex: 1 1 auto;
|
|
543
|
+
min-width: 0;
|
|
544
|
+
padding: var(--sp-1);
|
|
545
|
+
border-radius: var(--r-sm);
|
|
546
|
+
cursor: pointer;
|
|
547
|
+
outline: none;
|
|
548
|
+
}
|
|
549
|
+
.rs-trailing {
|
|
550
|
+
display: flex;
|
|
551
|
+
flex: none;
|
|
552
|
+
align-items: center;
|
|
553
|
+
gap: var(--sp-1);
|
|
554
|
+
padding-inline-end: var(--sp-1);
|
|
555
|
+
}
|
|
556
|
+
.rs-grip {
|
|
557
|
+
flex: none;
|
|
558
|
+
align-self: stretch;
|
|
559
|
+
width: 24px;
|
|
560
|
+
min-height: 24px;
|
|
561
|
+
margin-inline-start: var(--sp-0-5);
|
|
562
|
+
border-radius: var(--r-sm);
|
|
563
|
+
display: grid;
|
|
564
|
+
place-items: center;
|
|
565
|
+
color: var(--text-muted);
|
|
566
|
+
cursor: grab;
|
|
567
|
+
touch-action: none;
|
|
568
|
+
opacity: 0.6;
|
|
569
|
+
transition: opacity var(--dur-fast) var(--ease);
|
|
570
|
+
}
|
|
571
|
+
.rs-grip::before {
|
|
572
|
+
content: '';
|
|
573
|
+
width: 10px;
|
|
574
|
+
height: 16px;
|
|
575
|
+
background: radial-gradient(circle, currentColor 1.5px, transparent 1.75px) 0 0 / 5px 5.333px;
|
|
576
|
+
}
|
|
577
|
+
@media (hover: hover) and (pointer: fine) {
|
|
578
|
+
.rs-grip:hover {
|
|
579
|
+
opacity: 1;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
.is-dragging .rs-grip {
|
|
583
|
+
cursor: grabbing;
|
|
584
|
+
}
|
|
585
|
+
.rs-footer {
|
|
586
|
+
flex: none;
|
|
587
|
+
display: flex;
|
|
588
|
+
flex-direction: column;
|
|
589
|
+
padding-top: var(--sp-1);
|
|
590
|
+
border-top: 1px solid var(--border);
|
|
591
|
+
}
|
|
592
|
+
</style>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export type RichSelectOptionContext<T> = {
|
|
2
|
+
option: T;
|
|
3
|
+
index: number;
|
|
4
|
+
selected: boolean;
|
|
5
|
+
active: boolean;
|
|
6
|
+
/** True for the copy rendered inside the closed trigger. */
|
|
7
|
+
inTrigger: boolean;
|
|
8
|
+
};
|
|
9
|
+
import { type Snippet } from 'svelte';
|
|
10
|
+
declare function $$render<T>(): {
|
|
11
|
+
props: {
|
|
12
|
+
options: readonly T[];
|
|
13
|
+
/** Key of the selected option (`getKey`). */
|
|
14
|
+
value?: string | number;
|
|
15
|
+
onchange?: (value: string | number, option: T) => void;
|
|
16
|
+
getKey?: (option: T) => string | number;
|
|
17
|
+
/** Text used by typeahead and announcements. */
|
|
18
|
+
getLabel?: (option: T) => string;
|
|
19
|
+
/** The card, rendered in the closed trigger and for every listbox row.
|
|
20
|
+
* It sits inside the trigger button, so it must not contain controls. */
|
|
21
|
+
option: Snippet<[RichSelectOptionContext<T>]>;
|
|
22
|
+
/** Per-row actions beside the card. They never select the option. */
|
|
23
|
+
trailing?: Snippet<[{
|
|
24
|
+
option: T;
|
|
25
|
+
index: number;
|
|
26
|
+
close: () => void;
|
|
27
|
+
}]>;
|
|
28
|
+
footer?: Snippet<[{
|
|
29
|
+
close: () => void;
|
|
30
|
+
}]>;
|
|
31
|
+
/** Enables the drag handles and Alt+ArrowUp/Down; reorder `options` in it. */
|
|
32
|
+
onreorder?: (from: number, to: number) => void;
|
|
33
|
+
placeholder?: Snippet | string;
|
|
34
|
+
/** Accessible name of the select. */
|
|
35
|
+
label: string;
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
box?: "sm" | "md" | "lg";
|
|
38
|
+
placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
|
39
|
+
open?: boolean;
|
|
40
|
+
/** Play UI sounds when enabled globally; false silences this instance. */
|
|
41
|
+
sound?: boolean;
|
|
42
|
+
/** Spoken after a keyboard reorder. */
|
|
43
|
+
movedMessage?: (label: string, position: number, total: number) => string;
|
|
44
|
+
class?: string;
|
|
45
|
+
style?: string;
|
|
46
|
+
};
|
|
47
|
+
exports: {};
|
|
48
|
+
bindings: "open" | "value";
|
|
49
|
+
slots: {};
|
|
50
|
+
events: {};
|
|
51
|
+
};
|
|
52
|
+
declare class __sveltets_Render<T> {
|
|
53
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
54
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
55
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
56
|
+
bindings(): "open" | "value";
|
|
57
|
+
exports(): {};
|
|
58
|
+
}
|
|
59
|
+
interface $$IsomorphicComponent {
|
|
60
|
+
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']>> & {
|
|
61
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
62
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
63
|
+
<T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
64
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
65
|
+
}
|
|
66
|
+
declare const RichSelect: $$IsomorphicComponent;
|
|
67
|
+
type RichSelect<T> = InstanceType<typeof RichSelect<T>>;
|
|
68
|
+
export default RichSelect;
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
shrink = true,
|
|
31
31
|
block = false,
|
|
32
32
|
sound = true,
|
|
33
|
+
disabled = false,
|
|
33
34
|
class: klass = '',
|
|
34
35
|
...rest
|
|
35
36
|
}: {
|
|
@@ -64,6 +65,7 @@
|
|
|
64
65
|
hitArea?: 'auto' | 'compact';
|
|
65
66
|
/** Play UI sounds when enabled globally; false silences this instance. */
|
|
66
67
|
sound?: boolean;
|
|
68
|
+
disabled?: boolean;
|
|
67
69
|
class?: string;
|
|
68
70
|
[key: string]: unknown;
|
|
69
71
|
} = $props();
|
|
@@ -81,6 +83,7 @@
|
|
|
81
83
|
class="select-button {klass}"
|
|
82
84
|
class:row={variant === 'row'}
|
|
83
85
|
class:active
|
|
86
|
+
class:disabled
|
|
84
87
|
data-tsu="SelectButton"
|
|
85
88
|
{...rest}
|
|
86
89
|
class:grow={grow}
|
|
@@ -98,6 +101,7 @@
|
|
|
98
101
|
aria-label={label}
|
|
99
102
|
{title}
|
|
100
103
|
{value}
|
|
104
|
+
{disabled}
|
|
101
105
|
onchange={change}
|
|
102
106
|
>
|
|
103
107
|
{#if groups}
|
|
@@ -138,18 +142,25 @@
|
|
|
138
142
|
.select-button.active {
|
|
139
143
|
color: var(--accent);
|
|
140
144
|
}
|
|
145
|
+
.select-button.disabled {
|
|
146
|
+
opacity: var(--disabled-opacity);
|
|
147
|
+
cursor: not-allowed;
|
|
148
|
+
}
|
|
141
149
|
/* The inert face takes no pointer events: mirror ghost hover, press and the
|
|
142
150
|
focus ring from the select overlaying it. */
|
|
143
151
|
.select-button:active :global(.btn-ghost) {
|
|
144
152
|
--btn-scale-dur: var(--dur-press);
|
|
145
153
|
scale: var(--press-scale-sm);
|
|
146
154
|
}
|
|
155
|
+
.select-button.disabled:active :global(.btn-ghost) {
|
|
156
|
+
scale: none;
|
|
157
|
+
}
|
|
147
158
|
.select-button:has(:focus-visible) {
|
|
148
159
|
outline: var(--focus-ring);
|
|
149
160
|
outline-offset: var(--focus-ring-offset);
|
|
150
161
|
}
|
|
151
162
|
@media (hover: hover) and (pointer: fine) {
|
|
152
|
-
.select-button:hover :global(.btn-ghost) {
|
|
163
|
+
.select-button:not(.disabled):hover :global(.btn-ghost) {
|
|
153
164
|
background: var(--bg-elevated-2);
|
|
154
165
|
}
|
|
155
166
|
}
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
class:on={pressed}
|
|
64
64
|
aria-pressed={pressed}
|
|
65
65
|
>
|
|
66
|
-
{@render children?.()}
|
|
66
|
+
<span class="label">{@render children?.()}</span>
|
|
67
67
|
</button>
|
|
68
68
|
|
|
69
69
|
<style>
|
|
@@ -106,10 +106,20 @@
|
|
|
106
106
|
font-size: var(--control-fs-md);
|
|
107
107
|
--icon-size: var(--control-icon-md);
|
|
108
108
|
}
|
|
109
|
+
.label {
|
|
110
|
+
display: contents;
|
|
111
|
+
}
|
|
109
112
|
.toggle.grow {
|
|
110
113
|
flex: 1 1 0;
|
|
111
114
|
min-width: 0;
|
|
112
115
|
}
|
|
116
|
+
.toggle.grow .label {
|
|
117
|
+
display: block;
|
|
118
|
+
min-width: 0;
|
|
119
|
+
overflow: hidden;
|
|
120
|
+
text-overflow: ellipsis;
|
|
121
|
+
text-align: center;
|
|
122
|
+
}
|
|
113
123
|
.toggle.no-shrink {
|
|
114
124
|
flex: none;
|
|
115
125
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{ type: 'open', index?: number }
|
|
3
|
+
* | { type: 'close' }
|
|
4
|
+
* | { type: 'select' }
|
|
5
|
+
* | { type: 'move', index: number }
|
|
6
|
+
* | { type: 'change', index: number }
|
|
7
|
+
* | { type: 'reorder', from: number, to: number }
|
|
8
|
+
* | { type: 'trailing' }
|
|
9
|
+
* | { type: 'type', char: string }} RichSelectAction
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Map a keydown on the trigger to what the select does with it. Closed, the
|
|
13
|
+
* arrows step the value like a native select and Enter/Space/Alt+ArrowDown open.
|
|
14
|
+
* Open, the arrows and Home/End move the active option, Enter/Space select,
|
|
15
|
+
* Escape closes, Alt+arrows reorder (when `reorder` is set), and Tab or
|
|
16
|
+
* ArrowRight reach the active option's trailing actions (when it has any).
|
|
17
|
+
* Printable characters feed the typeahead in both states. `undefined` leaves
|
|
18
|
+
* the key alone.
|
|
19
|
+
*
|
|
20
|
+
* @param {{ key: string, altKey?: boolean, ctrlKey?: boolean, metaKey?: boolean, shiftKey?: boolean }} e
|
|
21
|
+
* @param {{ open: boolean, count: number, index: number, reorder?: boolean, trailing?: boolean }} state
|
|
22
|
+
* @returns {RichSelectAction | undefined}
|
|
23
|
+
*/
|
|
24
|
+
export function richSelectAction(e: {
|
|
25
|
+
key: string;
|
|
26
|
+
altKey?: boolean;
|
|
27
|
+
ctrlKey?: boolean;
|
|
28
|
+
metaKey?: boolean;
|
|
29
|
+
shiftKey?: boolean;
|
|
30
|
+
}, { open, count, index, reorder, trailing }: {
|
|
31
|
+
open: boolean;
|
|
32
|
+
count: number;
|
|
33
|
+
index: number;
|
|
34
|
+
reorder?: boolean;
|
|
35
|
+
trailing?: boolean;
|
|
36
|
+
}): RichSelectAction | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Index of the first label, searching forward from `from` and wrapping, that
|
|
39
|
+
* starts with `query` (case- and accent-insensitive). A query of one repeated
|
|
40
|
+
* character cycles through the labels starting with it, as native selects do.
|
|
41
|
+
*
|
|
42
|
+
* @param {readonly string[]} labels
|
|
43
|
+
* @param {string} query
|
|
44
|
+
* @param {number} from index of the current option, or -1
|
|
45
|
+
* @returns {number | undefined}
|
|
46
|
+
*/
|
|
47
|
+
export function typeaheadIndex(labels: readonly string[], query: string, from: number): number | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Accumulates typed characters into a query that resets after `timeout` ms of
|
|
50
|
+
* silence.
|
|
51
|
+
*
|
|
52
|
+
* @param {number} [timeout]
|
|
53
|
+
* @param {() => number} [now]
|
|
54
|
+
*/
|
|
55
|
+
export function createTypeahead(timeout?: number, now?: () => number): {
|
|
56
|
+
/** @param {string} char */
|
|
57
|
+
push(char: string): string;
|
|
58
|
+
reset(): void;
|
|
59
|
+
};
|
|
60
|
+
export type RichSelectAction = {
|
|
61
|
+
type: "open";
|
|
62
|
+
index?: number;
|
|
63
|
+
} | {
|
|
64
|
+
type: "close";
|
|
65
|
+
} | {
|
|
66
|
+
type: "select";
|
|
67
|
+
} | {
|
|
68
|
+
type: "move";
|
|
69
|
+
index: number;
|
|
70
|
+
} | {
|
|
71
|
+
type: "change";
|
|
72
|
+
index: number;
|
|
73
|
+
} | {
|
|
74
|
+
type: "reorder";
|
|
75
|
+
from: number;
|
|
76
|
+
to: number;
|
|
77
|
+
} | {
|
|
78
|
+
type: "trailing";
|
|
79
|
+
} | {
|
|
80
|
+
type: "type";
|
|
81
|
+
char: string;
|
|
82
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{ type: 'open', index?: number }
|
|
3
|
+
* | { type: 'close' }
|
|
4
|
+
* | { type: 'select' }
|
|
5
|
+
* | { type: 'move', index: number }
|
|
6
|
+
* | { type: 'change', index: number }
|
|
7
|
+
* | { type: 'reorder', from: number, to: number }
|
|
8
|
+
* | { type: 'trailing' }
|
|
9
|
+
* | { type: 'type', char: string }} RichSelectAction
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Map a keydown on the trigger to what the select does with it. Closed, the
|
|
14
|
+
* arrows step the value like a native select and Enter/Space/Alt+ArrowDown open.
|
|
15
|
+
* Open, the arrows and Home/End move the active option, Enter/Space select,
|
|
16
|
+
* Escape closes, Alt+arrows reorder (when `reorder` is set), and Tab or
|
|
17
|
+
* ArrowRight reach the active option's trailing actions (when it has any).
|
|
18
|
+
* Printable characters feed the typeahead in both states. `undefined` leaves
|
|
19
|
+
* the key alone.
|
|
20
|
+
*
|
|
21
|
+
* @param {{ key: string, altKey?: boolean, ctrlKey?: boolean, metaKey?: boolean, shiftKey?: boolean }} e
|
|
22
|
+
* @param {{ open: boolean, count: number, index: number, reorder?: boolean, trailing?: boolean }} state
|
|
23
|
+
* @returns {RichSelectAction | undefined}
|
|
24
|
+
*/
|
|
25
|
+
export function richSelectAction(e, { open, count, index, reorder = false, trailing = false }) {
|
|
26
|
+
const { key } = e;
|
|
27
|
+
if (e.ctrlKey || e.metaKey) return undefined;
|
|
28
|
+
const last = count - 1;
|
|
29
|
+
const cur = index >= 0 && index <= last ? index : -1;
|
|
30
|
+
if (!open) {
|
|
31
|
+
if (e.altKey) return key === 'ArrowDown' ? { type: 'open' } : undefined;
|
|
32
|
+
if (key === 'Enter' || key === ' ') return { type: 'open' };
|
|
33
|
+
if (count <= 0) return undefined;
|
|
34
|
+
if (key === 'ArrowDown')
|
|
35
|
+
return { type: 'change', index: cur < 0 ? 0 : Math.min(cur + 1, last) };
|
|
36
|
+
if (key === 'ArrowUp') return { type: 'change', index: cur < 0 ? last : Math.max(cur - 1, 0) };
|
|
37
|
+
if (key === 'Home') return { type: 'change', index: 0 };
|
|
38
|
+
if (key === 'End') return { type: 'change', index: last };
|
|
39
|
+
return printable(e) ? { type: 'type', char: key } : undefined;
|
|
40
|
+
}
|
|
41
|
+
if (key === 'Escape') return { type: 'close' };
|
|
42
|
+
if (e.altKey) {
|
|
43
|
+
if (key === 'ArrowUp' && reorder && cur > 0) return { type: 'reorder', from: cur, to: cur - 1 };
|
|
44
|
+
if (key === 'ArrowDown' && reorder && cur >= 0 && cur < last)
|
|
45
|
+
return { type: 'reorder', from: cur, to: cur + 1 };
|
|
46
|
+
if (key === 'ArrowUp' && !reorder) return { type: 'select' };
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
if ((key === 'Tab' && !e.shiftKey) || key === 'ArrowRight')
|
|
50
|
+
return trailing && cur >= 0 ? { type: 'trailing' } : undefined;
|
|
51
|
+
if (key === 'Tab') return { type: 'close' };
|
|
52
|
+
if (key === 'Enter' || key === ' ') return cur >= 0 ? { type: 'select' } : { type: 'close' };
|
|
53
|
+
if (count <= 0) return undefined;
|
|
54
|
+
if (key === 'ArrowDown') return { type: 'move', index: cur < 0 ? 0 : Math.min(cur + 1, last) };
|
|
55
|
+
if (key === 'ArrowUp') return { type: 'move', index: cur < 0 ? last : Math.max(cur - 1, 0) };
|
|
56
|
+
if (key === 'Home') return { type: 'move', index: 0 };
|
|
57
|
+
if (key === 'End') return { type: 'move', index: last };
|
|
58
|
+
return printable(e) ? { type: 'type', char: key } : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {{ key: string, altKey?: boolean }} e */
|
|
62
|
+
function printable(e) {
|
|
63
|
+
return e.key.length === 1 && !e.altKey && e.key !== ' ';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Index of the first label, searching forward from `from` and wrapping, that
|
|
68
|
+
* starts with `query` (case- and accent-insensitive). A query of one repeated
|
|
69
|
+
* character cycles through the labels starting with it, as native selects do.
|
|
70
|
+
*
|
|
71
|
+
* @param {readonly string[]} labels
|
|
72
|
+
* @param {string} query
|
|
73
|
+
* @param {number} from index of the current option, or -1
|
|
74
|
+
* @returns {number | undefined}
|
|
75
|
+
*/
|
|
76
|
+
export function typeaheadIndex(labels, query, from) {
|
|
77
|
+
const n = labels.length;
|
|
78
|
+
if (n === 0 || query === '') return undefined;
|
|
79
|
+
const fold = (/** @type {string} */ s) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
|
|
80
|
+
const q = fold(query);
|
|
81
|
+
const repeated = [...q].every((c) => c === q[0]);
|
|
82
|
+
const needle = repeated ? q[0] : q;
|
|
83
|
+
const start = repeated ? from + 1 : Math.max(from, 0);
|
|
84
|
+
for (let i = 0; i < n; i += 1) {
|
|
85
|
+
const at = (((start + i) % n) + n) % n;
|
|
86
|
+
if (fold(labels[at]).startsWith(needle)) return at;
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Accumulates typed characters into a query that resets after `timeout` ms of
|
|
93
|
+
* silence.
|
|
94
|
+
*
|
|
95
|
+
* @param {number} [timeout]
|
|
96
|
+
* @param {() => number} [now]
|
|
97
|
+
*/
|
|
98
|
+
export function createTypeahead(timeout = 500, now = () => Date.now()) {
|
|
99
|
+
let query = '';
|
|
100
|
+
let at = -Infinity;
|
|
101
|
+
return {
|
|
102
|
+
/** @param {string} char */
|
|
103
|
+
push(char) {
|
|
104
|
+
const t = now();
|
|
105
|
+
query = t - at > timeout ? char : query + char;
|
|
106
|
+
at = t;
|
|
107
|
+
return query;
|
|
108
|
+
},
|
|
109
|
+
reset() {
|
|
110
|
+
query = '';
|
|
111
|
+
at = -Infinity;
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -64,6 +64,25 @@
|
|
|
64
64
|
if (stuck) pending = false;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Keyboard focus at the scroll edge would cut its ring: bring it inside the scroll padding.
|
|
68
|
+
function onfocusin(e: FocusEvent) {
|
|
69
|
+
const el = scroller;
|
|
70
|
+
const target = e.target;
|
|
71
|
+
if (!el || !(target instanceof Element) || target === el) return;
|
|
72
|
+
try {
|
|
73
|
+
if (!target.matches(':focus-visible')) return;
|
|
74
|
+
} catch {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const pad = Number.parseFloat(getComputedStyle(el).scrollPaddingTop) || 0;
|
|
78
|
+
const box = el.getBoundingClientRect();
|
|
79
|
+
const r = target.getBoundingClientRect();
|
|
80
|
+
const top = box.top + el.clientTop;
|
|
81
|
+
const bottom = top + el.clientHeight;
|
|
82
|
+
if (r.top - pad < top) el.scrollTop -= top - (r.top - pad);
|
|
83
|
+
else if (r.bottom + pad > bottom) el.scrollTop += r.bottom + pad - bottom;
|
|
84
|
+
}
|
|
85
|
+
|
|
67
86
|
$effect.pre(() => {
|
|
68
87
|
const el = scroller;
|
|
69
88
|
const first = items.length ? key(items[0]) : undefined;
|
|
@@ -106,7 +125,7 @@
|
|
|
106
125
|
</script>
|
|
107
126
|
|
|
108
127
|
<div data-tsu="ConversationFeed" class="feed-wrap {klass}" style={styleProp}>
|
|
109
|
-
<div class="feed" bind:this={scroller} {onscroll}>
|
|
128
|
+
<div class="feed" bind:this={scroller} {onscroll} {onfocusin}>
|
|
110
129
|
{#if loadMore}
|
|
111
130
|
<LoadMore pill state={loadMore} onload={onloadmore} label={loadMoreLabel} />
|
|
112
131
|
{/if}
|
package/dist/floating.js
CHANGED
|
@@ -20,9 +20,15 @@ function alignPos(align, start, size, fSize) {
|
|
|
20
20
|
export function place(trigger, floating, placement = 'bottom-start', gap = 6) {
|
|
21
21
|
placeAt(trigger.getBoundingClientRect(), floating, placement, gap);
|
|
22
22
|
}
|
|
23
|
+
// The enter transition starts the panel at `scale: var(--enter-scale)`, so a
|
|
24
|
+
// client rect taken on open is ~4% short; offset sizes ignore transforms.
|
|
25
|
+
function layoutSize(el) {
|
|
26
|
+
const { width, height } = el.getBoundingClientRect();
|
|
27
|
+
return { width: el.offsetWidth || width, height: el.offsetHeight || height };
|
|
28
|
+
}
|
|
23
29
|
/** `place` against a viewport rect instead of an element. */
|
|
24
30
|
export function placeAt(t, floating, placement = 'bottom-start', gap = 6) {
|
|
25
|
-
const f = floating
|
|
31
|
+
const f = layoutSize(floating);
|
|
26
32
|
const vw = document.documentElement.clientWidth;
|
|
27
33
|
const vh = document.documentElement.clientHeight;
|
|
28
34
|
const [side, align] = placement.split('-');
|
package/dist/index.d.ts
CHANGED
|
@@ -85,6 +85,7 @@ export { default as Prose } from './components/molecules/Prose.svelte';
|
|
|
85
85
|
export { default as PullToRefresh } from './components/molecules/PullToRefresh.svelte';
|
|
86
86
|
export { default as RadialMenu, type RadialArc, } from './components/molecules/RadialMenu.svelte';
|
|
87
87
|
export { default as RadioGroup, type RadioOption, } from './components/molecules/RadioGroup.svelte';
|
|
88
|
+
export { default as RichSelect, type RichSelectOptionContext, } from './components/molecules/RichSelect.svelte';
|
|
88
89
|
export { default as SectionHeader } from './components/molecules/SectionHeader.svelte';
|
|
89
90
|
export { default as SeekSlider } from './components/molecules/SeekSlider.svelte';
|
|
90
91
|
export { default as SegmentedControl, type SegmentOption, } from './components/molecules/SegmentedControl.svelte';
|
package/dist/index.js
CHANGED
|
@@ -92,6 +92,7 @@ export { default as Prose } from './components/molecules/Prose.svelte';
|
|
|
92
92
|
export { default as PullToRefresh } from './components/molecules/PullToRefresh.svelte';
|
|
93
93
|
export { default as RadialMenu, } from './components/molecules/RadialMenu.svelte';
|
|
94
94
|
export { default as RadioGroup, } from './components/molecules/RadioGroup.svelte';
|
|
95
|
+
export { default as RichSelect, } from './components/molecules/RichSelect.svelte';
|
|
95
96
|
export { default as SectionHeader } from './components/molecules/SectionHeader.svelte';
|
|
96
97
|
export { default as SeekSlider } from './components/molecules/SeekSlider.svelte';
|
|
97
98
|
export { default as SegmentedControl, } from './components/molecules/SegmentedControl.svelte';
|
package/dist/styles/reset.css
CHANGED
|
@@ -51,9 +51,6 @@ a {
|
|
|
51
51
|
color: var(--link);
|
|
52
52
|
text-decoration: none;
|
|
53
53
|
}
|
|
54
|
-
a:hover {
|
|
55
|
-
text-decoration: underline;
|
|
56
|
-
}
|
|
57
54
|
|
|
58
55
|
button {
|
|
59
56
|
font: inherit;
|
|
@@ -96,6 +93,9 @@ pre {
|
|
|
96
93
|
/* Slim scrollbars for mice only. On touch, a custom scrollbar replaces the
|
|
97
94
|
native overlay bars with classic space-taking ones. */
|
|
98
95
|
@media (hover: hover) and (pointer: fine) {
|
|
96
|
+
a:hover {
|
|
97
|
+
text-decoration: underline;
|
|
98
|
+
}
|
|
99
99
|
::-webkit-scrollbar {
|
|
100
100
|
width: 8px;
|
|
101
101
|
height: 8px;
|
package/package.json
CHANGED