@streamscloud/kit 0.88.0 → 0.89.1

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.
@@ -2,5 +2,5 @@ export type { CursorPayload, CursorResult } from './cursor-result';
2
2
  export { ContinuationToken } from './continuation-token';
3
3
  export { Deferred } from './deferred';
4
4
  export { AppEvent, EventDispatcher } from './event-dispatcher';
5
- export { generateHandle } from './handle-generator';
6
5
  export { createGQLClient } from './graphql';
6
+ export { generateHandle, HANDLE_DEFAULT_MAX_LENGTH, HANDLE_PATTERN } from './url-name';
@@ -2,5 +2,5 @@
2
2
  export { ContinuationToken } from './continuation-token';
3
3
  export { Deferred } from './deferred';
4
4
  export { AppEvent, EventDispatcher } from './event-dispatcher';
5
- export { generateHandle } from './handle-generator';
6
5
  export { createGQLClient } from './graphql';
6
+ export { generateHandle, HANDLE_DEFAULT_MAX_LENGTH, HANDLE_PATTERN } from './url-name';
@@ -0,0 +1,4 @@
1
+ export declare const HANDLE_DEFAULT_MAX_LENGTH = 80;
2
+ export declare const HANDLE_PATTERN: RegExp;
3
+ /** Mirrors the backend's `UrlName.From` (AnyAscii, same version on both sides) — the server compares names by this handle, so keep both in lockstep. */
4
+ export declare const generateHandle: (text: string | null | undefined, maxLength?: number) => string;
@@ -0,0 +1,19 @@
1
+ import anyAscii from 'any-ascii';
2
+ export const HANDLE_DEFAULT_MAX_LENGTH = 80;
3
+ export const HANDLE_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
4
+ const STARTS_WITH_SYMBOL = /^\p{S}/u;
5
+ const graphemes = new Intl.Segmenter('und', { granularity: 'grapheme' });
6
+ const dropSymbols = (text) => Array.from(graphemes.segment(text), ({ segment }) => segment)
7
+ .filter((segment) => !STARTS_WITH_SYMBOL.test(segment))
8
+ .join('');
9
+ /** Mirrors the backend's `UrlName.From` (AnyAscii, same version on both sides) — the server compares names by this handle, so keep both in lockstep. */
10
+ export const generateHandle = (text, maxLength = HANDLE_DEFAULT_MAX_LENGTH) => {
11
+ if (!text) {
12
+ return '';
13
+ }
14
+ const handle = anyAscii(dropSymbols(text))
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9]+/g, '-')
17
+ .replace(/^-+|-+$/g, '');
18
+ return handle.length <= maxLength ? handle : handle.slice(0, maxLength).replace(/-+$/, '');
19
+ };
@@ -11,6 +11,16 @@ export declare class HtmlHelper {
11
11
  * swallow the space in "hello world" as it is typed.
12
12
  */
13
13
  static trimElementValue(element: HTMLInputElement | HTMLTextAreaElement): boolean;
14
+ /**
15
+ * Rewrites a field's current value in place through `clean`, keeping the caret after the same
16
+ * character, and returns whether it changed. Dispatches nothing — the caller emits.
17
+ */
18
+ static cleanElementValue(element: HTMLInputElement | HTMLTextAreaElement, clean: (text: string) => string): boolean;
19
+ /**
20
+ * `paste` handler for a text field: when `clean` changes the pasted text, inserts the cleaned text instead as one
21
+ * native edit, so Cmd+Z undoes the paste like any other.
22
+ */
23
+ static pasteCleanedText(event: ClipboardEvent, clean: (text: string) => string): void;
14
24
  static pasteIntoInput(value: string, element: HTMLInputElement | HTMLTextAreaElement): void;
15
25
  /**
16
26
  * Sanitizes SVG markup and serializes it as XML, valid both inline in HTML and as a standalone `image/svg+xml` file.
@@ -93,6 +93,43 @@ export class HtmlHelper {
93
93
  element.value = trimmed;
94
94
  return true;
95
95
  }
96
+ /**
97
+ * Rewrites a field's current value in place through `clean`, keeping the caret after the same
98
+ * character, and returns whether it changed. Dispatches nothing — the caller emits.
99
+ */
100
+ static cleanElementValue(element, clean) {
101
+ const cleaned = clean(element.value);
102
+ if (cleaned === element.value) {
103
+ return false;
104
+ }
105
+ const caret = element.selectionEnd === null ? null : clean(element.value.slice(0, element.selectionEnd)).length;
106
+ element.value = cleaned;
107
+ if (caret !== null && document.activeElement === element) {
108
+ element.setSelectionRange(caret, caret);
109
+ }
110
+ return true;
111
+ }
112
+ /**
113
+ * `paste` handler for a text field: when `clean` changes the pasted text, inserts the cleaned text instead as one
114
+ * native edit, so Cmd+Z undoes the paste like any other.
115
+ */
116
+ static pasteCleanedText(event, clean) {
117
+ const text = event.clipboardData?.getData('text/plain');
118
+ if (!text) {
119
+ return;
120
+ }
121
+ const cleaned = clean(text);
122
+ if (cleaned === text) {
123
+ return;
124
+ }
125
+ event.preventDefault();
126
+ const field = event.currentTarget;
127
+ // Without it the browser merges the insert into the typing before it, and Cmd+Z undoes both at once.
128
+ if (field.selectionStart !== null && field.selectionEnd !== null) {
129
+ field.setSelectionRange(field.selectionStart, field.selectionEnd, field.selectionDirection ?? undefined);
130
+ }
131
+ document.execCommand('insertText', false, cleaned);
132
+ }
96
133
  static pasteIntoInput(value, element) {
97
134
  if (element.selectionStart || element.selectionStart === 0) {
98
135
  const startPos = element.selectionStart;
@@ -7,6 +7,16 @@ export declare class StringHelper {
7
7
  static toKebabCase(str: string): string;
8
8
  static toSnakeCase(str: string): string;
9
9
  static truncate(str: string, length: number): string;
10
+ /**
11
+ * Text for a single-line field: every line break (CR, LF, CRLF, VT, FF, NEL, U+2028, U+2029) and tab
12
+ * becomes a space; every other control character (C0, DEL, C1) is dropped.
13
+ */
14
+ static toSingleLineText(text: string): string;
15
+ /**
16
+ * Text for a multi-line field: every line break (CRLF, lone CR, VT, FF, NEL, U+2028, U+2029) becomes `\n`;
17
+ * `\n` and tab stay; every other control character (C0, DEL, C1) is dropped.
18
+ */
19
+ static toMultilineText(text: string): string;
10
20
  static capitalizeFirstLetter(word: string): string;
11
21
  static toFileSizeString(sizeBytes: number): string;
12
22
  }
@@ -50,6 +50,20 @@ export class StringHelper {
50
50
  str = str.slice(0, length) + '...';
51
51
  return str;
52
52
  }
53
+ /**
54
+ * Text for a single-line field: every line break (CR, LF, CRLF, VT, FF, NEL, U+2028, U+2029) and tab
55
+ * becomes a space; every other control character (C0, DEL, C1) is dropped.
56
+ */
57
+ static toSingleLineText(text) {
58
+ return text.replace(/\r\n|[\n\r\t\v\f\u0085\p{Zl}\p{Zp}]/gu, ' ').replace(/\p{Cc}/gu, '');
59
+ }
60
+ /**
61
+ * Text for a multi-line field: every line break (CRLF, lone CR, VT, FF, NEL, U+2028, U+2029) becomes `\n`;
62
+ * `\n` and tab stay; every other control character (C0, DEL, C1) is dropped.
63
+ */
64
+ static toMultilineText(text) {
65
+ return text.replace(/\r\n?|[\v\f\u0085\p{Zl}\p{Zp}]/gu, '\n').replace(/[^\P{Cc}\n\t]/gu, '');
66
+ }
53
67
  static capitalizeFirstLetter(word) {
54
68
  return word.charAt(0).toUpperCase() + word.slice(1);
55
69
  }
@@ -1,8 +1,8 @@
1
+ import { HANDLE_PATTERN } from '../../url-name';
1
2
  import { ValidationLocalization } from './validation-localization';
2
3
  import * as yup from 'yup';
3
4
  const DEFAULT_MIN_LENGTH = 3;
4
5
  const DEFAULT_MAX_LENGTH = 30;
5
- const HANDLE_REGEX = /^[a-z_]([a-z0-9_]|[.-](?![.-]))*[a-z0-9_]$/;
6
6
  const resolveHandleLength = (lengthValidation) => ({
7
7
  minLength: lengthValidation?.minLength ?? DEFAULT_MIN_LENGTH,
8
8
  maxLength: lengthValidation?.maxLength ?? DEFAULT_MAX_LENGTH
@@ -10,7 +10,7 @@ const resolveHandleLength = (lengthValidation) => ({
10
10
  /** Value-level rules of `handleValidationSchema` — within length bounds and matching the handle format. */
11
11
  export const isValidHandle = (value, lengthValidation) => {
12
12
  const { minLength, maxLength } = resolveHandleLength(lengthValidation);
13
- return value.length >= minLength && value.length <= maxLength && HANDLE_REGEX.test(value);
13
+ return value.length >= minLength && value.length <= maxLength && HANDLE_PATTERN.test(value);
14
14
  };
15
15
  export const handleValidationSchema = (lengthValidation) => {
16
16
  const msg = new ValidationLocalization();
@@ -20,5 +20,5 @@ export const handleValidationSchema = (lengthValidation) => {
20
20
  .required(msg.required)
21
21
  .min(minLength, msg.minLength(minLength))
22
22
  .max(maxLength, msg.maxLength(maxLength))
23
- .test('handle-format', msg.badFormat, (val) => !!val && HANDLE_REGEX.test(val));
23
+ .test('handle-format', msg.badFormat, (val) => !!val && HANDLE_PATTERN.test(val));
24
24
  };
@@ -1,4 +1,4 @@
1
- <script lang="ts">import { HtmlHelper } from '../../core/utils';
1
+ <script lang="ts">import { HtmlHelper, StringHelper } from '../../core/utils';
2
2
  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';
@@ -25,6 +25,9 @@ const handleInput = () => {
25
25
  if (!inputEl) {
26
26
  return;
27
27
  }
28
+ if (type !== 'password') {
29
+ HtmlHelper.cleanElementValue(inputEl, StringHelper.toSingleLineText);
30
+ }
28
31
  on?.input?.(inputEl.value);
29
32
  };
30
33
  const applyTrim = () => {
@@ -71,6 +74,11 @@ const handleBlur = () => {
71
74
  }
72
75
  on?.blur?.();
73
76
  };
77
+ const handlePaste = (event) => {
78
+ if (type !== 'password') {
79
+ HtmlHelper.pasteCleanedText(event, StringHelper.toSingleLineText);
80
+ }
81
+ };
74
82
  const handleKeydown = (event) => {
75
83
  // Input only — the native `change` Enter fires next announces the commit; emitting it here doubles it.
76
84
  if (event.key === 'Enter' && !event.isComposing) {
@@ -133,6 +141,7 @@ const focusInput = () => inputEl?.focus();
133
141
  aria-required={ariaRequired ? 'true' : undefined}
134
142
  oninput={handleInput}
135
143
  onchange={handleChange}
144
+ onpaste={handlePaste}
136
145
  onblur={handleBlur}
137
146
  onfocus={() => on?.focus?.()}
138
147
  onkeydown={handleKeydown} />
@@ -173,6 +182,9 @@ Leading / trailing whitespace is stripped at every commit point — `change`, bl
173
182
  value the consumer receives always matches what the field shows. Typing is untouched. Pass
174
183
  `trim={false}` to opt out; `type="password"` never trims.
175
184
 
185
+ Entered text is cleaned as it arrives — typed, pasted, dropped or autofilled: line breaks and tabs
186
+ become spaces, other control characters are dropped. `type="password"` is never cleaned.
187
+
176
188
  Two snippet slots widen the chrome: `prefix` for a full-height segmented leading addon (see
177
189
  `WebsiteInput`), and `suffix` for a free-form trailing addon rendered before any trailing icons
178
190
  or the clear button (see `HandleInput`'s availability pill).
@@ -10,6 +10,9 @@ import type { InputProps } from './types';
10
10
  * value the consumer receives always matches what the field shows. Typing is untouched. Pass
11
11
  * `trim={false}` to opt out; `type="password"` never trims.
12
12
  *
13
+ * Entered text is cleaned as it arrives — typed, pasted, dropped or autofilled: line breaks and tabs
14
+ * become spaces, other control characters are dropped. `type="password"` is never cleaned.
15
+ *
13
16
  * Two snippet slots widen the chrome: `prefix` for a full-height segmented leading addon (see
14
17
  * `WebsiteInput`), and `suffix` for a free-form trailing addon rendered before any trailing icons
15
18
  * or the clear button (see `HandleInput`'s availability pill).
@@ -1,9 +1,9 @@
1
- <script lang="ts">import { HtmlHelper } from '../../core/utils';
1
+ <script lang="ts">import { HtmlHelper, StringHelper } from '../../core/utils';
2
2
  import { InputEmojiPicker } from '../_internal/input-emoji-picker';
3
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
- const sanitizeLineBreaks = (text) => text.replace(/[\r\n]+/g, ' ');
6
- const valueParsed = $derived(singleLine && value ? sanitizeLineBreaks(value) : (value ?? ''));
5
+ const cleanText = $derived(singleLine ? StringHelper.toSingleLineText : StringHelper.toMultilineText);
6
+ const valueParsed = $derived(singleLine && value ? cleanText(value) : (value ?? ''));
7
7
  const effectiveRows = $derived(singleLine ? 1 : rows);
8
8
  const trimOnCommit = $derived(trim && !disabled && !readonly && !inert);
9
9
  /** Imperative API exposed via `bind:this` on `<Textarea>`. */
@@ -38,6 +38,7 @@ const handleInput = () => {
38
38
  if (!textareaEl) {
39
39
  return;
40
40
  }
41
+ HtmlHelper.cleanElementValue(textareaEl, cleanText);
41
42
  resize();
42
43
  on?.input?.(textareaEl.value);
43
44
  };
@@ -62,21 +63,11 @@ const handleChange = () => {
62
63
  on?.change?.(textareaEl.value);
63
64
  };
64
65
  const handleBeforeInput = (e) => {
65
- if (!singleLine || !textareaEl) {
66
- return;
67
- }
68
- if (e.inputType === 'insertLineBreak' || e.inputType === 'insertParagraph') {
69
- e.preventDefault();
70
- return;
71
- }
72
- if (e.data && /[\r\n]/.test(e.data)) {
66
+ if (singleLine && (e.inputType === 'insertLineBreak' || e.inputType === 'insertParagraph')) {
73
67
  e.preventDefault();
74
- const start = textareaEl.selectionStart;
75
- const end = textareaEl.selectionEnd;
76
- textareaEl.setRangeText(sanitizeLineBreaks(e.data), start, end, 'end');
77
- textareaEl.dispatchEvent(new Event('input', { bubbles: true }));
78
68
  }
79
69
  };
70
+ const handlePaste = (e) => HtmlHelper.pasteCleanedText(e, cleanText);
80
71
  const handleKeydown = (e) => {
81
72
  if (submitOnEnter && e.key === 'Enter' && !e.isComposing && !(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)) {
82
73
  e.preventDefault();
@@ -134,6 +125,7 @@ const focusTextarea = () => textareaEl?.focus();
134
125
  oninput={handleInput}
135
126
  onchange={handleChange}
136
127
  onbeforeinput={handleBeforeInput}
128
+ onpaste={handlePaste}
137
129
  onkeydown={handleKeydown}
138
130
  onblur={handleBlur}
139
131
  onfocus={() => on?.focus?.()}></textarea>
@@ -157,6 +149,10 @@ point — `change`, blur, and before `on.submit` — so the value the consumer r
157
149
  what the field shows. Blank lines inside the text are left alone. Typing is untouched. Pass
158
150
  `trim={false}` to opt out.
159
151
 
152
+ Entered text is cleaned as it arrives — typed, pasted or dropped: every line break (including Word's
153
+ soft break) becomes `\n`, other control characters except tab are dropped. In `singleLine` mode line breaks and tabs
154
+ become spaces instead.
155
+
160
156
  ### CSS Custom Properties
161
157
  | Property | Description | Default |
162
158
  |---|---|---|
@@ -67,6 +67,10 @@ type Props = {
67
67
  * what the field shows. Blank lines inside the text are left alone. Typing is untouched. Pass
68
68
  * `trim={false}` to opt out.
69
69
  *
70
+ * Entered text is cleaned as it arrives — typed, pasted or dropped: every line break (including Word's
71
+ * soft break) becomes `\n`, other control characters except tab are dropped. In `singleLine` mode line breaks and tabs
72
+ * become spaces instead.
73
+ *
70
74
  * ### CSS Custom Properties
71
75
  * | Property | Description | Default |
72
76
  * |---|---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamscloud/kit",
3
- "version": "0.88.0",
3
+ "version": "0.89.1",
4
4
  "author": "StreamsCloud",
5
5
  "repository": {
6
6
  "type": "git",
@@ -488,6 +488,7 @@
488
488
  "@fontsource/jetbrains-mono": "^5.2.8",
489
489
  "@fontsource/source-sans-pro": "^5.2.5",
490
490
  "@urql/core": "^6.0.1",
491
+ "any-ascii": "^0.3.3",
491
492
  "cleave-zen": "^0.0.17",
492
493
  "colord": "^2.9.3",
493
494
  "dequal": "^2.0.3",
@@ -518,6 +519,7 @@
518
519
  "@tsconfig/svelte": "^5.0.8",
519
520
  "@types/node": "^26.6.1",
520
521
  "@urql/core": "^6.0.1",
522
+ "any-ascii": "^0.3.3",
521
523
  "autoprefixer": "^10.6.1",
522
524
  "cleave-zen": "^0.0.17",
523
525
  "colord": "^2.9.3",
@@ -1 +0,0 @@
1
- export declare const generateHandle: (base: string | null | undefined) => string;
@@ -1,32 +0,0 @@
1
- const SPEC_SYMBOLS = [
2
- { symbol: 'å', replacement: 'a' },
3
- { symbol: 'Å', replacement: 'A' },
4
- { symbol: 'æ', replacement: 'ae' },
5
- { symbol: 'Æ', replacement: 'Ae' },
6
- { symbol: 'ø', replacement: 'o' },
7
- { symbol: 'Ø', replacement: 'O' },
8
- { symbol: 'ö', replacement: 'o' },
9
- { symbol: 'Ö', replacement: 'O' },
10
- { symbol: 'ä', replacement: 'a' },
11
- { symbol: 'Ä', replacement: 'A' }
12
- ];
13
- export const generateHandle = (base) => {
14
- if (!base) {
15
- return '';
16
- }
17
- let handle = base;
18
- // Replace nordic/special chars
19
- for (const { symbol, replacement } of SPEC_SYMBOLS) {
20
- handle = handle.replace(new RegExp(symbol, 'g'), replacement);
21
- }
22
- handle = handle
23
- // Insert hyphen before uppercase (camelCase → kebab-case)
24
- .replace(/([a-z])([A-Z])/g, '$1-$2')
25
- // Lowercase
26
- .toLowerCase()
27
- // Replace any non-alphanumeric with hyphen
28
- .replace(/[^a-z0-9]+/g, '-')
29
- // Remove leading/trailing hyphens
30
- .replace(/^-+|-+$/g, '');
31
- return handle;
32
- };