@streamscloud/kit 0.31.0 → 0.33.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/core/utils/html-helper.d.ts +6 -0
- package/dist/core/utils/html-helper.js +13 -0
- package/dist/ui/input/cmp.input.svelte +30 -2
- package/dist/ui/input/cmp.input.svelte.d.ts +4 -0
- package/dist/ui/input/types.d.ts +8 -1
- package/dist/ui/select/_select-trigger.scss +16 -12
- package/dist/ui/select/cmp.input-suggest.svelte +513 -0
- package/dist/ui/select/cmp.input-suggest.svelte.d.ts +106 -0
- package/dist/ui/select/cmp.multiselect-tree.svelte.d.ts +1 -1
- package/dist/ui/select/index.d.ts +3 -1
- package/dist/ui/select/index.js +1 -0
- package/dist/ui/select/multiselect-base.svelte +1 -1
- package/dist/ui/select/select-core.svelte.d.ts +6 -4
- package/dist/ui/select/select-core.svelte.js +13 -8
- package/dist/ui/select/types.d.ts +12 -2
- package/dist/ui/textarea/cmp.textarea.svelte +26 -2
- package/dist/ui/textarea/cmp.textarea.svelte.d.ts +12 -0
- package/dist/ui/website-input/cmp.website-input.svelte +3 -2
- package/package.json +1 -1
|
@@ -5,6 +5,12 @@ export declare class HtmlHelper {
|
|
|
5
5
|
* Replaces reserved html characters with html entities e.g. '<' with '<'
|
|
6
6
|
*/
|
|
7
7
|
static escapeSpecialChars(s: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Strips leading / trailing whitespace from a field's current value in place, returning whether it
|
|
10
|
+
* changed. Dispatches nothing — the caller emits. Call at commit points only: on `input` it would
|
|
11
|
+
* swallow the space in "hello world" as it is typed.
|
|
12
|
+
*/
|
|
13
|
+
static trimElementValue(element: HTMLInputElement | HTMLTextAreaElement): boolean;
|
|
8
14
|
static pasteIntoInput(value: string, element: HTMLInputElement | HTMLTextAreaElement): void;
|
|
9
15
|
static sanitizeSvg(svg: string): string;
|
|
10
16
|
static sanitizeHtml(html: string): string;
|
|
@@ -29,6 +29,19 @@ export class HtmlHelper {
|
|
|
29
29
|
static escapeSpecialChars(s) {
|
|
30
30
|
return s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Strips leading / trailing whitespace from a field's current value in place, returning whether it
|
|
34
|
+
* changed. Dispatches nothing — the caller emits. Call at commit points only: on `input` it would
|
|
35
|
+
* swallow the space in "hello world" as it is typed.
|
|
36
|
+
*/
|
|
37
|
+
static trimElementValue(element) {
|
|
38
|
+
const trimmed = element.value.trim();
|
|
39
|
+
if (trimmed === element.value) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
element.value = trimmed;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
32
45
|
static pasteIntoInput(value, element) {
|
|
33
46
|
if (element.selectionStart || element.selectionStart === 0) {
|
|
34
47
|
const startPos = element.selectionStart;
|
|
@@ -3,13 +3,14 @@ import { InputClearButton } from '../_internal/input-clear-button';
|
|
|
3
3
|
import { InputEmojiPicker } from '../_internal/input-emoji-picker';
|
|
4
4
|
import { InputPasswordToggle } from '../_internal/input-password-toggle';
|
|
5
5
|
import { IconSlot } from '../icon';
|
|
6
|
-
const { value, type = 'text', size = 'md', disabled = false, readonly = false, inert = false, error = false, borderless = false, clearable = false, emoji = false, placeholder = '', title = '', name = '', id = null, autofocus = false, maxLength = null, autocomplete, autocapitalize, inputmode, enterkeyhint, spellcheck, 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, 'aria-required': ariaRequired, on, icon, iconPosition = 'leading', secondaryIcon, prefix, suffix } = $props();
|
|
6
|
+
const { value, type = 'text', size = 'md', disabled = false, readonly = false, inert = false, error = false, borderless = false, clearable = false, emoji = false, trim = true, placeholder = '', title = '', name = '', id = null, autofocus = false, maxLength = null, autocomplete, autocapitalize, inputmode, enterkeyhint, spellcheck, 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, 'aria-required': ariaRequired, on, icon, iconPosition = 'leading', secondaryIcon, prefix, suffix } = $props();
|
|
7
7
|
let inputEl = $state.raw(undefined);
|
|
8
8
|
let passwordRevealed = $state(false);
|
|
9
9
|
const valueParsed = $derived(value ?? '');
|
|
10
10
|
const clearButtonVisible = $derived(clearable && !disabled && !readonly && !inert && !!valueParsed);
|
|
11
11
|
const passwordToggleVisible = $derived(type === 'password' && !disabled && !readonly && !inert);
|
|
12
12
|
const effectiveType = $derived(type === 'password' && passwordRevealed ? 'text' : type);
|
|
13
|
+
const trimOnCommit = $derived(trim && type !== 'password' && !disabled && !readonly && !inert);
|
|
13
14
|
/** Imperative API exposed via `bind:this` on `<Input>`. */
|
|
14
15
|
export const focus = () => inputEl?.focus();
|
|
15
16
|
export const select = () => inputEl?.select();
|
|
@@ -26,10 +27,24 @@ const handleInput = () => {
|
|
|
26
27
|
}
|
|
27
28
|
on?.input?.(inputEl.value);
|
|
28
29
|
};
|
|
30
|
+
const applyTrim = () => {
|
|
31
|
+
if (!trimOnCommit || !inputEl || !HtmlHelper.trimElementValue(inputEl)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
handleInput();
|
|
35
|
+
return inputEl.value;
|
|
36
|
+
};
|
|
37
|
+
const commitTrim = () => {
|
|
38
|
+
const trimmed = applyTrim();
|
|
39
|
+
if (trimmed !== null) {
|
|
40
|
+
on?.change?.(trimmed);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
29
43
|
const handleChange = () => {
|
|
30
44
|
if (!inputEl) {
|
|
31
45
|
return;
|
|
32
46
|
}
|
|
47
|
+
applyTrim();
|
|
33
48
|
on?.change?.(inputEl.value);
|
|
34
49
|
};
|
|
35
50
|
const handleClear = () => {
|
|
@@ -47,6 +62,8 @@ const handlePasswordToggle = () => {
|
|
|
47
62
|
inputEl?.focus();
|
|
48
63
|
};
|
|
49
64
|
const handleBlur = () => {
|
|
65
|
+
// Before the re-sync: trimming after it re-emits a lagging prop as fresh input, losing the last edit.
|
|
66
|
+
commitTrim();
|
|
50
67
|
// Force re-sync to the controlled value on blur — covers the case where the consumer
|
|
51
68
|
// didn't update the prop in response to input events (e.g. yup rejected the value).
|
|
52
69
|
if (inputEl) {
|
|
@@ -54,6 +71,13 @@ const handleBlur = () => {
|
|
|
54
71
|
}
|
|
55
72
|
on?.blur?.();
|
|
56
73
|
};
|
|
74
|
+
const handleKeydown = (event) => {
|
|
75
|
+
// Input only — the native `change` Enter fires next announces the commit; emitting it here doubles it.
|
|
76
|
+
if (event.key === 'Enter' && !event.isComposing) {
|
|
77
|
+
applyTrim();
|
|
78
|
+
}
|
|
79
|
+
on?.keydown?.(event);
|
|
80
|
+
};
|
|
57
81
|
const handleEmojiSelect = (emojiChar) => {
|
|
58
82
|
if (inputEl) {
|
|
59
83
|
HtmlHelper.pasteIntoInput(emojiChar, inputEl);
|
|
@@ -111,7 +135,7 @@ const focusInput = () => inputEl?.focus();
|
|
|
111
135
|
onchange={handleChange}
|
|
112
136
|
onblur={handleBlur}
|
|
113
137
|
onfocus={() => on?.focus?.()}
|
|
114
|
-
onkeydown={
|
|
138
|
+
onkeydown={handleKeydown} />
|
|
115
139
|
|
|
116
140
|
{#if suffix}
|
|
117
141
|
<span class="input__suffix">{@render suffix()}</span>
|
|
@@ -145,6 +169,10 @@ focus underline. Discriminated `type` selects native input mode (`text` / `passw
|
|
|
145
169
|
for non-editable but selectable, `error` for the visual + a11y error state. Imperative
|
|
146
170
|
`focus()` / `select()` / `clear()` exposed via `bind:this`.
|
|
147
171
|
|
|
172
|
+
Leading / trailing whitespace is stripped at every commit point — `change`, blur, Enter — so the
|
|
173
|
+
value the consumer receives always matches what the field shows. Typing is untouched. Pass
|
|
174
|
+
`trim={false}` to opt out; `type="password"` never trims.
|
|
175
|
+
|
|
148
176
|
Two snippet slots widen the chrome: `prefix` for a full-height segmented leading addon (see
|
|
149
177
|
`WebsiteInput`), and `suffix` for a free-form trailing addon rendered before any trailing icons
|
|
150
178
|
or the clear button (see `HandleInput`'s availability pill).
|
|
@@ -6,6 +6,10 @@ import type { InputProps } from './types';
|
|
|
6
6
|
* for non-editable but selectable, `error` for the visual + a11y error state. Imperative
|
|
7
7
|
* `focus()` / `select()` / `clear()` exposed via `bind:this`.
|
|
8
8
|
*
|
|
9
|
+
* Leading / trailing whitespace is stripped at every commit point — `change`, blur, Enter — so the
|
|
10
|
+
* value the consumer receives always matches what the field shows. Typing is untouched. Pass
|
|
11
|
+
* `trim={false}` to opt out; `type="password"` never trims.
|
|
12
|
+
*
|
|
9
13
|
* Two snippet slots widen the chrome: `prefix` for a full-height segmented leading addon (see
|
|
10
14
|
* `WebsiteInput`), and `suffix` for a free-form trailing addon rendered before any trailing icons
|
|
11
15
|
* or the clear button (see `HandleInput`'s availability pill).
|
package/dist/ui/input/types.d.ts
CHANGED
|
@@ -22,6 +22,13 @@ export type InputProps = {
|
|
|
22
22
|
clearable?: boolean;
|
|
23
23
|
/** Show the emoji-picker trigger inside the input. */
|
|
24
24
|
emoji?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Strip leading / trailing whitespace when the value is committed — on `change`, on blur, and on
|
|
27
|
+
* Enter. Typing is never touched, so the committed value always equals what the field shows.
|
|
28
|
+
* Ignored for `type="password"`, where whitespace can be part of the secret.
|
|
29
|
+
* @default true
|
|
30
|
+
*/
|
|
31
|
+
trim?: boolean;
|
|
25
32
|
placeholder?: string;
|
|
26
33
|
title?: string;
|
|
27
34
|
name?: string;
|
|
@@ -75,4 +82,4 @@ export type InputProps = {
|
|
|
75
82
|
* Proxies should declare their Props as `Omit<InputForwardedProps, 'hardwiredKey1' | ...> & OwnProps`
|
|
76
83
|
* to surgically remove forwarded props that the proxy hard-wires (so consumers can't override them).
|
|
77
84
|
*/
|
|
78
|
-
export type InputForwardedProps = Pick<InputProps, 'value' | 'size' | 'disabled' | 'readonly' | 'inert' | 'error' | 'borderless' | 'placeholder' | 'title' | 'name' | 'id' | 'autofocus' | 'maxLength' | 'autocomplete' | 'autocapitalize' | 'inputmode' | 'enterkeyhint' | 'spellcheck' | 'aria-describedby' | 'aria-label' | 'aria-required' | 'on'>;
|
|
85
|
+
export type InputForwardedProps = Pick<InputProps, 'value' | 'size' | 'disabled' | 'readonly' | 'inert' | 'error' | 'borderless' | 'trim' | 'placeholder' | 'title' | 'name' | 'id' | 'autofocus' | 'maxLength' | 'autocomplete' | 'autocapitalize' | 'inputmode' | 'enterkeyhint' | 'spellcheck' | 'aria-describedby' | 'aria-label' | 'aria-required' | 'on'>;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Shared trigger shape for Singleselect / SingleselectAsync / (later) Multiselect.
|
|
7
7
|
// Public CSS API lives under `--sc-kit--select--trigger--*` so consumers can override
|
|
8
8
|
// once and have all select variants pick it up.
|
|
9
|
-
@mixin select-trigger {
|
|
9
|
+
@mixin select-trigger($chevron: true) {
|
|
10
10
|
--_sel--height: var(--sc-kit--select--trigger--height, var(--_sel--size-height, var(--sc-kit--field--height--md)));
|
|
11
11
|
--_sel--padding-inline: var(--sc-kit--select--trigger--padding-inline, var(--_sel--size-padding-inline, var(--sc-kit--field--padding-inline--md)));
|
|
12
12
|
--_sel--gap: var(--sc-kit--select--trigger--gap, var(--sc-kit--space--2));
|
|
@@ -164,12 +164,14 @@
|
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
167
|
+
@if $chevron {
|
|
168
|
+
&__chevron {
|
|
169
|
+
transition: transform var(--sc-kit--duration--fast) var(--sc-kit--ease--default);
|
|
170
|
+
}
|
|
170
171
|
|
|
171
|
-
|
|
172
|
-
|
|
172
|
+
&--open &__chevron {
|
|
173
|
+
transform: rotate(180deg);
|
|
174
|
+
}
|
|
173
175
|
}
|
|
174
176
|
|
|
175
177
|
&__spinner {
|
|
@@ -193,12 +195,14 @@
|
|
|
193
195
|
// reflow to additional rows below. (Spinner positioning lives in MultiselectAsync's
|
|
194
196
|
// local style block — Svelte's per-component CSS-usage check otherwise flags this rule
|
|
195
197
|
// as unused in Singleselect / sync Multiselect.)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
198
|
+
@if $chevron {
|
|
199
|
+
&--multi &__chevron {
|
|
200
|
+
position: absolute;
|
|
201
|
+
inset-block-start: calc(var(--_sel--height) / 2);
|
|
202
|
+
inset-inline-end: var(--_sel--padding-inline);
|
|
203
|
+
transform: translateY(-50%);
|
|
204
|
+
margin: 0;
|
|
205
|
+
}
|
|
202
206
|
}
|
|
203
207
|
|
|
204
208
|
// Inline-chip area visible inside the trigger when value is non-empty (inline mode).
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
<script lang="ts" module>export {};
|
|
2
|
+
</script>
|
|
3
|
+
|
|
4
|
+
<script lang="ts">import { HtmlHelper } from '../../core/utils';
|
|
5
|
+
import { InputClearButton } from '../_internal/input-clear-button';
|
|
6
|
+
import { IconSlot } from '../icon';
|
|
7
|
+
import { default as SelectListbox } from './select-listbox.svelte';
|
|
8
|
+
const { value, suggestions, size = 'md', placeholder = '', disabled = false, readonly = false, inert = false, error = false, borderless = false, clearable = false, trim = true, icon, name, id, autocomplete = 'off', 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedby, 'aria-required': ariaRequired, on } = $props();
|
|
9
|
+
let inputEl = $state.raw(undefined);
|
|
10
|
+
let rootEl = $state.raw(undefined);
|
|
11
|
+
let isOpen = $state(false);
|
|
12
|
+
let isFiltering = $state(false);
|
|
13
|
+
let highlight = $state(-1);
|
|
14
|
+
let lastCommitted = '';
|
|
15
|
+
let isRestoringFocus = false;
|
|
16
|
+
const listboxId = `input-suggest-${Math.random().toString(36).slice(2, 9)}`;
|
|
17
|
+
const currentValue = $derived(value ?? '');
|
|
18
|
+
const isInteractive = $derived(!disabled && !readonly && !inert);
|
|
19
|
+
const matches = $derived.by(() => {
|
|
20
|
+
if (!isFiltering) {
|
|
21
|
+
return suggestions;
|
|
22
|
+
}
|
|
23
|
+
const needle = currentValue.trim().toLowerCase();
|
|
24
|
+
if (!needle) {
|
|
25
|
+
return suggestions;
|
|
26
|
+
}
|
|
27
|
+
return suggestions.filter((suggestion) => suggestion.value.toLowerCase().includes(needle));
|
|
28
|
+
});
|
|
29
|
+
const rows = $derived(matches.map((suggestion) => ({
|
|
30
|
+
kind: 'option',
|
|
31
|
+
option: { label: suggestion.value, value: suggestion },
|
|
32
|
+
indent: false,
|
|
33
|
+
selected: false
|
|
34
|
+
})));
|
|
35
|
+
const isPanelOpen = $derived(isOpen && rows.length > 0);
|
|
36
|
+
const activeIndex = $derived(highlight >= 0 && highlight < rows.length ? highlight : -1);
|
|
37
|
+
const showClear = $derived(clearable && isInteractive && !!currentValue);
|
|
38
|
+
const openSuggestions = () => {
|
|
39
|
+
isOpen = true;
|
|
40
|
+
isFiltering = false;
|
|
41
|
+
highlight = -1;
|
|
42
|
+
};
|
|
43
|
+
const closeSuggestions = () => {
|
|
44
|
+
isOpen = false;
|
|
45
|
+
isFiltering = false;
|
|
46
|
+
highlight = -1;
|
|
47
|
+
};
|
|
48
|
+
// clearing the flag synchronously drops the guard when focus() dispatches late — handleFocus then reopens the popover after a pick
|
|
49
|
+
const restoreFocus = () => {
|
|
50
|
+
isRestoringFocus = true;
|
|
51
|
+
inputEl?.focus();
|
|
52
|
+
queueMicrotask(() => {
|
|
53
|
+
isRestoringFocus = false;
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
export const focus = () => inputEl?.focus();
|
|
57
|
+
export const select = () => inputEl?.select();
|
|
58
|
+
export const open = () => {
|
|
59
|
+
if (isInteractive) {
|
|
60
|
+
inputEl?.focus();
|
|
61
|
+
openSuggestions();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
export const close = () => closeSuggestions();
|
|
65
|
+
const commitValue = (next) => {
|
|
66
|
+
lastCommitted = next;
|
|
67
|
+
on?.change?.(next);
|
|
68
|
+
};
|
|
69
|
+
const flushCommit = () => {
|
|
70
|
+
if (!inputEl) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (trim && isInteractive && HtmlHelper.trimElementValue(inputEl)) {
|
|
74
|
+
on?.input?.(inputEl.value);
|
|
75
|
+
}
|
|
76
|
+
if (inputEl.value !== lastCommitted) {
|
|
77
|
+
commitValue(inputEl.value);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const emitValue = (next) => {
|
|
81
|
+
if (inputEl) {
|
|
82
|
+
inputEl.value = next;
|
|
83
|
+
}
|
|
84
|
+
on?.input?.(next);
|
|
85
|
+
commitValue(next);
|
|
86
|
+
};
|
|
87
|
+
const applySuggestion = (suggestion) => {
|
|
88
|
+
closeSuggestions();
|
|
89
|
+
emitValue(suggestion.value);
|
|
90
|
+
on?.select?.(suggestion);
|
|
91
|
+
restoreFocus();
|
|
92
|
+
};
|
|
93
|
+
export const clear = () => {
|
|
94
|
+
if (!isInteractive) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
emitValue('');
|
|
98
|
+
restoreFocus();
|
|
99
|
+
openSuggestions();
|
|
100
|
+
};
|
|
101
|
+
const handleRootClick = () => {
|
|
102
|
+
if (!isInteractive) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
inputEl?.focus();
|
|
106
|
+
if (!isOpen) {
|
|
107
|
+
openSuggestions();
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
// calling openSuggestions() here expands the popover on Tab-focus
|
|
111
|
+
const handleFocus = () => {
|
|
112
|
+
if (isRestoringFocus) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
lastCommitted = inputEl?.value ?? currentValue;
|
|
116
|
+
on?.focus?.();
|
|
117
|
+
};
|
|
118
|
+
const handleBlur = (event) => {
|
|
119
|
+
const next = event.relatedTarget;
|
|
120
|
+
// Row clicks blur the input before the click lands — closing here would unmount the row first.
|
|
121
|
+
if (next instanceof Node && rootEl?.contains(next)) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
closeSuggestions();
|
|
125
|
+
flushCommit();
|
|
126
|
+
if (inputEl) {
|
|
127
|
+
inputEl.value = currentValue;
|
|
128
|
+
}
|
|
129
|
+
on?.blur?.();
|
|
130
|
+
};
|
|
131
|
+
const handleRootMousedown = (event) => {
|
|
132
|
+
if (event.target !== inputEl) {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const handleInput = () => {
|
|
137
|
+
if (!inputEl) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (isInteractive) {
|
|
141
|
+
isOpen = true;
|
|
142
|
+
isFiltering = true;
|
|
143
|
+
highlight = -1;
|
|
144
|
+
}
|
|
145
|
+
on?.input?.(inputEl.value);
|
|
146
|
+
};
|
|
147
|
+
const highlightedRow = () => (isPanelOpen && activeIndex >= 0 ? rows[activeIndex] : undefined);
|
|
148
|
+
const handleKeydown = (event) => {
|
|
149
|
+
// Ahead of on.keydown: the trim below lands after it, so an Enter-submitting consumer reads raw text.
|
|
150
|
+
if (event.key === 'Enter' && !event.isComposing && isInteractive && highlightedRow()?.kind !== 'option') {
|
|
151
|
+
flushCommit();
|
|
152
|
+
}
|
|
153
|
+
on?.keydown?.(event);
|
|
154
|
+
if (!isInteractive) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
158
|
+
const step = event.key === 'ArrowDown' ? 1 : -1;
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
// plain `isOpen` — openSuggestions() here would drop the active filter and offer unrelated rows to Enter
|
|
161
|
+
isOpen = true;
|
|
162
|
+
const count = rows.length;
|
|
163
|
+
if (count === 0) {
|
|
164
|
+
highlight = -1;
|
|
165
|
+
}
|
|
166
|
+
else if (activeIndex < 0) {
|
|
167
|
+
highlight = step === 1 ? 0 : count - 1;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
highlight = (activeIndex + step + count) % count;
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (event.key === 'Enter') {
|
|
175
|
+
if (event.isComposing) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const row = highlightedRow();
|
|
179
|
+
if (row?.kind === 'option') {
|
|
180
|
+
event.preventDefault();
|
|
181
|
+
applySuggestion(row.option.value);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
flushCommit();
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (event.key === 'Escape' && isPanelOpen) {
|
|
189
|
+
event.preventDefault();
|
|
190
|
+
closeSuggestions();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (event.key === 'Tab') {
|
|
194
|
+
closeSuggestions();
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const handlePickRow = (row) => {
|
|
198
|
+
if (row.kind === 'option') {
|
|
199
|
+
applySuggestion(row.option.value);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
</script>
|
|
203
|
+
|
|
204
|
+
{#snippet suggestionRow({ option }: { option: SelectOption<InputSuggestion> })}
|
|
205
|
+
<span class="input-suggest__suggestion">
|
|
206
|
+
<span class="input-suggest__suggestion-value">{option.label}</span>
|
|
207
|
+
{#if option.value.description}
|
|
208
|
+
<span class="input-suggest__suggestion-description">{option.value.description}</span>
|
|
209
|
+
{/if}
|
|
210
|
+
</span>
|
|
211
|
+
{/snippet}
|
|
212
|
+
|
|
213
|
+
<div
|
|
214
|
+
bind:this={rootEl}
|
|
215
|
+
class="input-suggest input-suggest--{size}"
|
|
216
|
+
class:input-suggest--open={isPanelOpen}
|
|
217
|
+
class:input-suggest--error={error}
|
|
218
|
+
class:input-suggest--disabled={disabled}
|
|
219
|
+
class:input-suggest--readonly={readonly}
|
|
220
|
+
class:input-suggest--inert={inert}
|
|
221
|
+
class:input-suggest--borderless={borderless}
|
|
222
|
+
onclick={handleRootClick}
|
|
223
|
+
onmousedown={handleRootMousedown}
|
|
224
|
+
onkeydown={() => undefined}
|
|
225
|
+
role="none">
|
|
226
|
+
{#if icon}
|
|
227
|
+
<span class="input-suggest__icon input-suggest__icon--leading" aria-hidden="true"><IconSlot icon={icon} /></span>
|
|
228
|
+
{/if}
|
|
229
|
+
|
|
230
|
+
<input
|
|
231
|
+
bind:this={inputEl}
|
|
232
|
+
class="input-suggest__input"
|
|
233
|
+
type="text"
|
|
234
|
+
role="combobox"
|
|
235
|
+
aria-haspopup="listbox"
|
|
236
|
+
aria-expanded={isPanelOpen}
|
|
237
|
+
aria-controls={listboxId}
|
|
238
|
+
aria-activedescendant={isPanelOpen && activeIndex >= 0 ? `${listboxId}-row-${activeIndex}` : undefined}
|
|
239
|
+
aria-autocomplete="list"
|
|
240
|
+
aria-label={ariaLabel}
|
|
241
|
+
aria-describedby={ariaDescribedby}
|
|
242
|
+
aria-required={ariaRequired ? 'true' : undefined}
|
|
243
|
+
aria-invalid={error ? 'true' : undefined}
|
|
244
|
+
id={id}
|
|
245
|
+
name={name}
|
|
246
|
+
autocomplete={autocomplete}
|
|
247
|
+
placeholder={placeholder}
|
|
248
|
+
disabled={disabled}
|
|
249
|
+
readonly={readonly}
|
|
250
|
+
inert={inert}
|
|
251
|
+
value={currentValue}
|
|
252
|
+
oninput={handleInput}
|
|
253
|
+
onfocus={handleFocus}
|
|
254
|
+
onblur={handleBlur}
|
|
255
|
+
onkeydown={handleKeydown} />
|
|
256
|
+
|
|
257
|
+
{#if showClear}
|
|
258
|
+
<InputClearButton size={size} on={{ clear }} />
|
|
259
|
+
{/if}
|
|
260
|
+
|
|
261
|
+
<SelectListbox
|
|
262
|
+
triggerEl={rootEl}
|
|
263
|
+
isOpen={isPanelOpen}
|
|
264
|
+
rows={rows}
|
|
265
|
+
highlight={activeIndex}
|
|
266
|
+
listboxId={listboxId}
|
|
267
|
+
optionSnippet={suggestionRow}
|
|
268
|
+
on={{ pickRow: handlePickRow, hoverRow: (index) => (highlight = index), dismiss: closeSuggestions }} />
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<!--
|
|
272
|
+
@component
|
|
273
|
+
InputSuggest — a plain text field that offers suggestions. **NOT a select:** the value is the
|
|
274
|
+
string the user typed, it changes on every keystroke, and it is never constrained to the
|
|
275
|
+
suggestion list — typed text that matches nothing stays valid, and there is no "Use …" /
|
|
276
|
+
"Create …" confirmation step. `on.input` fires per keystroke exactly like `Input`'s; `on.change`
|
|
277
|
+
commits once per edit — when focus leaves the whole control, on Enter, or on pick / clear —
|
|
278
|
+
so reaching into the popover never commits the half-typed query. `on.select` additionally fires
|
|
279
|
+
when a suggestion was picked. `value` is controlled and is what the filter reads.
|
|
280
|
+
|
|
281
|
+
A click opens the full list, typing filters it by case-insensitive substring, no match closes the
|
|
282
|
+
popover and the field keeps working as an ordinary input. Focus alone opens nothing — tabbing in
|
|
283
|
+
just places the caret, as with the selects. ArrowDown / ArrowUp move through the
|
|
284
|
+
suggestions, Enter substitutes the highlighted one (and is left alone when nothing is
|
|
285
|
+
highlighted), Escape closes without touching the text, Tab closes and keeps what was typed.
|
|
286
|
+
Trigger chrome, popover and rows are shared with the selects, minus the chevron.
|
|
287
|
+
|
|
288
|
+
Leading / trailing whitespace is stripped on commit, so the committed value always matches what the
|
|
289
|
+
field shows. Typing is untouched. Pass `trim={false}` to opt out.
|
|
290
|
+
|
|
291
|
+
### CSS Custom Properties
|
|
292
|
+
| Property | Description | Default |
|
|
293
|
+
|---|---|---|
|
|
294
|
+
| `--sc-kit--input-suggest--description--color` | Suggestion description line color | `var(--sc-kit--color--text--muted)` |
|
|
295
|
+
| `--sc-kit--input-suggest--description--font-size` | Suggestion description line font size | `var(--sc-kit--font-size--sm)` |
|
|
296
|
+
|
|
297
|
+
**Trigger** (shared with the selects): `--sc-kit--select--trigger--{height, padding-inline, gap,
|
|
298
|
+
background, background--disabled, border-color, border-color--hover, border-color--error,
|
|
299
|
+
border-radius, underline-color, font-size, color, placeholder--color, icon--color, icon--size}`.
|
|
300
|
+
|
|
301
|
+
**Popover panel:** `--sc-kit--select--panel--{background, border-color, border-radius, box-shadow,
|
|
302
|
+
max-height, padding}`.
|
|
303
|
+
|
|
304
|
+
**Suggestion rows:** `--sc-kit--select--option--{color, color--selected, background--active,
|
|
305
|
+
padding-inline}`. `--sc-kit--select--option--height` is NOT overridable here — the component pins
|
|
306
|
+
it to `auto` so a row with a `description` can grow to two lines.
|
|
307
|
+
|
|
308
|
+
**Clear button** (rendered when `clearable` and the value is non-empty):
|
|
309
|
+
`--sc-kit--input-clear-button--{size, icon-size, border-radius, color, color--hover,
|
|
310
|
+
background--hover}`.
|
|
311
|
+
-->
|
|
312
|
+
|
|
313
|
+
<style>.input-suggest {
|
|
314
|
+
--_sel--height: var(--sc-kit--select--trigger--height, var(--_sel--size-height, var(--sc-kit--field--height--md)));
|
|
315
|
+
--_sel--padding-inline: var(--sc-kit--select--trigger--padding-inline, var(--_sel--size-padding-inline, var(--sc-kit--field--padding-inline--md)));
|
|
316
|
+
--_sel--gap: var(--sc-kit--select--trigger--gap, var(--sc-kit--space--2));
|
|
317
|
+
--_sel--background: var(--sc-kit--select--trigger--background, var(--sc-kit--color--bg--field));
|
|
318
|
+
--_sel--background-disabled: var(--sc-kit--select--trigger--background--disabled, var(--sc-kit--color--bg--field-alt));
|
|
319
|
+
--_sel--border-color: var(--sc-kit--select--trigger--border-color, var(--sc-kit--color--border--field));
|
|
320
|
+
--_sel--border-color-hover: var(--sc-kit--select--trigger--border-color--hover, var(--sc-kit--color--border--strong));
|
|
321
|
+
--_sel--border-color-error: var(--sc-kit--select--trigger--border-color--error, var(--sc-kit--color--danger));
|
|
322
|
+
--_sel--border-radius: var(--sc-kit--select--trigger--border-radius, var(--_sel--size-border-radius, var(--sc-kit--field--border-radius--md)));
|
|
323
|
+
--_sel--underline-color: var(--sc-kit--select--trigger--underline-color, transparent);
|
|
324
|
+
--_sel--font-size: var(--sc-kit--select--trigger--font-size, var(--_sel--size-font-size, var(--sc-kit--font-size--md)));
|
|
325
|
+
--_sel--color: var(--sc-kit--select--trigger--color, var(--sc-kit--color--text--primary));
|
|
326
|
+
--_sel--placeholder-color: var(--sc-kit--select--trigger--placeholder--color, var(--sc-kit--color--text--placeholder));
|
|
327
|
+
--_sel--icon-color: var(--sc-kit--select--trigger--icon--color, var(--sc-kit--color--text--muted));
|
|
328
|
+
--_sel--icon-size: var(--sc-kit--select--trigger--icon--size, var(--_sel--size-icon-size, var(--sc-kit--field--icon--size--md)));
|
|
329
|
+
--_--input-affordance--opacity: 0;
|
|
330
|
+
--_--input-affordance--scale: 0;
|
|
331
|
+
}
|
|
332
|
+
.input-suggest:where(:hover), .input-suggest:where(:focus-within) {
|
|
333
|
+
--_--input-affordance--opacity: 1;
|
|
334
|
+
--_--input-affordance--scale: 1;
|
|
335
|
+
}
|
|
336
|
+
.input-suggest {
|
|
337
|
+
box-sizing: border-box;
|
|
338
|
+
position: relative;
|
|
339
|
+
display: inline-flex;
|
|
340
|
+
align-items: center;
|
|
341
|
+
width: 100%;
|
|
342
|
+
max-width: 100%;
|
|
343
|
+
height: var(--_sel--height);
|
|
344
|
+
padding: 0 var(--_sel--padding-inline);
|
|
345
|
+
background: var(--_sel--background);
|
|
346
|
+
border: 1px solid var(--_sel--border-color);
|
|
347
|
+
border-radius: var(--_sel--border-radius);
|
|
348
|
+
color: var(--_sel--color);
|
|
349
|
+
font-size: var(--_sel--font-size);
|
|
350
|
+
line-height: var(--sc-kit--leading--tight);
|
|
351
|
+
cursor: pointer;
|
|
352
|
+
box-shadow: inset 0 calc(-1 * max(2px, 0.125em)) var(--_sel--underline-color);
|
|
353
|
+
transition: background-color var(--sc-kit--duration--base) var(--sc-kit--ease--default), border-color var(--sc-kit--duration--base) var(--sc-kit--ease--default), color var(--sc-kit--duration--base) var(--sc-kit--ease--default), box-shadow var(--sc-kit--duration--base) var(--sc-kit--ease--default);
|
|
354
|
+
}
|
|
355
|
+
.input-suggest--sm {
|
|
356
|
+
--_sel--size-height: var(--sc-kit--field--height--sm);
|
|
357
|
+
--_sel--size-padding-inline: var(--sc-kit--field--padding-inline--sm);
|
|
358
|
+
--_sel--size-font-size: var(--sc-kit--font-size--sm);
|
|
359
|
+
--_sel--size-icon-size: var(--sc-kit--field--icon--size--sm);
|
|
360
|
+
--_sel--size-border-radius: var(--sc-kit--field--border-radius--sm);
|
|
361
|
+
}
|
|
362
|
+
.input-suggest--md {
|
|
363
|
+
--_sel--size-height: var(--sc-kit--field--height--md);
|
|
364
|
+
--_sel--size-padding-inline: var(--sc-kit--field--padding-inline--md);
|
|
365
|
+
--_sel--size-font-size: var(--sc-kit--font-size--md);
|
|
366
|
+
--_sel--size-icon-size: var(--sc-kit--field--icon--size--md);
|
|
367
|
+
--_sel--size-border-radius: var(--sc-kit--field--border-radius--md);
|
|
368
|
+
}
|
|
369
|
+
.input-suggest--lg {
|
|
370
|
+
--_sel--size-height: var(--sc-kit--field--height--lg);
|
|
371
|
+
--_sel--size-padding-inline: var(--sc-kit--field--padding-inline--lg);
|
|
372
|
+
--_sel--size-font-size: var(--sc-kit--font-size--lg);
|
|
373
|
+
--_sel--size-icon-size: var(--sc-kit--field--icon--size--lg);
|
|
374
|
+
--_sel--size-border-radius: var(--sc-kit--field--border-radius--md);
|
|
375
|
+
}
|
|
376
|
+
.input-suggest:where(:hover:not(:focus-within)) {
|
|
377
|
+
--sc-kit--select--trigger--border-color: var(--_sel--border-color-hover);
|
|
378
|
+
}
|
|
379
|
+
.input-suggest:where(:focus-within), .input-suggest--open {
|
|
380
|
+
--sc-kit--select--trigger--underline-color: var(--sc-kit--color--accent);
|
|
381
|
+
}
|
|
382
|
+
.input-suggest--open {
|
|
383
|
+
--sc-kit--select--trigger--border-color: unset;
|
|
384
|
+
}
|
|
385
|
+
.input-suggest--error {
|
|
386
|
+
--sc-kit--select--trigger--border-color: var(--_sel--border-color-error);
|
|
387
|
+
--sc-kit--select--trigger--underline-color: var(--sc-kit--color--danger);
|
|
388
|
+
}
|
|
389
|
+
.input-suggest--disabled, .input-suggest--readonly, .input-suggest--inert {
|
|
390
|
+
--sc-kit--select--trigger--border-color: unset;
|
|
391
|
+
--sc-kit--select--trigger--underline-color: unset;
|
|
392
|
+
--_--input-affordance--opacity: 0;
|
|
393
|
+
--_--input-affordance--scale: 0;
|
|
394
|
+
cursor: default;
|
|
395
|
+
}
|
|
396
|
+
.input-suggest--disabled {
|
|
397
|
+
--sc-kit--select--trigger--background: var(--_sel--background-disabled);
|
|
398
|
+
--sc-kit--select--trigger--color: var(--sc-kit--color--text--muted);
|
|
399
|
+
}
|
|
400
|
+
.input-suggest--borderless {
|
|
401
|
+
--sc-kit--select--trigger--background: transparent;
|
|
402
|
+
--sc-kit--select--trigger--border-color: transparent;
|
|
403
|
+
--sc-kit--select--trigger--underline-color: transparent;
|
|
404
|
+
box-shadow: none;
|
|
405
|
+
}
|
|
406
|
+
.input-suggest__input {
|
|
407
|
+
flex: 1;
|
|
408
|
+
min-width: 0;
|
|
409
|
+
height: 100%;
|
|
410
|
+
padding: 0;
|
|
411
|
+
margin: 0;
|
|
412
|
+
border: none;
|
|
413
|
+
outline: none;
|
|
414
|
+
background: transparent;
|
|
415
|
+
color: inherit;
|
|
416
|
+
font-family: inherit;
|
|
417
|
+
font-size: inherit;
|
|
418
|
+
line-height: inherit;
|
|
419
|
+
cursor: inherit;
|
|
420
|
+
}
|
|
421
|
+
.input-suggest__input::placeholder {
|
|
422
|
+
color: var(--_sel--placeholder-color);
|
|
423
|
+
}
|
|
424
|
+
.input-suggest__input:not([readonly]) {
|
|
425
|
+
cursor: text;
|
|
426
|
+
}
|
|
427
|
+
.input-suggest__input--hidden {
|
|
428
|
+
opacity: 0;
|
|
429
|
+
width: 0;
|
|
430
|
+
flex: 0;
|
|
431
|
+
}
|
|
432
|
+
.input-suggest__input--no-search {
|
|
433
|
+
caret-color: transparent;
|
|
434
|
+
cursor: pointer;
|
|
435
|
+
}
|
|
436
|
+
.input-suggest__selection {
|
|
437
|
+
flex: 0 1 auto;
|
|
438
|
+
min-width: 0;
|
|
439
|
+
text-overflow: ellipsis;
|
|
440
|
+
max-width: 100%;
|
|
441
|
+
white-space: nowrap;
|
|
442
|
+
overflow: hidden;
|
|
443
|
+
}
|
|
444
|
+
.input-suggest__icon {
|
|
445
|
+
--sc-kit--icon--color: var(--_sel--icon-color);
|
|
446
|
+
--sc-kit--icon--size: var(--_sel--icon-size);
|
|
447
|
+
flex-shrink: 0;
|
|
448
|
+
display: inline-flex;
|
|
449
|
+
align-items: center;
|
|
450
|
+
justify-content: center;
|
|
451
|
+
line-height: 0;
|
|
452
|
+
}
|
|
453
|
+
.input-suggest__icon--leading {
|
|
454
|
+
margin-inline-end: var(--_sel--gap);
|
|
455
|
+
}
|
|
456
|
+
.input-suggest__icon--trailing {
|
|
457
|
+
margin-inline-start: var(--_sel--gap);
|
|
458
|
+
}
|
|
459
|
+
.input-suggest__spinner {
|
|
460
|
+
color: var(--_sel--icon-color);
|
|
461
|
+
}
|
|
462
|
+
.input-suggest--multi {
|
|
463
|
+
height: auto;
|
|
464
|
+
min-height: var(--_sel--height);
|
|
465
|
+
flex-wrap: wrap;
|
|
466
|
+
padding-block: 0.25rem;
|
|
467
|
+
padding-inline-end: calc(var(--_sel--padding-inline) + var(--_sel--icon-size) + var(--_sel--gap));
|
|
468
|
+
gap: var(--sc-kit--space--1);
|
|
469
|
+
}
|
|
470
|
+
.input-suggest__more {
|
|
471
|
+
display: inline-flex;
|
|
472
|
+
align-items: center;
|
|
473
|
+
padding: 0 var(--sc-kit--space--2);
|
|
474
|
+
font-size: var(--sc-kit--font-size--sm);
|
|
475
|
+
color: var(--sc-kit--color--text--muted);
|
|
476
|
+
user-select: none;
|
|
477
|
+
}
|
|
478
|
+
.input-suggest {
|
|
479
|
+
--_isg--description-color: var(--sc-kit--input-suggest--description--color, var(--sc-kit--color--text--muted));
|
|
480
|
+
--_isg--description-font-size: var(--sc-kit--input-suggest--description--font-size, var(--sc-kit--font-size--sm));
|
|
481
|
+
--sc-kit--select--option--height: auto;
|
|
482
|
+
cursor: text;
|
|
483
|
+
}
|
|
484
|
+
.input-suggest--disabled, .input-suggest--readonly, .input-suggest--inert {
|
|
485
|
+
cursor: default;
|
|
486
|
+
}
|
|
487
|
+
.input-suggest__suggestion {
|
|
488
|
+
box-sizing: border-box;
|
|
489
|
+
display: flex;
|
|
490
|
+
flex: 1;
|
|
491
|
+
flex-direction: column;
|
|
492
|
+
justify-content: center;
|
|
493
|
+
gap: 0.125rem;
|
|
494
|
+
min-width: 0;
|
|
495
|
+
min-block-size: var(--_sel--height);
|
|
496
|
+
padding-block: var(--sc-kit--space--1);
|
|
497
|
+
}
|
|
498
|
+
.input-suggest__suggestion-value {
|
|
499
|
+
text-overflow: ellipsis;
|
|
500
|
+
max-width: 100%;
|
|
501
|
+
white-space: nowrap;
|
|
502
|
+
overflow: hidden;
|
|
503
|
+
}
|
|
504
|
+
.input-suggest__suggestion-description {
|
|
505
|
+
color: var(--_isg--description-color);
|
|
506
|
+
font-size: var(--_isg--description-font-size);
|
|
507
|
+
white-space: pre-line;
|
|
508
|
+
word-break: break-word;
|
|
509
|
+
display: -webkit-box;
|
|
510
|
+
overflow: hidden;
|
|
511
|
+
-webkit-box-orient: vertical;
|
|
512
|
+
-webkit-line-clamp: 2;
|
|
513
|
+
}</style>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export type InputSuggestInstance = {
|
|
2
|
+
focus: () => void;
|
|
3
|
+
select: () => void;
|
|
4
|
+
clear: () => void;
|
|
5
|
+
open: () => void;
|
|
6
|
+
close: () => void;
|
|
7
|
+
};
|
|
8
|
+
import { type IconProp } from '../icon';
|
|
9
|
+
import type { InputSuggestion } from './types';
|
|
10
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
11
|
+
type Props = {
|
|
12
|
+
/** Controlled — feed `on.input` back into it; the suggestion filter reads this prop, not the DOM. */
|
|
13
|
+
value: string | null | undefined;
|
|
14
|
+
/** Advisory shortcuts offered in the popover. Never a constraint — any typed text stays valid. */
|
|
15
|
+
suggestions: InputSuggestion[];
|
|
16
|
+
/** @default 'md' */
|
|
17
|
+
size?: 'sm' | 'md' | 'lg';
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/** Non-editable but selectable; the popover never opens. */
|
|
21
|
+
readonly?: boolean;
|
|
22
|
+
/** Visual display mode — non-focusable, no hover/focus animations. */
|
|
23
|
+
inert?: boolean;
|
|
24
|
+
/** Visual error state — also sets `aria-invalid="true"`. */
|
|
25
|
+
error?: boolean;
|
|
26
|
+
/** Strip border + background + focus underline. */
|
|
27
|
+
borderless?: boolean;
|
|
28
|
+
/** Show the × clear button when the value is non-empty. */
|
|
29
|
+
clearable?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Strip leading / trailing whitespace when the value is committed — focus leaving the control or
|
|
32
|
+
* Enter. Typing is never touched, so the committed value always equals what the field shows.
|
|
33
|
+
* @default true
|
|
34
|
+
*/
|
|
35
|
+
trim?: boolean;
|
|
36
|
+
/** Leading icon — string SVG source, `{ src, color?, size? }` object, or custom snippet. */
|
|
37
|
+
icon?: IconProp;
|
|
38
|
+
name?: string;
|
|
39
|
+
id?: string;
|
|
40
|
+
/** @default 'off' */
|
|
41
|
+
autocomplete?: HTMLInputAttributes['autocomplete'];
|
|
42
|
+
'aria-label'?: string;
|
|
43
|
+
'aria-describedby'?: string;
|
|
44
|
+
'aria-required'?: boolean;
|
|
45
|
+
on?: {
|
|
46
|
+
/** Fires on every keystroke, and on pick / clear — same signature and timing as `Input`. */
|
|
47
|
+
input?: (value: string) => void;
|
|
48
|
+
/** Fires when an edit is committed — focus leaving the control or Enter — and on pick / clear. */
|
|
49
|
+
change?: (value: string) => void;
|
|
50
|
+
/** Fires after `input` and `change` when the user picked a suggestion. */
|
|
51
|
+
select?: (suggestion: InputSuggestion) => void;
|
|
52
|
+
/** Fires when focus leaves the whole control — reaching into the popover does not count as leaving. */
|
|
53
|
+
blur?: () => void;
|
|
54
|
+
focus?: () => void;
|
|
55
|
+
keydown?: (event: KeyboardEvent) => void;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* InputSuggest — a plain text field that offers suggestions. **NOT a select:** the value is the
|
|
60
|
+
* string the user typed, it changes on every keystroke, and it is never constrained to the
|
|
61
|
+
* suggestion list — typed text that matches nothing stays valid, and there is no "Use …" /
|
|
62
|
+
* "Create …" confirmation step. `on.input` fires per keystroke exactly like `Input`'s; `on.change`
|
|
63
|
+
* commits once per edit — when focus leaves the whole control, on Enter, or on pick / clear —
|
|
64
|
+
* so reaching into the popover never commits the half-typed query. `on.select` additionally fires
|
|
65
|
+
* when a suggestion was picked. `value` is controlled and is what the filter reads.
|
|
66
|
+
*
|
|
67
|
+
* A click opens the full list, typing filters it by case-insensitive substring, no match closes the
|
|
68
|
+
* popover and the field keeps working as an ordinary input. Focus alone opens nothing — tabbing in
|
|
69
|
+
* just places the caret, as with the selects. ArrowDown / ArrowUp move through the
|
|
70
|
+
* suggestions, Enter substitutes the highlighted one (and is left alone when nothing is
|
|
71
|
+
* highlighted), Escape closes without touching the text, Tab closes and keeps what was typed.
|
|
72
|
+
* Trigger chrome, popover and rows are shared with the selects, minus the chevron.
|
|
73
|
+
*
|
|
74
|
+
* Leading / trailing whitespace is stripped on commit, so the committed value always matches what the
|
|
75
|
+
* field shows. Typing is untouched. Pass `trim={false}` to opt out.
|
|
76
|
+
*
|
|
77
|
+
* ### CSS Custom Properties
|
|
78
|
+
* | Property | Description | Default |
|
|
79
|
+
* |---|---|---|
|
|
80
|
+
* | `--sc-kit--input-suggest--description--color` | Suggestion description line color | `var(--sc-kit--color--text--muted)` |
|
|
81
|
+
* | `--sc-kit--input-suggest--description--font-size` | Suggestion description line font size | `var(--sc-kit--font-size--sm)` |
|
|
82
|
+
*
|
|
83
|
+
* **Trigger** (shared with the selects): `--sc-kit--select--trigger--{height, padding-inline, gap,
|
|
84
|
+
* background, background--disabled, border-color, border-color--hover, border-color--error,
|
|
85
|
+
* border-radius, underline-color, font-size, color, placeholder--color, icon--color, icon--size}`.
|
|
86
|
+
*
|
|
87
|
+
* **Popover panel:** `--sc-kit--select--panel--{background, border-color, border-radius, box-shadow,
|
|
88
|
+
* max-height, padding}`.
|
|
89
|
+
*
|
|
90
|
+
* **Suggestion rows:** `--sc-kit--select--option--{color, color--selected, background--active,
|
|
91
|
+
* padding-inline}`. `--sc-kit--select--option--height` is NOT overridable here — the component pins
|
|
92
|
+
* it to `auto` so a row with a `description` can grow to two lines.
|
|
93
|
+
*
|
|
94
|
+
* **Clear button** (rendered when `clearable` and the value is non-empty):
|
|
95
|
+
* `--sc-kit--input-clear-button--{size, icon-size, border-radius, color, color--hover,
|
|
96
|
+
* background--hover}`.
|
|
97
|
+
*/
|
|
98
|
+
declare const Cmp: import("svelte").Component<Props, {
|
|
99
|
+
focus: () => void;
|
|
100
|
+
select: () => void;
|
|
101
|
+
open: () => void;
|
|
102
|
+
close: () => void;
|
|
103
|
+
clear: () => void;
|
|
104
|
+
}, "">;
|
|
105
|
+
type Cmp = ReturnType<typeof Cmp>;
|
|
106
|
+
export default Cmp;
|
|
@@ -80,7 +80,7 @@ declare function $$render<T>(): {
|
|
|
80
80
|
on?: {
|
|
81
81
|
/** Fires when the user picks/unpicks (and on group toggle-all). Emits the full new selection (array of values). */
|
|
82
82
|
change?: (value: T[]) => void;
|
|
83
|
-
/** Fires when the user confirms the create-with-parent section. `parentValue` is the chosen parent root's `value` from the parent dropdown — undefined when no parent was selected. Return a Promise to keep the spinner up. */
|
|
83
|
+
/** Fires when the user confirms the create-with-parent section. `query` is trimmed. `parentValue` is the chosen parent root's `value` from the parent dropdown — undefined when no parent was selected. Return a Promise to keep the spinner up. */
|
|
84
84
|
create?: (payload: {
|
|
85
85
|
query: string;
|
|
86
86
|
parentValue?: T;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { default as InputSuggest } from './cmp.input-suggest.svelte';
|
|
2
|
+
export type { InputSuggestInstance } from './cmp.input-suggest.svelte';
|
|
1
3
|
export { default as Singleselect } from './cmp.singleselect.svelte';
|
|
2
4
|
export type { SingleselectInstance } from './cmp.singleselect.svelte';
|
|
3
5
|
export { default as SingleselectAsync } from './cmp.singleselect-async.svelte';
|
|
@@ -9,4 +11,4 @@ export type { MultiselectAsyncInstance } from './cmp.multiselect-async.svelte';
|
|
|
9
11
|
export { default as MultiselectTree } from './cmp.multiselect-tree.svelte';
|
|
10
12
|
export type { MultiselectTreeInstance } from './cmp.multiselect-tree.svelte';
|
|
11
13
|
export { isSelectGroup } from './types';
|
|
12
|
-
export type { SelectCreateOptions, SelectItem, SelectOption, SelectOptionGroup } from './types';
|
|
14
|
+
export type { InputSuggestion, SelectCreateOptions, SelectItem, SelectOption, SelectOptionGroup } from './types';
|
package/dist/ui/select/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { default as InputSuggest } from './cmp.input-suggest.svelte';
|
|
1
2
|
export { default as Singleselect } from './cmp.singleselect.svelte';
|
|
2
3
|
export { default as SingleselectAsync } from './cmp.singleselect-async.svelte';
|
|
3
4
|
export { default as Multiselect } from './cmp.multiselect.svelte';
|
|
@@ -279,7 +279,7 @@ const handleDndFinalize = (e) => {
|
|
|
279
279
|
{#if selectedParent}
|
|
280
280
|
{localization.createIn}<span>"{selectedParent.label}"</span>
|
|
281
281
|
{:else}
|
|
282
|
-
{localization.createAsParent}<span>"{core.
|
|
282
|
+
{localization.createAsParent}<span>"{core.createQuery}"</span>
|
|
283
283
|
{/if}
|
|
284
284
|
</div>
|
|
285
285
|
<div class="multiselect__create-new-action">
|
|
@@ -13,7 +13,7 @@ export type SelectCoreConfig<T> = {
|
|
|
13
13
|
getCompare: () => (a: T, b: T) => boolean;
|
|
14
14
|
/** When false, typing never enters filtering mode — the popover is a click/keyboard-only picker. @default true */
|
|
15
15
|
getSearchable?: () => boolean;
|
|
16
|
-
/** Optional `createOptions.canCreate` validator — presence enables creatable mode. */
|
|
16
|
+
/** Optional `createOptions.canCreate` validator — presence enables creatable mode. Called with the trimmed query. */
|
|
17
17
|
getCanCreate: () => ((q: string) => boolean) | undefined;
|
|
18
18
|
/** When true, no `kind:'create'` row is emitted (the host renders its own create UI) — keyboard navigation must not reach a row the listbox doesn't show. @default false */
|
|
19
19
|
getHideCreateRow?: () => boolean;
|
|
@@ -52,9 +52,9 @@ export type SelectCoreConfig<T> = {
|
|
|
52
52
|
}) => void;
|
|
53
53
|
onClear: () => void;
|
|
54
54
|
/**
|
|
55
|
-
* Consumer's create handler. Receives the current query
|
|
56
|
-
*
|
|
57
|
-
* after the {@link SPINNER_DELAY_MS} gate.
|
|
55
|
+
* Consumer's create handler. Receives the current query — trimmed, since it becomes the new
|
|
56
|
+
* item's label — and (optionally) a `parentValue` picked via the create-with-parent section.
|
|
57
|
+
* May return a Promise — when it does, the spinner appears after the {@link SPINNER_DELAY_MS} gate.
|
|
58
58
|
*/
|
|
59
59
|
onCreate: (payload: {
|
|
60
60
|
query: string;
|
|
@@ -64,6 +64,8 @@ export type SelectCoreConfig<T> = {
|
|
|
64
64
|
export type SelectCore<T> = {
|
|
65
65
|
readonly isOpen: boolean;
|
|
66
66
|
readonly query: string;
|
|
67
|
+
/** `query` trimmed — what `canCreate` is asked about, what the create row shows, and what `onCreate` receives. Cmps rendering the typed text as a to-be-created label use this, not `query`. */
|
|
68
|
+
readonly createQuery: string;
|
|
67
69
|
readonly highlight: number;
|
|
68
70
|
readonly isFiltering: boolean;
|
|
69
71
|
readonly rows: SelectRow<T>[];
|
|
@@ -66,15 +66,17 @@ export function createSelectCore(config) {
|
|
|
66
66
|
};
|
|
67
67
|
const debouncedLoad = $derived(Utils.debounce((q) => void runLoad(q), config.getDebounceMs?.() ?? 400));
|
|
68
68
|
const flatOptions = $derived(flattenItems(items));
|
|
69
|
+
// Gate, row label and onCreate all read this: trimming at only one lets canCreate approve "a " and create "a".
|
|
70
|
+
const createQuery = $derived(query.trim());
|
|
69
71
|
const showCreateRow = $derived.by(() => {
|
|
70
72
|
const canCreate = config.getCanCreate();
|
|
71
|
-
if (!canCreate || !
|
|
73
|
+
if (!canCreate || !createQuery) {
|
|
72
74
|
return false;
|
|
73
75
|
}
|
|
74
|
-
if (!canCreate(
|
|
76
|
+
if (!canCreate(createQuery)) {
|
|
75
77
|
return false;
|
|
76
78
|
}
|
|
77
|
-
const q =
|
|
79
|
+
const q = createQuery.toLowerCase();
|
|
78
80
|
if (flatOptions.some((f) => f.option.label.toLowerCase() === q)) {
|
|
79
81
|
return false;
|
|
80
82
|
}
|
|
@@ -84,7 +86,7 @@ export function createSelectCore(config) {
|
|
|
84
86
|
const rows = $derived.by(() => {
|
|
85
87
|
const out = [];
|
|
86
88
|
if (showCreateRow && !config.getHideCreateRow?.()) {
|
|
87
|
-
out.push({ kind: 'create', query });
|
|
89
|
+
out.push({ kind: 'create', query: createQuery });
|
|
88
90
|
}
|
|
89
91
|
const compare = config.getCompare();
|
|
90
92
|
const hideSelected = config.getHideSelected?.() ?? false;
|
|
@@ -222,8 +224,8 @@ export function createSelectCore(config) {
|
|
|
222
224
|
return;
|
|
223
225
|
}
|
|
224
226
|
if (row.kind === 'create') {
|
|
225
|
-
if (
|
|
226
|
-
void handleCreate(
|
|
227
|
+
if (createQuery) {
|
|
228
|
+
void handleCreate(createQuery);
|
|
227
229
|
}
|
|
228
230
|
return;
|
|
229
231
|
}
|
|
@@ -346,6 +348,9 @@ export function createSelectCore(config) {
|
|
|
346
348
|
get query() {
|
|
347
349
|
return query;
|
|
348
350
|
},
|
|
351
|
+
get createQuery() {
|
|
352
|
+
return createQuery;
|
|
353
|
+
},
|
|
349
354
|
get highlight() {
|
|
350
355
|
return highlight;
|
|
351
356
|
},
|
|
@@ -384,10 +389,10 @@ export function createSelectCore(config) {
|
|
|
384
389
|
}
|
|
385
390
|
},
|
|
386
391
|
confirmCreate(parentValue) {
|
|
387
|
-
if (!
|
|
392
|
+
if (!createQuery || spinnerVisible) {
|
|
388
393
|
return;
|
|
389
394
|
}
|
|
390
|
-
void handleCreate(
|
|
395
|
+
void handleCreate(createQuery, parentValue);
|
|
391
396
|
}
|
|
392
397
|
};
|
|
393
398
|
}
|
|
@@ -47,9 +47,19 @@ export type SelectRowCreate = {
|
|
|
47
47
|
query: string;
|
|
48
48
|
};
|
|
49
49
|
export type SelectRow<T> = SelectRowOption<T> | SelectRowGroupHeader<T> | SelectRowCreate;
|
|
50
|
+
/**
|
|
51
|
+
* One advisory row of `InputSuggest`. Suggestions never constrain the typed value — they only
|
|
52
|
+
* offer a shortcut to a known string.
|
|
53
|
+
*/
|
|
54
|
+
export type InputSuggestion = {
|
|
55
|
+
/** Text substituted into the field when the row is picked — also the row's label. */
|
|
56
|
+
value: string;
|
|
57
|
+
/** Secondary line rendered under the value in a muted tone. */
|
|
58
|
+
description?: string;
|
|
59
|
+
};
|
|
50
60
|
/** Creatable mode — passing this object to a select enables the create UI. */
|
|
51
61
|
export type SelectCreateOptions = {
|
|
52
|
-
/** Validates each query —
|
|
62
|
+
/** Validates each query, trimmed — the same string the create row shows and `on.create` receives. Only when it returns true does the create UI appear (and the query isn't a duplicate of an existing label). */
|
|
53
63
|
canCreate: (query: string) => boolean;
|
|
54
64
|
/** Replaces the whole localized text in front of the typed query on the create row: `Create "foo"` → `Invite "foo"`, and drops the leading `+` glyph (the label fills the full row width). Not localized by the kit; pass already-translated text. Has no effect on the create-with-parent section, which keeps its own localized wording. */
|
|
55
65
|
prefix?: string;
|
|
@@ -211,7 +221,7 @@ export type MultiselectBaseProps<T> = {
|
|
|
211
221
|
on?: {
|
|
212
222
|
/** Fires when the user picks/unpicks/reorders. Emits the full new selection. */
|
|
213
223
|
change?: (value: SelectOption<T>[]) => void;
|
|
214
|
-
/** Fires when the user activates "Create …". When `parentValue` is present, the user picked it via the create-with-parent section. Return a Promise to keep the spinner up until the consumer finishes. */
|
|
224
|
+
/** Fires when the user activates "Create …". `query` is trimmed. When `parentValue` is present, the user picked it via the create-with-parent section. Return a Promise to keep the spinner up until the consumer finishes. */
|
|
215
225
|
create?: (payload: {
|
|
216
226
|
query: string;
|
|
217
227
|
parentValue?: T;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<script lang="ts">import { HtmlHelper } from '../../core/utils';
|
|
2
2
|
import { InputEmojiPicker } from '../_internal/input-emoji-picker';
|
|
3
|
-
const { value, size = 'md', disabled = false, readonly = false, inert = false, error = false, borderless = false, emoji = false, singleLine = false, submitOnEnter = false, placeholder = '', name = '', id = null, autofocus = false, maxLength = null, rows = 3, autoresize = true, maxRows = 12, autocomplete, spellcheck, 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, 'aria-required': ariaRequired, on } = $props();
|
|
3
|
+
const { value, size = 'md', disabled = false, readonly = false, inert = false, error = false, borderless = false, emoji = false, singleLine = false, submitOnEnter = false, trim = true, placeholder = '', name = '', id = null, autofocus = false, maxLength = null, rows = 3, autoresize = true, maxRows = 12, autocomplete, spellcheck, 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, 'aria-required': ariaRequired, on } = $props();
|
|
4
4
|
let textareaEl = $state.raw(undefined);
|
|
5
5
|
const sanitizeLineBreaks = (text) => text.replace(/[\r\n]+/g, ' ');
|
|
6
6
|
const valueParsed = $derived(singleLine && value ? sanitizeLineBreaks(value) : (value ?? ''));
|
|
7
7
|
const effectiveRows = $derived(singleLine ? 1 : rows);
|
|
8
|
+
const trimOnCommit = $derived(trim && !disabled && !readonly && !inert);
|
|
8
9
|
/** Imperative API exposed via `bind:this` on `<Textarea>`. */
|
|
9
10
|
export const focus = () => textareaEl?.focus();
|
|
10
11
|
export const select = () => textareaEl?.select();
|
|
@@ -40,10 +41,24 @@ const handleInput = () => {
|
|
|
40
41
|
resize();
|
|
41
42
|
on?.input?.(textareaEl.value);
|
|
42
43
|
};
|
|
44
|
+
const applyTrim = () => {
|
|
45
|
+
if (!trimOnCommit || !textareaEl || !HtmlHelper.trimElementValue(textareaEl)) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
handleInput();
|
|
49
|
+
return textareaEl.value;
|
|
50
|
+
};
|
|
51
|
+
const commitTrim = () => {
|
|
52
|
+
const trimmed = applyTrim();
|
|
53
|
+
if (trimmed !== null) {
|
|
54
|
+
on?.change?.(trimmed);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
43
57
|
const handleChange = () => {
|
|
44
58
|
if (!textareaEl) {
|
|
45
59
|
return;
|
|
46
60
|
}
|
|
61
|
+
applyTrim();
|
|
47
62
|
on?.change?.(textareaEl.value);
|
|
48
63
|
};
|
|
49
64
|
const handleBeforeInput = (e) => {
|
|
@@ -63,12 +78,16 @@ const handleBeforeInput = (e) => {
|
|
|
63
78
|
}
|
|
64
79
|
};
|
|
65
80
|
const handleKeydown = (e) => {
|
|
66
|
-
if (submitOnEnter && e.key === 'Enter' && !(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)) {
|
|
81
|
+
if (submitOnEnter && e.key === 'Enter' && !e.isComposing && !(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)) {
|
|
67
82
|
e.preventDefault();
|
|
83
|
+
// Full commit, unlike Input's Enter — a textarea fires no native `change`, so nothing follows this.
|
|
84
|
+
commitTrim();
|
|
68
85
|
on?.submit?.();
|
|
69
86
|
}
|
|
70
87
|
};
|
|
71
88
|
const handleBlur = () => {
|
|
89
|
+
// Before the re-sync: trimming after it re-emits a lagging prop as fresh input, losing the last edit.
|
|
90
|
+
commitTrim();
|
|
72
91
|
if (textareaEl) {
|
|
73
92
|
textareaEl.value = valueParsed;
|
|
74
93
|
}
|
|
@@ -131,6 +150,11 @@ Set `singleLine` to strip line breaks (paste-safe), `submitOnEnter` to fire `on.
|
|
|
131
150
|
plain Enter, `emoji` to show the inline emoji picker. Imperative `focus()` / `select()`
|
|
132
151
|
exposed via `bind:this`.
|
|
133
152
|
|
|
153
|
+
Leading / trailing whitespace, including any surrounding blank lines, is stripped at every commit
|
|
154
|
+
point — `change`, blur, and before `on.submit` — so the value the consumer receives always matches
|
|
155
|
+
what the field shows. Blank lines inside the text are left alone. Typing is untouched. Pass
|
|
156
|
+
`trim={false}` to opt out.
|
|
157
|
+
|
|
134
158
|
### CSS Custom Properties
|
|
135
159
|
| Property | Description | Default |
|
|
136
160
|
|---|---|---|
|
|
@@ -22,6 +22,13 @@ type Props = {
|
|
|
22
22
|
singleLine?: boolean;
|
|
23
23
|
/** Fire `on.submit` on Enter (without modifiers). Shift+Enter still inserts a newline. */
|
|
24
24
|
submitOnEnter?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Strip leading / trailing whitespace, including any surrounding blank lines, when the value is committed — on
|
|
27
|
+
* `change`, on blur, and before `on.submit`. Typing is never touched, so the committed value
|
|
28
|
+
* always equals what the field shows.
|
|
29
|
+
* @default true
|
|
30
|
+
*/
|
|
31
|
+
trim?: boolean;
|
|
25
32
|
placeholder?: string;
|
|
26
33
|
name?: string;
|
|
27
34
|
id?: string | null;
|
|
@@ -55,6 +62,11 @@ type Props = {
|
|
|
55
62
|
* plain Enter, `emoji` to show the inline emoji picker. Imperative `focus()` / `select()`
|
|
56
63
|
* exposed via `bind:this`.
|
|
57
64
|
*
|
|
65
|
+
* Leading / trailing whitespace, including any surrounding blank lines, is stripped at every commit
|
|
66
|
+
* point — `change`, blur, and before `on.submit` — so the value the consumer receives always matches
|
|
67
|
+
* what the field shows. Blank lines inside the text are left alone. Typing is untouched. Pass
|
|
68
|
+
* `trim={false}` to opt out.
|
|
69
|
+
*
|
|
58
70
|
* ### CSS Custom Properties
|
|
59
71
|
* | Property | Description | Default |
|
|
60
72
|
* |---|---|---|
|
|
@@ -4,9 +4,10 @@ let { value, size = 'md', prefix = 'https://', on, ...rest } = $props();
|
|
|
4
4
|
const localization = new WebsiteInputLocalization();
|
|
5
5
|
// Strip any pre-existing http/https prefix from the external value, so users always see just the host.
|
|
6
6
|
const stripPrefix = (raw) => raw.replace(/^https?:\/\//i, '');
|
|
7
|
+
// Blank test trimmed, value as typed: trimming here eats spaces mid-typing, `prefix + spaces` reads as filled.
|
|
7
8
|
const toFull = (display) => {
|
|
8
|
-
const cleaned = stripPrefix(display)
|
|
9
|
-
return cleaned === '' ? '' : prefix + cleaned;
|
|
9
|
+
const cleaned = stripPrefix(display);
|
|
10
|
+
return cleaned.trim() === '' ? '' : prefix + cleaned;
|
|
10
11
|
};
|
|
11
12
|
const displayValue = $derived(stripPrefix(value ?? ''));
|
|
12
13
|
const handleInput = (v) => on?.input?.(toFull(v));
|