@streamscloud/kit 0.32.0 → 0.34.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/cmp.input-suggest.svelte +22 -4
- package/dist/ui/select/cmp.input-suggest.svelte.d.ts +9 -0
- package/dist/ui/select/cmp.multiselect-tree.svelte +5 -1
- package/dist/ui/select/cmp.multiselect-tree.svelte.d.ts +10 -3
- 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 +2 -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'>;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<script lang="ts" module>export {};
|
|
2
2
|
</script>
|
|
3
3
|
|
|
4
|
-
<script lang="ts">import {
|
|
4
|
+
<script lang="ts">import { HtmlHelper } from '../../core/utils';
|
|
5
|
+
import { InputClearButton } from '../_internal/input-clear-button';
|
|
5
6
|
import { IconSlot } from '../icon';
|
|
6
7
|
import { default as SelectListbox } from './select-listbox.svelte';
|
|
7
|
-
const { value, suggestions, size = 'md', placeholder = '', disabled = false, readonly = false, inert = false, error = false, borderless = false, clearable = false, icon, name, id, autocomplete = 'off', 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedby, 'aria-required': ariaRequired, on } = $props();
|
|
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();
|
|
8
9
|
let inputEl = $state.raw(undefined);
|
|
9
10
|
let rootEl = $state.raw(undefined);
|
|
10
11
|
let isOpen = $state(false);
|
|
@@ -66,7 +67,13 @@ const commitValue = (next) => {
|
|
|
66
67
|
on?.change?.(next);
|
|
67
68
|
};
|
|
68
69
|
const flushCommit = () => {
|
|
69
|
-
if (inputEl
|
|
70
|
+
if (!inputEl) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (trim && isInteractive && HtmlHelper.trimElementValue(inputEl)) {
|
|
74
|
+
on?.input?.(inputEl.value);
|
|
75
|
+
}
|
|
76
|
+
if (inputEl.value !== lastCommitted) {
|
|
70
77
|
commitValue(inputEl.value);
|
|
71
78
|
}
|
|
72
79
|
};
|
|
@@ -137,7 +144,12 @@ const handleInput = () => {
|
|
|
137
144
|
}
|
|
138
145
|
on?.input?.(inputEl.value);
|
|
139
146
|
};
|
|
147
|
+
const highlightedRow = () => (isPanelOpen && activeIndex >= 0 ? rows[activeIndex] : undefined);
|
|
140
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
|
+
}
|
|
141
153
|
on?.keydown?.(event);
|
|
142
154
|
if (!isInteractive) {
|
|
143
155
|
return;
|
|
@@ -160,7 +172,10 @@ const handleKeydown = (event) => {
|
|
|
160
172
|
return;
|
|
161
173
|
}
|
|
162
174
|
if (event.key === 'Enter') {
|
|
163
|
-
|
|
175
|
+
if (event.isComposing) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const row = highlightedRow();
|
|
164
179
|
if (row?.kind === 'option') {
|
|
165
180
|
event.preventDefault();
|
|
166
181
|
applySuggestion(row.option.value);
|
|
@@ -270,6 +285,9 @@ suggestions, Enter substitutes the highlighted one (and is left alone when nothi
|
|
|
270
285
|
highlighted), Escape closes without touching the text, Tab closes and keeps what was typed.
|
|
271
286
|
Trigger chrome, popover and rows are shared with the selects, minus the chevron.
|
|
272
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
|
+
|
|
273
291
|
### CSS Custom Properties
|
|
274
292
|
| Property | Description | Default |
|
|
275
293
|
|---|---|---|
|
|
@@ -27,6 +27,12 @@ type Props = {
|
|
|
27
27
|
borderless?: boolean;
|
|
28
28
|
/** Show the × clear button when the value is non-empty. */
|
|
29
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;
|
|
30
36
|
/** Leading icon — string SVG source, `{ src, color?, size? }` object, or custom snippet. */
|
|
31
37
|
icon?: IconProp;
|
|
32
38
|
name?: string;
|
|
@@ -65,6 +71,9 @@ type Props = {
|
|
|
65
71
|
* highlighted), Escape closes without touching the text, Tab closes and keeps what was typed.
|
|
66
72
|
* Trigger chrome, popover and rows are shared with the selects, minus the chevron.
|
|
67
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
|
+
*
|
|
68
77
|
* ### CSS Custom Properties
|
|
69
78
|
* | Property | Description | Default |
|
|
70
79
|
* |---|---|---|
|
|
@@ -82,7 +82,11 @@ $effect(() => {
|
|
|
82
82
|
MultiselectTree — sync, grouped multi-value picker with cascade + tri-state behavior and an
|
|
83
83
|
optional create-with-parent section. Tree of one level: parent groups (each a
|
|
84
84
|
`SelectOptionGroup<T>`) with child options; cascade-aware toggle on group headers; tri-state
|
|
85
|
-
checkbox visual when `selectionMode='children-include-parent'`.
|
|
85
|
+
checkbox visual when `selectionMode='children-include-parent'`.
|
|
86
|
+
|
|
87
|
+
`groupHeader='value'` + `selectionMode='children-only'` turns the parent into an ordinary
|
|
88
|
+
independent checkbox instead: clicking it picks the group's own `value` and never touches the
|
|
89
|
+
children, and the header's checkbox is binary rather than tri-state. Selection is visible inside
|
|
86
90
|
the popover (checkbox rows) AND as chips inside the trigger by default; pass an empty
|
|
87
91
|
`selectionSnippet` to hide the chip row entirely, or a custom one to render a count badge
|
|
88
92
|
or any summary instead.
|
|
@@ -36,10 +36,13 @@ declare function $$render<T>(): {
|
|
|
36
36
|
/**
|
|
37
37
|
* Group-header behavior:
|
|
38
38
|
* - `'static'` — non-interactive label.
|
|
39
|
+
* - `'value'` — clicking the header picks the group's own `value` and nothing else; the header is an ordinary
|
|
40
|
+
* binary checkbox, independent of its children. Requires a `value` on the group, otherwise the header stays
|
|
41
|
+
* inert. Pair with `selectionMode='children-only'` for a fully independent parent.
|
|
39
42
|
* - `'toggle-all'` (default) — clicking the header toggles every child option at once; tri-state checkbox when `selectionMode='children-include-parent'`.
|
|
40
43
|
* @default 'toggle-all'
|
|
41
44
|
*/
|
|
42
|
-
groupHeader?: "static" | "toggle-all";
|
|
45
|
+
groupHeader?: "static" | "value" | "toggle-all";
|
|
43
46
|
/**
|
|
44
47
|
* Cascade between group.value and children:
|
|
45
48
|
* - `'children-only'` (default) — picking a child adds only the child; group's own `value` stays independent.
|
|
@@ -80,7 +83,7 @@ declare function $$render<T>(): {
|
|
|
80
83
|
on?: {
|
|
81
84
|
/** Fires when the user picks/unpicks (and on group toggle-all). Emits the full new selection (array of values). */
|
|
82
85
|
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. */
|
|
86
|
+
/** 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
87
|
create?: (payload: {
|
|
85
88
|
query: string;
|
|
86
89
|
parentValue?: T;
|
|
@@ -118,7 +121,11 @@ interface $$IsomorphicComponent {
|
|
|
118
121
|
* MultiselectTree — sync, grouped multi-value picker with cascade + tri-state behavior and an
|
|
119
122
|
* optional create-with-parent section. Tree of one level: parent groups (each a
|
|
120
123
|
* `SelectOptionGroup<T>`) with child options; cascade-aware toggle on group headers; tri-state
|
|
121
|
-
* checkbox visual when `selectionMode='children-include-parent'`.
|
|
124
|
+
* checkbox visual when `selectionMode='children-include-parent'`.
|
|
125
|
+
*
|
|
126
|
+
* `groupHeader='value'` + `selectionMode='children-only'` turns the parent into an ordinary
|
|
127
|
+
* independent checkbox instead: clicking it picks the group's own `value` and never touches the
|
|
128
|
+
* children, and the header's checkbox is binary rather than tri-state. Selection is visible inside
|
|
122
129
|
* the popover (checkbox rows) AND as chips inside the trigger by default; pass an empty
|
|
123
130
|
* `selectionSnippet` to hide the chip row entirely, or a custom one to render a count badge
|
|
124
131
|
* or any summary instead.
|
|
@@ -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
|
}
|
|
@@ -59,7 +59,7 @@ export type InputSuggestion = {
|
|
|
59
59
|
};
|
|
60
60
|
/** Creatable mode — passing this object to a select enables the create UI. */
|
|
61
61
|
export type SelectCreateOptions = {
|
|
62
|
-
/** 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). */
|
|
63
63
|
canCreate: (query: string) => boolean;
|
|
64
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. */
|
|
65
65
|
prefix?: string;
|
|
@@ -221,7 +221,7 @@ export type MultiselectBaseProps<T> = {
|
|
|
221
221
|
on?: {
|
|
222
222
|
/** Fires when the user picks/unpicks/reorders. Emits the full new selection. */
|
|
223
223
|
change?: (value: SelectOption<T>[]) => void;
|
|
224
|
-
/** 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. */
|
|
225
225
|
create?: (payload: {
|
|
226
226
|
query: string;
|
|
227
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));
|