@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.
@@ -5,6 +5,12 @@ export declare class HtmlHelper {
5
5
  * Replaces reserved html characters with html entities e.g. '<' with '&lt;'
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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
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={(event) => on?.keydown?.(event)} />
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).
@@ -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 { InputClearButton } from '../_internal/input-clear-button';
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 && inputEl.value !== lastCommitted) {
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
- const row = isPanelOpen && activeIndex >= 0 ? rows[activeIndex] : undefined;
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'`. Selection is visible inside
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'`. Selection is visible inside
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.query}"</span>
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 and (optionally) a `parentValue` picked
56
- * via the create-with-parent section. May return a Promise when it does, the spinner appears
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 || !query.trim()) {
73
+ if (!canCreate || !createQuery) {
72
74
  return false;
73
75
  }
74
- if (!canCreate(query)) {
76
+ if (!canCreate(createQuery)) {
75
77
  return false;
76
78
  }
77
- const q = query.trim().toLowerCase();
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 (query.trim()) {
226
- void handleCreate(query);
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 (!query.trim() || spinnerVisible) {
392
+ if (!createQuery || spinnerVisible) {
388
393
  return;
389
394
  }
390
- void handleCreate(query, parentValue);
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 — only when it returns true does the create UI appear (and the query isn't a duplicate of an existing label). */
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).trim();
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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamscloud/kit",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
4
4
  "author": "StreamsCloud",
5
5
  "repository": {
6
6
  "type": "git",