@salmexio/ui 0.2.0 → 0.3.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/dialogs/ContextMenu/ContextMenu.svelte +521 -0
- package/dist/dialogs/ContextMenu/ContextMenu.svelte.d.ts +53 -0
- package/dist/dialogs/ContextMenu/ContextMenu.svelte.d.ts.map +1 -0
- package/dist/dialogs/ContextMenu/index.d.ts +3 -0
- package/dist/dialogs/ContextMenu/index.d.ts.map +1 -0
- package/dist/dialogs/ContextMenu/index.js +1 -0
- package/dist/dialogs/index.d.ts +2 -0
- package/dist/dialogs/index.d.ts.map +1 -1
- package/dist/dialogs/index.js +1 -0
- package/dist/feedback/Alert/Alert.svelte +1 -62
- package/dist/feedback/Alert/Alert.svelte.d.ts +1 -1
- package/dist/feedback/Alert/Alert.svelte.d.ts.map +1 -1
- package/dist/forms/Select/Select.svelte +883 -0
- package/dist/forms/Select/Select.svelte.d.ts +68 -0
- package/dist/forms/Select/Select.svelte.d.ts.map +1 -0
- package/dist/forms/Select/index.d.ts +3 -0
- package/dist/forms/Select/index.d.ts.map +1 -0
- package/dist/forms/Select/index.js +1 -0
- package/dist/forms/index.d.ts +2 -0
- package/dist/forms/index.d.ts.map +1 -1
- package/dist/forms/index.js +1 -0
- package/dist/layout/Card/Card.svelte +29 -169
- package/dist/layout/Card/Card.svelte.d.ts +3 -9
- package/dist/layout/Card/Card.svelte.d.ts.map +1 -1
- package/dist/navigation/CommandPalette/CommandPalette.svelte +574 -0
- package/dist/navigation/CommandPalette/CommandPalette.svelte.d.ts +47 -0
- package/dist/navigation/CommandPalette/CommandPalette.svelte.d.ts.map +1 -0
- package/dist/navigation/CommandPalette/index.d.ts +3 -0
- package/dist/navigation/CommandPalette/index.d.ts.map +1 -0
- package/dist/navigation/CommandPalette/index.js +1 -0
- package/dist/navigation/index.d.ts +2 -0
- package/dist/navigation/index.d.ts.map +1 -1
- package/dist/navigation/index.js +1 -0
- package/dist/primitives/Badge/Badge.svelte +45 -9
- package/dist/primitives/Badge/Badge.svelte.d.ts +0 -2
- package/dist/primitives/Badge/Badge.svelte.d.ts.map +1 -1
- package/dist/primitives/Button/Button.svelte +40 -14
- package/dist/primitives/Button/Button.svelte.d.ts +1 -1
- package/dist/primitives/Button/Button.svelte.d.ts.map +1 -1
- package/dist/styles/tokens.css +4 -4
- package/dist/windowing/Window/Window.svelte +3 -3
- package/package.json +1 -1
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component Select
|
|
3
|
+
|
|
4
|
+
Win2K × Basquiat — Dropdown select with sunken trigger field, raised panel,
|
|
5
|
+
keyboard-first navigation, type-ahead search, option groups, and multi-select.
|
|
6
|
+
|
|
7
|
+
Follows WAI-ARIA APG Select-Only Combobox pattern: DOM focus stays on
|
|
8
|
+
the trigger button; visual focus in the listbox is communicated via
|
|
9
|
+
aria-activedescendant. The dropdown is portaled to document.body via
|
|
10
|
+
$effect to escape transforms/overflow on ancestor elements.
|
|
11
|
+
|
|
12
|
+
@example
|
|
13
|
+
<Select
|
|
14
|
+
label="Country"
|
|
15
|
+
options={[{ value: 'us', label: 'United States' }, { value: 'uk', label: 'United Kingdom' }]}
|
|
16
|
+
bind:value={selectedCountry}
|
|
17
|
+
/>
|
|
18
|
+
-->
|
|
19
|
+
<script lang="ts" module>
|
|
20
|
+
export interface SelectOption {
|
|
21
|
+
value: string;
|
|
22
|
+
label: string;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SelectGroup {
|
|
27
|
+
label: string;
|
|
28
|
+
options: SelectOption[];
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<script lang="ts">
|
|
33
|
+
import { cn } from '../../utils/cn.js';
|
|
34
|
+
import { Keys } from '../../utils/keyboard.js';
|
|
35
|
+
import { onMount, tick } from 'svelte';
|
|
36
|
+
|
|
37
|
+
type SelectSize = 'sm' | 'md' | 'lg';
|
|
38
|
+
|
|
39
|
+
interface Props {
|
|
40
|
+
/** Visible label (required for a11y) */
|
|
41
|
+
label: string;
|
|
42
|
+
/** Flat list of options */
|
|
43
|
+
options?: SelectOption[];
|
|
44
|
+
/** Grouped options (mutually exclusive with options) */
|
|
45
|
+
groups?: SelectGroup[];
|
|
46
|
+
/** Selected value (single mode) */
|
|
47
|
+
value?: string;
|
|
48
|
+
/** Selected values (multi mode) */
|
|
49
|
+
values?: string[];
|
|
50
|
+
/** Enable multi-select with checkboxes */
|
|
51
|
+
multiple?: boolean;
|
|
52
|
+
/** Placeholder when nothing selected */
|
|
53
|
+
placeholder?: string;
|
|
54
|
+
/** Error message */
|
|
55
|
+
error?: string;
|
|
56
|
+
/** Hint text */
|
|
57
|
+
hint?: string;
|
|
58
|
+
/** Size variant */
|
|
59
|
+
size?: SelectSize;
|
|
60
|
+
/** Disabled state */
|
|
61
|
+
disabled?: boolean;
|
|
62
|
+
/** Required field */
|
|
63
|
+
required?: boolean;
|
|
64
|
+
/** Hide label visually (still accessible) */
|
|
65
|
+
hideLabel?: boolean;
|
|
66
|
+
/** Additional CSS class */
|
|
67
|
+
class?: string;
|
|
68
|
+
/** Called when value changes (single mode) */
|
|
69
|
+
onchange?: (value: string) => void;
|
|
70
|
+
/** Called when values change (multi mode) */
|
|
71
|
+
onchangemulti?: (values: string[]) => void;
|
|
72
|
+
/** Test ID */
|
|
73
|
+
testId?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let {
|
|
77
|
+
label,
|
|
78
|
+
options = [],
|
|
79
|
+
groups = [],
|
|
80
|
+
value = $bindable(''),
|
|
81
|
+
values = $bindable([]),
|
|
82
|
+
multiple = false,
|
|
83
|
+
placeholder = 'Select an option',
|
|
84
|
+
error = '',
|
|
85
|
+
hint = '',
|
|
86
|
+
size = 'md',
|
|
87
|
+
disabled = false,
|
|
88
|
+
required = false,
|
|
89
|
+
hideLabel = false,
|
|
90
|
+
class: className = '',
|
|
91
|
+
onchange,
|
|
92
|
+
onchangemulti,
|
|
93
|
+
testId
|
|
94
|
+
}: Props = $props();
|
|
95
|
+
|
|
96
|
+
const id = `select-${Math.random().toString(36).slice(2, 9)}`;
|
|
97
|
+
const listboxId = `${id}-listbox`;
|
|
98
|
+
const labelId = `${id}-label`;
|
|
99
|
+
const errorId = `${id}-error`;
|
|
100
|
+
const hintId = `${id}-hint`;
|
|
101
|
+
|
|
102
|
+
let isOpen = $state(false);
|
|
103
|
+
let activeIndex = $state(-1);
|
|
104
|
+
let typeAhead = $state('');
|
|
105
|
+
let typeAheadTimer: ReturnType<typeof setTimeout> | undefined;
|
|
106
|
+
let triggerEl = $state<HTMLButtonElement | null>(null);
|
|
107
|
+
let listboxEl = $state<HTMLDivElement | null>(null);
|
|
108
|
+
let hasInteracted = $state(false);
|
|
109
|
+
|
|
110
|
+
// Fixed-position coordinates for the dropdown
|
|
111
|
+
let panelTop = $state(0);
|
|
112
|
+
let panelLeft = $state(0);
|
|
113
|
+
let panelWidth = $state(0);
|
|
114
|
+
let panelMaxHeight = $state(260);
|
|
115
|
+
let placeAbove = $state(false);
|
|
116
|
+
|
|
117
|
+
// Flatten all options for keyboard navigation
|
|
118
|
+
const flatOptions = $derived<SelectOption[]>(
|
|
119
|
+
groups.length > 0
|
|
120
|
+
? groups.flatMap((g) => g.options)
|
|
121
|
+
: options
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const enabledIndices = $derived(
|
|
125
|
+
flatOptions.map((o, i) => ({ i, disabled: o.disabled })).filter((x) => !x.disabled).map((x) => x.i)
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const selectedOption = $derived(
|
|
129
|
+
multiple ? null : flatOptions.find((o) => o.value === value)
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const selectedLabels = $derived(
|
|
133
|
+
multiple
|
|
134
|
+
? flatOptions.filter((o) => values.includes(o.value)).map((o) => o.label)
|
|
135
|
+
: []
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const displayText = $derived(
|
|
139
|
+
multiple
|
|
140
|
+
? selectedLabels.length > 0
|
|
141
|
+
? selectedLabels.length <= 2
|
|
142
|
+
? selectedLabels.join(', ')
|
|
143
|
+
: `${selectedLabels.length} selected`
|
|
144
|
+
: placeholder
|
|
145
|
+
: selectedOption?.label ?? placeholder
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const showError = $derived(hasInteracted && !!error);
|
|
149
|
+
|
|
150
|
+
const ariaDescribedBy = $derived(
|
|
151
|
+
[showError && errorId, hint && hintId].filter(Boolean).join(' ') || undefined
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
function positionDropdown() {
|
|
155
|
+
if (!triggerEl) return;
|
|
156
|
+
const rect = triggerEl.getBoundingClientRect();
|
|
157
|
+
const viewportH = window.innerHeight;
|
|
158
|
+
const spaceBelow = viewportH - rect.bottom;
|
|
159
|
+
const spaceAbove = rect.top;
|
|
160
|
+
const maxH = 260;
|
|
161
|
+
|
|
162
|
+
// Prefer below; flip above if not enough room below but more room above
|
|
163
|
+
placeAbove = spaceBelow < Math.min(maxH, 150) && spaceAbove > spaceBelow;
|
|
164
|
+
|
|
165
|
+
panelLeft = rect.left;
|
|
166
|
+
panelWidth = rect.width;
|
|
167
|
+
|
|
168
|
+
if (placeAbove) {
|
|
169
|
+
panelMaxHeight = Math.min(maxH, spaceAbove - 8);
|
|
170
|
+
panelTop = rect.top - 2; // 2px gap
|
|
171
|
+
} else {
|
|
172
|
+
panelMaxHeight = Math.min(maxH, spaceBelow - 8);
|
|
173
|
+
panelTop = rect.bottom + 2; // 2px gap
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function openDropdown() {
|
|
178
|
+
if (disabled) return;
|
|
179
|
+
isOpen = true;
|
|
180
|
+
// Set active to current value or first enabled
|
|
181
|
+
const currentIdx = flatOptions.findIndex((o) => o.value === value);
|
|
182
|
+
activeIndex = currentIdx >= 0 ? currentIdx : (enabledIndices[0] ?? -1);
|
|
183
|
+
|
|
184
|
+
positionDropdown();
|
|
185
|
+
|
|
186
|
+
// After render, scroll active option into view within the listbox
|
|
187
|
+
tick().then(() => scrollActiveIntoView());
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function closeDropdown() {
|
|
191
|
+
isOpen = false;
|
|
192
|
+
activeIndex = -1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function toggle() {
|
|
196
|
+
if (isOpen) closeDropdown();
|
|
197
|
+
else openDropdown();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function selectOption(opt: SelectOption) {
|
|
201
|
+
if (opt.disabled) return;
|
|
202
|
+
if (multiple) {
|
|
203
|
+
const next = values.includes(opt.value)
|
|
204
|
+
? values.filter((v) => v !== opt.value)
|
|
205
|
+
: [...values, opt.value];
|
|
206
|
+
values = next;
|
|
207
|
+
onchangemulti?.(next);
|
|
208
|
+
} else {
|
|
209
|
+
value = opt.value;
|
|
210
|
+
onchange?.(opt.value);
|
|
211
|
+
closeDropdown();
|
|
212
|
+
}
|
|
213
|
+
hasInteracted = true;
|
|
214
|
+
// Focus stays on trigger per APG pattern
|
|
215
|
+
triggerEl?.focus();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function scrollActiveIntoView() {
|
|
219
|
+
if (!listboxEl || activeIndex < 0) return;
|
|
220
|
+
const active = listboxEl.querySelector(`[data-option-index="${activeIndex}"]`) as HTMLElement;
|
|
221
|
+
if (!active) return;
|
|
222
|
+
// Manual scroll math — only scrolls the listbox, never the page
|
|
223
|
+
const listTop = listboxEl.scrollTop;
|
|
224
|
+
const listHeight = listboxEl.clientHeight;
|
|
225
|
+
const elTop = active.offsetTop;
|
|
226
|
+
const elHeight = active.offsetHeight;
|
|
227
|
+
if (elTop < listTop) {
|
|
228
|
+
listboxEl.scrollTop = elTop;
|
|
229
|
+
} else if (elTop + elHeight > listTop + listHeight) {
|
|
230
|
+
listboxEl.scrollTop = elTop + elHeight - listHeight;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function navigateUp() {
|
|
235
|
+
const pos = enabledIndices.indexOf(activeIndex);
|
|
236
|
+
if (pos > 0) {
|
|
237
|
+
activeIndex = enabledIndices[pos - 1];
|
|
238
|
+
scrollActiveIntoView();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function navigateDown() {
|
|
243
|
+
const pos = enabledIndices.indexOf(activeIndex);
|
|
244
|
+
if (pos < enabledIndices.length - 1) {
|
|
245
|
+
activeIndex = enabledIndices[pos + 1];
|
|
246
|
+
scrollActiveIntoView();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function handleTypeAhead(char: string) {
|
|
251
|
+
typeAhead += char.toLowerCase();
|
|
252
|
+
clearTimeout(typeAheadTimer);
|
|
253
|
+
typeAheadTimer = setTimeout(() => (typeAhead = ''), 500);
|
|
254
|
+
|
|
255
|
+
// If all chars are the same, cycle through matches starting with that char
|
|
256
|
+
const allSame = typeAhead.split('').every((c) => c === typeAhead[0]);
|
|
257
|
+
if (allSame && typeAhead.length > 1) {
|
|
258
|
+
const singleChar = typeAhead[0];
|
|
259
|
+
const matches = enabledIndices.filter((i) =>
|
|
260
|
+
flatOptions[i].label.toLowerCase().startsWith(singleChar)
|
|
261
|
+
);
|
|
262
|
+
if (matches.length > 0) {
|
|
263
|
+
const currentMatchPos = matches.indexOf(activeIndex);
|
|
264
|
+
const nextPos = (currentMatchPos + 1) % matches.length;
|
|
265
|
+
activeIndex = matches[nextPos];
|
|
266
|
+
scrollActiveIntoView();
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
// Multi-char prefix search
|
|
270
|
+
const match = enabledIndices.find((i) =>
|
|
271
|
+
flatOptions[i].label.toLowerCase().startsWith(typeAhead)
|
|
272
|
+
);
|
|
273
|
+
if (match !== undefined) {
|
|
274
|
+
activeIndex = match;
|
|
275
|
+
scrollActiveIntoView();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Single unified keydown handler on the trigger (DOM focus always stays here)
|
|
281
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
282
|
+
if (!isOpen) {
|
|
283
|
+
// --- CLOSED STATE ---
|
|
284
|
+
switch (e.key) {
|
|
285
|
+
case Keys.Enter:
|
|
286
|
+
case Keys.Space:
|
|
287
|
+
case Keys.ArrowDown:
|
|
288
|
+
e.preventDefault();
|
|
289
|
+
openDropdown();
|
|
290
|
+
return;
|
|
291
|
+
case Keys.ArrowUp:
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
openDropdown();
|
|
294
|
+
// Jump to last enabled
|
|
295
|
+
activeIndex = enabledIndices[enabledIndices.length - 1] ?? -1;
|
|
296
|
+
tick().then(() => scrollActiveIntoView());
|
|
297
|
+
return;
|
|
298
|
+
case Keys.Home:
|
|
299
|
+
e.preventDefault();
|
|
300
|
+
openDropdown();
|
|
301
|
+
activeIndex = enabledIndices[0] ?? -1;
|
|
302
|
+
tick().then(() => scrollActiveIntoView());
|
|
303
|
+
return;
|
|
304
|
+
case Keys.End:
|
|
305
|
+
e.preventDefault();
|
|
306
|
+
openDropdown();
|
|
307
|
+
activeIndex = enabledIndices[enabledIndices.length - 1] ?? -1;
|
|
308
|
+
tick().then(() => scrollActiveIntoView());
|
|
309
|
+
return;
|
|
310
|
+
default:
|
|
311
|
+
// Type-ahead opens the dropdown
|
|
312
|
+
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
|
313
|
+
e.preventDefault();
|
|
314
|
+
openDropdown();
|
|
315
|
+
handleTypeAhead(e.key);
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// --- OPEN STATE ---
|
|
322
|
+
switch (e.key) {
|
|
323
|
+
case Keys.ArrowDown:
|
|
324
|
+
e.preventDefault();
|
|
325
|
+
navigateDown();
|
|
326
|
+
break;
|
|
327
|
+
case Keys.ArrowUp:
|
|
328
|
+
e.preventDefault();
|
|
329
|
+
navigateUp();
|
|
330
|
+
break;
|
|
331
|
+
case Keys.Home:
|
|
332
|
+
e.preventDefault();
|
|
333
|
+
activeIndex = enabledIndices[0] ?? -1;
|
|
334
|
+
scrollActiveIntoView();
|
|
335
|
+
break;
|
|
336
|
+
case Keys.End:
|
|
337
|
+
e.preventDefault();
|
|
338
|
+
activeIndex = enabledIndices[enabledIndices.length - 1] ?? -1;
|
|
339
|
+
scrollActiveIntoView();
|
|
340
|
+
break;
|
|
341
|
+
case Keys.Enter:
|
|
342
|
+
case Keys.Space:
|
|
343
|
+
e.preventDefault();
|
|
344
|
+
if (activeIndex >= 0) selectOption(flatOptions[activeIndex]);
|
|
345
|
+
break;
|
|
346
|
+
case Keys.Escape:
|
|
347
|
+
e.preventDefault();
|
|
348
|
+
closeDropdown();
|
|
349
|
+
break;
|
|
350
|
+
case Keys.Tab:
|
|
351
|
+
// Per APG: Tab selects current option and closes
|
|
352
|
+
if (activeIndex >= 0 && !multiple) {
|
|
353
|
+
selectOption(flatOptions[activeIndex]);
|
|
354
|
+
} else {
|
|
355
|
+
closeDropdown();
|
|
356
|
+
}
|
|
357
|
+
break;
|
|
358
|
+
default:
|
|
359
|
+
// Type-ahead search while open
|
|
360
|
+
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
|
361
|
+
e.preventDefault();
|
|
362
|
+
handleTypeAhead(e.key);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function handleOptionMouseDown(e: MouseEvent, opt: SelectOption) {
|
|
368
|
+
// preventDefault keeps focus on trigger (crucial for the combobox pattern)
|
|
369
|
+
e.preventDefault();
|
|
370
|
+
selectOption(opt);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function handleOptionMouseEnter(index: number, opt: SelectOption) {
|
|
374
|
+
if (!opt.disabled) activeIndex = index;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function handleClickOutside(e: MouseEvent) {
|
|
378
|
+
const target = e.target as Node;
|
|
379
|
+
if (triggerEl?.contains(target)) return;
|
|
380
|
+
if (listboxEl?.contains(target)) return;
|
|
381
|
+
closeDropdown();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function getOptionId(index: number) {
|
|
385
|
+
return `${id}-option-${index}`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function getGlobalIndex(groupIdx: number, optIdx: number): number {
|
|
389
|
+
let offset = 0;
|
|
390
|
+
for (let g = 0; g < groupIdx; g++) {
|
|
391
|
+
offset += groups[g].options.length;
|
|
392
|
+
}
|
|
393
|
+
return offset + optIdx;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Reposition on scroll/resize when open
|
|
397
|
+
function handleReposition() {
|
|
398
|
+
if (isOpen) positionDropdown();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Portal: move listbox DOM node to document.body to escape transform/overflow ancestors
|
|
402
|
+
$effect(() => {
|
|
403
|
+
if (listboxEl && isOpen) {
|
|
404
|
+
document.body.appendChild(listboxEl);
|
|
405
|
+
return () => {
|
|
406
|
+
// Svelte will clean up the node; if still in body, remove it
|
|
407
|
+
if (listboxEl?.parentNode === document.body) {
|
|
408
|
+
document.body.removeChild(listboxEl);
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
onMount(() => {
|
|
415
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
416
|
+
window.addEventListener('scroll', handleReposition, true);
|
|
417
|
+
window.addEventListener('resize', handleReposition);
|
|
418
|
+
|
|
419
|
+
return () => {
|
|
420
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
421
|
+
window.removeEventListener('scroll', handleReposition, true);
|
|
422
|
+
window.removeEventListener('resize', handleReposition);
|
|
423
|
+
clearTimeout(typeAheadTimer);
|
|
424
|
+
// Clean up portaled node if still in body
|
|
425
|
+
if (listboxEl?.parentNode === document.body) {
|
|
426
|
+
document.body.removeChild(listboxEl);
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
});
|
|
430
|
+
</script>
|
|
431
|
+
|
|
432
|
+
<div
|
|
433
|
+
class={cn('salmex-select-wrapper', `salmex-select-${size}`, className)}
|
|
434
|
+
data-testid={testId}
|
|
435
|
+
>
|
|
436
|
+
<!-- Label -->
|
|
437
|
+
<label
|
|
438
|
+
id={labelId}
|
|
439
|
+
for={id}
|
|
440
|
+
class={cn('salmex-select-label', hideLabel && 'salmex-sr-only')}
|
|
441
|
+
>
|
|
442
|
+
{label}
|
|
443
|
+
{#if required}<span class="salmex-select-required" aria-hidden="true">*</span>{/if}
|
|
444
|
+
</label>
|
|
445
|
+
|
|
446
|
+
<!-- Trigger button — DOM focus always stays here -->
|
|
447
|
+
<button
|
|
448
|
+
bind:this={triggerEl}
|
|
449
|
+
{id}
|
|
450
|
+
type="button"
|
|
451
|
+
class={cn(
|
|
452
|
+
'salmex-select-trigger',
|
|
453
|
+
isOpen && 'salmex-select-trigger-open',
|
|
454
|
+
showError && 'salmex-select-trigger-error',
|
|
455
|
+
disabled && 'salmex-select-trigger-disabled'
|
|
456
|
+
)}
|
|
457
|
+
role="combobox"
|
|
458
|
+
aria-expanded={isOpen}
|
|
459
|
+
aria-haspopup="listbox"
|
|
460
|
+
aria-controls={isOpen ? listboxId : undefined}
|
|
461
|
+
aria-activedescendant={isOpen && activeIndex >= 0 ? getOptionId(activeIndex) : undefined}
|
|
462
|
+
aria-labelledby={labelId}
|
|
463
|
+
aria-describedby={ariaDescribedBy}
|
|
464
|
+
aria-required={required}
|
|
465
|
+
aria-invalid={showError}
|
|
466
|
+
{disabled}
|
|
467
|
+
onclick={toggle}
|
|
468
|
+
onkeydown={handleKeydown}
|
|
469
|
+
>
|
|
470
|
+
<span class="salmex-select-value" class:salmex-select-placeholder={!selectedOption && selectedLabels.length === 0}>
|
|
471
|
+
{displayText}
|
|
472
|
+
</span>
|
|
473
|
+
<span class="salmex-select-chevron" aria-hidden="true">
|
|
474
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
475
|
+
<path d="M3 4.5L6 7.5L9 4.5" />
|
|
476
|
+
</svg>
|
|
477
|
+
</span>
|
|
478
|
+
</button>
|
|
479
|
+
|
|
480
|
+
<!-- Footer: error / hint -->
|
|
481
|
+
<div class="salmex-select-footer">
|
|
482
|
+
{#if showError}
|
|
483
|
+
<p id={errorId} class="salmex-select-error" role="alert" aria-live="assertive">{error}</p>
|
|
484
|
+
{:else if hint}
|
|
485
|
+
<p id={hintId} class="salmex-select-hint">{hint}</p>
|
|
486
|
+
{/if}
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
|
|
490
|
+
<!-- Dropdown panel — fixed positioning to escape overflow/stacking contexts -->
|
|
491
|
+
{#if isOpen}
|
|
492
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
493
|
+
<div
|
|
494
|
+
bind:this={listboxEl}
|
|
495
|
+
id={listboxId}
|
|
496
|
+
class="salmex-select-panel"
|
|
497
|
+
style="position:fixed;left:{panelLeft}px;{placeAbove ? `bottom:${window.innerHeight - panelTop}px` : `top:${panelTop}px`};width:{panelWidth}px;max-height:{panelMaxHeight}px;"
|
|
498
|
+
role="listbox"
|
|
499
|
+
aria-labelledby={labelId}
|
|
500
|
+
aria-multiselectable={multiple || undefined}
|
|
501
|
+
tabindex="-1"
|
|
502
|
+
onmousedown={(e) => e.preventDefault()}
|
|
503
|
+
>
|
|
504
|
+
{#if groups.length > 0}
|
|
505
|
+
{#each groups as group, gi}
|
|
506
|
+
<div role="group" aria-label={group.label}>
|
|
507
|
+
<div class="salmex-select-group-label">{group.label}</div>
|
|
508
|
+
{#each group.options as opt, oi}
|
|
509
|
+
{@const globalIdx = getGlobalIndex(gi, oi)}
|
|
510
|
+
{@const isActive = globalIdx === activeIndex}
|
|
511
|
+
{@const isSelected = multiple ? values.includes(opt.value) : opt.value === value}
|
|
512
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
513
|
+
<div
|
|
514
|
+
id={getOptionId(globalIdx)}
|
|
515
|
+
class={cn(
|
|
516
|
+
'salmex-select-option',
|
|
517
|
+
isActive && 'salmex-select-option-active',
|
|
518
|
+
isSelected && 'salmex-select-option-selected',
|
|
519
|
+
opt.disabled && 'salmex-select-option-disabled'
|
|
520
|
+
)}
|
|
521
|
+
role="option"
|
|
522
|
+
tabindex="-1"
|
|
523
|
+
aria-selected={isSelected}
|
|
524
|
+
aria-disabled={opt.disabled || undefined}
|
|
525
|
+
data-option-index={globalIdx}
|
|
526
|
+
onmouseenter={() => handleOptionMouseEnter(globalIdx, opt)}
|
|
527
|
+
onmousedown={(e) => handleOptionMouseDown(e, opt)}
|
|
528
|
+
>
|
|
529
|
+
{#if multiple}
|
|
530
|
+
<span class="salmex-select-check" aria-hidden="true">
|
|
531
|
+
{#if isSelected}
|
|
532
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M2.5 6L5 8.5L9.5 3.5" /></svg>
|
|
533
|
+
{/if}
|
|
534
|
+
</span>
|
|
535
|
+
{/if}
|
|
536
|
+
<span class="salmex-select-option-label">{opt.label}</span>
|
|
537
|
+
{#if !multiple && isSelected}
|
|
538
|
+
<span class="salmex-select-checkmark" aria-hidden="true">
|
|
539
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M2.5 6L5 8.5L9.5 3.5" /></svg>
|
|
540
|
+
</span>
|
|
541
|
+
{/if}
|
|
542
|
+
</div>
|
|
543
|
+
{/each}
|
|
544
|
+
</div>
|
|
545
|
+
{/each}
|
|
546
|
+
{:else}
|
|
547
|
+
{#each flatOptions as opt, i}
|
|
548
|
+
{@const isActive = i === activeIndex}
|
|
549
|
+
{@const isSelected = multiple ? values.includes(opt.value) : opt.value === value}
|
|
550
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
551
|
+
<div
|
|
552
|
+
id={getOptionId(i)}
|
|
553
|
+
class={cn(
|
|
554
|
+
'salmex-select-option',
|
|
555
|
+
isActive && 'salmex-select-option-active',
|
|
556
|
+
isSelected && 'salmex-select-option-selected',
|
|
557
|
+
opt.disabled && 'salmex-select-option-disabled'
|
|
558
|
+
)}
|
|
559
|
+
role="option"
|
|
560
|
+
tabindex="-1"
|
|
561
|
+
aria-selected={isSelected}
|
|
562
|
+
aria-disabled={opt.disabled || undefined}
|
|
563
|
+
data-option-index={i}
|
|
564
|
+
onmouseenter={() => handleOptionMouseEnter(i, opt)}
|
|
565
|
+
onmousedown={(e) => handleOptionMouseDown(e, opt)}
|
|
566
|
+
>
|
|
567
|
+
{#if multiple}
|
|
568
|
+
<span class="salmex-select-check" aria-hidden="true">
|
|
569
|
+
{#if isSelected}
|
|
570
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M2.5 6L5 8.5L9.5 3.5" /></svg>
|
|
571
|
+
{/if}
|
|
572
|
+
</span>
|
|
573
|
+
{/if}
|
|
574
|
+
<span class="salmex-select-option-label">{opt.label}</span>
|
|
575
|
+
{#if !multiple && isSelected}
|
|
576
|
+
<span class="salmex-select-checkmark" aria-hidden="true">
|
|
577
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M2.5 6L5 8.5L9.5 3.5" /></svg>
|
|
578
|
+
</span>
|
|
579
|
+
{/if}
|
|
580
|
+
</div>
|
|
581
|
+
{/each}
|
|
582
|
+
{/if}
|
|
583
|
+
</div>
|
|
584
|
+
{/if}
|
|
585
|
+
|
|
586
|
+
<style>
|
|
587
|
+
/* ========================================
|
|
588
|
+
WRAPPER
|
|
589
|
+
======================================== */
|
|
590
|
+
.salmex-select-wrapper {
|
|
591
|
+
display: flex;
|
|
592
|
+
flex-direction: column;
|
|
593
|
+
gap: var(--salmex-space-1);
|
|
594
|
+
font-family: var(--salmex-font-system);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/* ========================================
|
|
598
|
+
LABEL
|
|
599
|
+
======================================== */
|
|
600
|
+
.salmex-select-label {
|
|
601
|
+
font-size: var(--salmex-font-size-sm);
|
|
602
|
+
font-weight: 700;
|
|
603
|
+
text-transform: uppercase;
|
|
604
|
+
letter-spacing: 0.3px;
|
|
605
|
+
color: rgb(var(--salmex-text-primary));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
.salmex-select-required {
|
|
609
|
+
color: rgb(var(--salmex-street-red));
|
|
610
|
+
margin-left: 2px;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
.salmex-sr-only {
|
|
614
|
+
position: absolute;
|
|
615
|
+
width: 1px;
|
|
616
|
+
height: 1px;
|
|
617
|
+
padding: 0;
|
|
618
|
+
margin: -1px;
|
|
619
|
+
overflow: hidden;
|
|
620
|
+
clip: rect(0, 0, 0, 0);
|
|
621
|
+
white-space: nowrap;
|
|
622
|
+
border: 0;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/* ========================================
|
|
626
|
+
TRIGGER — Sunken field matching TextInput
|
|
627
|
+
======================================== */
|
|
628
|
+
.salmex-select-trigger {
|
|
629
|
+
display: flex;
|
|
630
|
+
align-items: center;
|
|
631
|
+
justify-content: space-between;
|
|
632
|
+
gap: var(--salmex-space-2);
|
|
633
|
+
width: 100%;
|
|
634
|
+
border: 2px solid rgb(var(--salmex-border-dark));
|
|
635
|
+
background: rgb(var(--salmex-bg-primary));
|
|
636
|
+
color: rgb(var(--salmex-text-primary));
|
|
637
|
+
font-family: var(--salmex-font-system);
|
|
638
|
+
font-weight: 600;
|
|
639
|
+
cursor: pointer;
|
|
640
|
+
text-align: left;
|
|
641
|
+
transition: all var(--salmex-transition-fast);
|
|
642
|
+
/* Sunken inset — same as TextInput */
|
|
643
|
+
box-shadow:
|
|
644
|
+
inset 2px 2px 0 rgb(var(--salmex-button-shadow)),
|
|
645
|
+
inset -1px -1px 0 rgb(var(--salmex-button-highlight));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
.salmex-select-trigger:hover:not(:disabled) {
|
|
649
|
+
border-color: rgb(var(--salmex-text-primary));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.salmex-select-trigger:focus-visible {
|
|
653
|
+
outline: none;
|
|
654
|
+
border-color: rgb(var(--salmex-text-primary));
|
|
655
|
+
box-shadow:
|
|
656
|
+
inset 2px 2px 0 rgb(var(--salmex-button-shadow)),
|
|
657
|
+
inset -1px -1px 0 rgb(var(--salmex-button-highlight)),
|
|
658
|
+
0 0 0 2px rgb(var(--salmex-midnight-black)),
|
|
659
|
+
0 0 0 5px rgb(var(--salmex-crown-yellow));
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
:global([data-theme='dark']) .salmex-select-trigger:focus-visible {
|
|
663
|
+
box-shadow:
|
|
664
|
+
inset 2px 2px 0 rgb(var(--salmex-button-shadow)),
|
|
665
|
+
inset -1px -1px 0 rgb(var(--salmex-button-highlight)),
|
|
666
|
+
0 0 0 3px rgb(var(--salmex-crown-yellow));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
.salmex-select-trigger-open {
|
|
670
|
+
border-color: rgb(var(--salmex-text-primary));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
.salmex-select-trigger-error {
|
|
674
|
+
border-color: rgb(var(--salmex-street-red));
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
.salmex-select-trigger-disabled {
|
|
678
|
+
opacity: 0.5;
|
|
679
|
+
cursor: not-allowed;
|
|
680
|
+
filter: grayscale(0.5);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/* ========================================
|
|
684
|
+
SIZES
|
|
685
|
+
======================================== */
|
|
686
|
+
.salmex-select-sm .salmex-select-trigger {
|
|
687
|
+
min-height: 28px;
|
|
688
|
+
padding: 0 var(--salmex-space-3);
|
|
689
|
+
font-size: var(--salmex-font-size-xs);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
.salmex-select-md .salmex-select-trigger {
|
|
693
|
+
min-height: 36px;
|
|
694
|
+
padding: 0 var(--salmex-space-4);
|
|
695
|
+
font-size: var(--salmex-font-size-base);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
.salmex-select-lg .salmex-select-trigger {
|
|
699
|
+
min-height: 44px;
|
|
700
|
+
padding: 0 var(--salmex-space-5);
|
|
701
|
+
font-size: var(--salmex-font-size-md);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/* ========================================
|
|
705
|
+
VALUE DISPLAY
|
|
706
|
+
======================================== */
|
|
707
|
+
.salmex-select-value {
|
|
708
|
+
flex: 1;
|
|
709
|
+
overflow: hidden;
|
|
710
|
+
text-overflow: ellipsis;
|
|
711
|
+
white-space: nowrap;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
.salmex-select-placeholder {
|
|
715
|
+
color: rgb(var(--salmex-text-disabled));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
.salmex-select-chevron {
|
|
719
|
+
flex-shrink: 0;
|
|
720
|
+
display: flex;
|
|
721
|
+
align-items: center;
|
|
722
|
+
transition: transform var(--salmex-transition-fast);
|
|
723
|
+
color: rgb(var(--salmex-text-secondary));
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
.salmex-select-trigger-open .salmex-select-chevron {
|
|
727
|
+
transform: rotate(180deg);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/* ========================================
|
|
731
|
+
DROPDOWN PANEL — Raised, bold shadow, fixed position
|
|
732
|
+
======================================== */
|
|
733
|
+
.salmex-select-panel {
|
|
734
|
+
z-index: var(--salmex-z-dropdown);
|
|
735
|
+
overflow-y: auto;
|
|
736
|
+
overflow-x: hidden;
|
|
737
|
+
background: rgb(var(--salmex-bg-primary));
|
|
738
|
+
border: 3px solid rgb(var(--salmex-border-dark));
|
|
739
|
+
box-shadow:
|
|
740
|
+
inset 1px 1px 0 rgb(var(--salmex-button-highlight)),
|
|
741
|
+
inset -1px -1px 0 rgb(var(--salmex-button-shadow)),
|
|
742
|
+
5px 5px 0 rgb(0 0 0 / 0.35);
|
|
743
|
+
outline: none;
|
|
744
|
+
padding: var(--salmex-space-1) 0;
|
|
745
|
+
font-family: var(--salmex-font-system);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
:global([data-theme='dark']) .salmex-select-panel {
|
|
749
|
+
box-shadow:
|
|
750
|
+
inset 1px 1px 0 rgb(var(--salmex-button-highlight)),
|
|
751
|
+
inset -1px -1px 0 rgb(var(--salmex-button-shadow)),
|
|
752
|
+
5px 5px 0 rgb(0 0 0 / 0.7);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/* ========================================
|
|
756
|
+
OPTION
|
|
757
|
+
======================================== */
|
|
758
|
+
.salmex-select-option {
|
|
759
|
+
display: flex;
|
|
760
|
+
align-items: center;
|
|
761
|
+
gap: var(--salmex-space-2);
|
|
762
|
+
padding: var(--salmex-space-2) var(--salmex-space-4);
|
|
763
|
+
font-size: var(--salmex-font-size-sm);
|
|
764
|
+
font-weight: 600;
|
|
765
|
+
color: rgb(var(--salmex-text-primary));
|
|
766
|
+
cursor: pointer;
|
|
767
|
+
user-select: none;
|
|
768
|
+
transition: background var(--salmex-transition-fast);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
.salmex-select-option-active {
|
|
772
|
+
background: rgb(var(--salmex-electric-blue));
|
|
773
|
+
color: rgb(var(--salmex-chalk-white));
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
:global([data-theme='dark']) .salmex-select-option-active {
|
|
777
|
+
background: rgb(var(--salmex-primary-light));
|
|
778
|
+
color: rgb(var(--salmex-midnight-black));
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
.salmex-select-option-selected:not(.salmex-select-option-active) {
|
|
782
|
+
background: rgb(var(--salmex-electric-blue) / 0.1);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
:global([data-theme='dark']) .salmex-select-option-selected:not(.salmex-select-option-active) {
|
|
786
|
+
background: rgb(var(--salmex-primary-light) / 0.15);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
.salmex-select-option-disabled {
|
|
790
|
+
opacity: 0.4;
|
|
791
|
+
cursor: not-allowed;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
.salmex-select-option-label {
|
|
795
|
+
flex: 1;
|
|
796
|
+
overflow: hidden;
|
|
797
|
+
text-overflow: ellipsis;
|
|
798
|
+
white-space: nowrap;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/* Checkmark for single select */
|
|
802
|
+
.salmex-select-checkmark {
|
|
803
|
+
flex-shrink: 0;
|
|
804
|
+
display: flex;
|
|
805
|
+
align-items: center;
|
|
806
|
+
color: rgb(var(--salmex-electric-blue));
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
.salmex-select-option-active .salmex-select-checkmark {
|
|
810
|
+
color: rgb(var(--salmex-chalk-white));
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
:global([data-theme='dark']) .salmex-select-option-active .salmex-select-checkmark {
|
|
814
|
+
color: rgb(var(--salmex-midnight-black));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/* Checkbox square for multi select */
|
|
818
|
+
.salmex-select-check {
|
|
819
|
+
flex-shrink: 0;
|
|
820
|
+
display: flex;
|
|
821
|
+
align-items: center;
|
|
822
|
+
justify-content: center;
|
|
823
|
+
width: 16px;
|
|
824
|
+
height: 16px;
|
|
825
|
+
border: 2px solid rgb(var(--salmex-border-dark));
|
|
826
|
+
background: rgb(var(--salmex-bg-primary));
|
|
827
|
+
box-shadow:
|
|
828
|
+
inset 1px 1px 0 rgb(var(--salmex-button-shadow)),
|
|
829
|
+
inset -1px -1px 0 rgb(var(--salmex-button-highlight));
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
.salmex-select-option-active .salmex-select-check {
|
|
833
|
+
border-color: rgb(var(--salmex-chalk-white));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
:global([data-theme='dark']) .salmex-select-option-active .salmex-select-check {
|
|
837
|
+
border-color: rgb(var(--salmex-midnight-black));
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/* ========================================
|
|
841
|
+
GROUP LABELS
|
|
842
|
+
======================================== */
|
|
843
|
+
.salmex-select-group-label {
|
|
844
|
+
padding: var(--salmex-space-2) var(--salmex-space-4) var(--salmex-space-1);
|
|
845
|
+
font-size: var(--salmex-font-size-xs);
|
|
846
|
+
font-weight: 700;
|
|
847
|
+
text-transform: uppercase;
|
|
848
|
+
letter-spacing: 0.4px;
|
|
849
|
+
color: rgb(var(--salmex-text-secondary));
|
|
850
|
+
user-select: none;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/* ========================================
|
|
854
|
+
FOOTER — Error / Hint
|
|
855
|
+
======================================== */
|
|
856
|
+
.salmex-select-footer {
|
|
857
|
+
min-height: 18px;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
.salmex-select-error {
|
|
861
|
+
font-size: var(--salmex-font-size-xs);
|
|
862
|
+
font-weight: 600;
|
|
863
|
+
color: rgb(var(--salmex-street-red));
|
|
864
|
+
margin: 0;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
.salmex-select-hint {
|
|
868
|
+
font-size: var(--salmex-font-size-xs);
|
|
869
|
+
color: rgb(var(--salmex-text-secondary));
|
|
870
|
+
margin: 0;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/* ========================================
|
|
874
|
+
REDUCED MOTION
|
|
875
|
+
======================================== */
|
|
876
|
+
@media (prefers-reduced-motion: reduce) {
|
|
877
|
+
.salmex-select-trigger,
|
|
878
|
+
.salmex-select-option,
|
|
879
|
+
.salmex-select-chevron {
|
|
880
|
+
transition: none;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
</style>
|