@dorsk/tsumikit 0.82.1 → 0.82.2

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/README.md CHANGED
@@ -944,6 +944,36 @@ opaque and the blur is dropped; `prefers-contrast: more` also uses the opaque sc
944
944
  </Backdrop>
945
945
  ```
946
946
 
947
+ ### Masked spans while typing (Textarea `segments`)
948
+
949
+ `Textarea` (and `Composer`, which forwards it) accepts `segments`, a function that splits the
950
+ current value into spans for a mirror overlay rendered behind a text-transparent native textarea.
951
+ Each segment's `text` is rendered verbatim so glyph widths match 1:1; `mask: true` keeps the span's
952
+ layout but hides its glyphs under a token fill (`--textarea-mask` overrides it); `class` is added
953
+ to the span. The texts must concatenate back to the value. Caret, selection, IME composition, undo,
954
+ autoresize and scrolling stay native. The overlay is `aria-hidden`: assistive tech and the form
955
+ still get the raw value. Without `segments` the Textarea renders exactly as before.
956
+
957
+ ```svelte
958
+ <script lang="ts">
959
+ import { Textarea } from '@dorsk/tsumikit';
960
+ const TAG = /(<redacted>)([\s\S]*?)(<\/redacted>)/g;
961
+ function segments(text: string) {
962
+ const out = [];
963
+ let last = 0;
964
+ for (const m of text.matchAll(TAG)) {
965
+ if (m.index > last) out.push({ text: text.slice(last, m.index) });
966
+ out.push({ text: m[1], class: 'tag' }, { text: m[2], mask: true }, { text: m[3], class: 'tag' });
967
+ last = m.index + m[0].length;
968
+ }
969
+ if (last < text.length) out.push({ text: text.slice(last) });
970
+ return out;
971
+ }
972
+ </script>
973
+
974
+ <Textarea bind:value autoresize {segments} aria-label="Message" />
975
+ ```
976
+
947
977
  ### Breakpoints
948
978
 
949
979
  One list for every media query: `sm` 40rem (640px), `md` 48rem (768px), `lg`
@@ -18,11 +18,20 @@
18
18
  // `onsubmit` fires with the value on Enter (`submitOn="enter"`, Shift+Enter
19
19
  // still inserts a newline) or Ctrl/Meta+Enter (`submitOn="mod-enter"`, the
20
20
  // default whenever `onsubmit` is given).
21
+ //
22
+ // `segments` opts into a mirror overlay: a layer behind the (text-transparent)
23
+ // textarea that renders the value split into spans, so tagged spans can show
24
+ // masked while the user types. The native control keeps caret, selection, IME,
25
+ // undo, autoresize and scrolling; the overlay shares its box, font and
26
+ // wrapping rules and follows its scroll offset. The raw value stays what
27
+ // assistive tech reads and what the form submits.
21
28
  import type { HTMLTextareaAttributes } from 'svelte/elements';
22
29
  import { autoresize as autoresizeAction, rowsFloor, rowsHeight } from '../../autoresize';
23
30
  import { getFieldContext, warnUnlabelled } from '../../field-context';
24
31
  import { getInputGroupContext } from '../../input-group-context';
25
32
 
33
+ export type TextareaSegment = { text: string; mask?: boolean; class?: string };
34
+
26
35
  type Props = Omit<HTMLTextareaAttributes, 'onsubmit'> & {
27
36
  mono?: boolean;
28
37
  autoresize?: boolean;
@@ -45,6 +54,11 @@
45
54
  /** Fires with the current value on the `submitOn` key combo. */
46
55
  onsubmit?: (value: string) => void;
47
56
  submitOn?: 'enter' | 'mod-enter' | 'none';
57
+ /** Split the value into spans for the mirror overlay; the texts must
58
+ * concatenate back to the value so glyphs line up 1:1. `mask` keeps a
59
+ * span's layout but hides its glyphs under a fill; `class` is added to
60
+ * the span. Unset: no overlay, no extra DOM. */
61
+ segments?: (value: string) => TextareaSegment[];
48
62
  id?: string;
49
63
  'aria-describedby'?: string | null;
50
64
  'aria-invalid'?: HTMLTextareaAttributes['aria-invalid'];
@@ -66,7 +80,10 @@
66
80
  maxHeight,
67
81
  onsubmit,
68
82
  submitOn = 'none',
83
+ segments,
69
84
  onkeydown,
85
+ oninput,
86
+ onscroll,
70
87
  id,
71
88
  'aria-describedby': ariaDescribedby,
72
89
  'aria-invalid': ariaInvalid,
@@ -136,6 +153,27 @@
136
153
  onsubmit(String(value ?? ''));
137
154
  }
138
155
 
156
+ let mirror = $state<HTMLDivElement | null>(null);
157
+ const segs = $derived(segments ? segments(String(value ?? '')) : null);
158
+
159
+ function syncScroll() {
160
+ if (!el || !mirror) return;
161
+ mirror.scrollTop = el.scrollTop;
162
+ mirror.scrollLeft = el.scrollLeft;
163
+ }
164
+ function handleInput(e: Event & { currentTarget: EventTarget & HTMLTextAreaElement }) {
165
+ oninput?.(e);
166
+ if (segs) syncScroll();
167
+ }
168
+ function handleScroll(e: UIEvent & { currentTarget: EventTarget & HTMLTextAreaElement }) {
169
+ onscroll?.(e);
170
+ if (segs) syncScroll();
171
+ }
172
+ $effect(() => {
173
+ void segs;
174
+ syncScroll();
175
+ });
176
+
139
177
  // --- manual resize drag (mirrors AppShell/Modal: rAF-throttled pointer drag) ---
140
178
  let dragging = $state(false);
141
179
  let rafId = 0;
@@ -195,7 +233,23 @@
195
233
  class:grouped={!!group}
196
234
  class:bar={!!group?.bar}
197
235
  data-tsu="Textarea"
236
+ class:mirrored={!!segs}
198
237
  >
238
+ {#if segs}
239
+ <div
240
+ bind:this={mirror}
241
+ class="mirror textarea"
242
+ class:mono
243
+ class:textarea-sm={sizeEff === 'sm'}
244
+ class:textarea-lg={sizeEff === 'lg'}
245
+ class:grouped={!!group}
246
+ class:bar={!!group?.bar}
247
+ class:capped={!!maxHeight}
248
+ style:max-height={maxHeight}
249
+ aria-hidden="true"
250
+ data-tsu="TextareaMirror"
251
+ >{#each segs as seg}<span class={seg.class} class:masked={!!seg.mask}>{seg.text}</span>{/each}<span class="tail">{'\u200b'}</span></div>
252
+ {/if}
199
253
  {#if autoresize}
200
254
  <textarea
201
255
  bind:this={el}
@@ -206,6 +260,7 @@
206
260
  class:grouped={!!group}
207
261
  class:bar={!!group?.bar}
208
262
  class:capped={!!maxHeight}
263
+ class:mirrored={!!segs}
209
264
  disabled={isDisabled}
210
265
  style:max-height={maxHeight}
211
266
  bind:value
@@ -213,6 +268,8 @@
213
268
  {...rest}
214
269
  placeholder={rest.placeholder ?? (field ? ' ' : undefined)}
215
270
  onkeydown={onsubmit || onkeydown ? handleKeydown : undefined}
271
+ oninput={segs || oninput ? handleInput : undefined}
272
+ onscroll={segs || onscroll ? handleScroll : undefined}
216
273
  id={id ?? field?.id}
217
274
  aria-describedby={ariaDescribedby ?? field?.describedBy}
218
275
  aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
@@ -227,12 +284,15 @@
227
284
  class:grouped={!!group}
228
285
  class:bar={!!group?.bar}
229
286
  class:capped={!!maxHeight}
287
+ class:mirrored={!!segs}
230
288
  disabled={isDisabled}
231
289
  style:max-height={maxHeight}
232
290
  bind:value
233
291
  {...rest}
234
292
  placeholder={rest.placeholder ?? (field ? ' ' : undefined)}
235
293
  onkeydown={onsubmit || onkeydown ? handleKeydown : undefined}
294
+ oninput={segs || oninput ? handleInput : undefined}
295
+ onscroll={segs || onscroll ? handleScroll : undefined}
236
296
  id={id ?? field?.id}
237
297
  aria-describedby={ariaDescribedby ?? field?.describedBy}
238
298
  aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
@@ -294,6 +354,47 @@
294
354
  font-size: var(--control-fs-md);
295
355
  line-height: var(--lh-tight);
296
356
  font-family: inherit;
357
+ white-space: pre-wrap;
358
+ overflow-wrap: break-word;
359
+ word-break: normal;
360
+ tab-size: 4;
361
+ }
362
+ /* Mirror overlay: same box, font and wrapping as the textarea; the textarea
363
+ above it keeps its border, caret and selection but shows no glyphs. */
364
+ .textarea-wrap.mirrored {
365
+ isolation: isolate;
366
+ }
367
+ .mirror {
368
+ position: absolute;
369
+ inset: 0;
370
+ z-index: 0;
371
+ box-sizing: border-box;
372
+ min-height: 0;
373
+ overflow: auto;
374
+ border-color: transparent;
375
+ box-shadow: none;
376
+ pointer-events: none;
377
+ transition: none;
378
+ }
379
+ .textarea.mirrored {
380
+ position: relative;
381
+ z-index: 1;
382
+ color: transparent;
383
+ caret-color: var(--textarea-fg, var(--text));
384
+ background: transparent;
385
+ }
386
+ .textarea.mirrored::selection {
387
+ color: transparent;
388
+ background: color-mix(in srgb, var(--accent) 30%, transparent);
389
+ }
390
+ .mirrored .resize-handle {
391
+ z-index: 2;
392
+ }
393
+ .mirror .masked {
394
+ color: transparent;
395
+ background: var(--textarea-mask, color-mix(in srgb, var(--text) 18%, transparent));
396
+ border-radius: var(--r-sm);
397
+ box-decoration-break: clone;
297
398
  }
298
399
  .textarea:focus {
299
400
  outline: none;
@@ -1,5 +1,10 @@
1
1
  import type { ControlSize } from '../../size';
2
2
  import type { HTMLTextareaAttributes } from 'svelte/elements';
3
+ export type TextareaSegment = {
4
+ text: string;
5
+ mask?: boolean;
6
+ class?: string;
7
+ };
3
8
  type Props = Omit<HTMLTextareaAttributes, 'onsubmit'> & {
4
9
  mono?: boolean;
5
10
  autoresize?: boolean;
@@ -22,6 +27,11 @@ type Props = Omit<HTMLTextareaAttributes, 'onsubmit'> & {
22
27
  /** Fires with the current value on the `submitOn` key combo. */
23
28
  onsubmit?: (value: string) => void;
24
29
  submitOn?: 'enter' | 'mod-enter' | 'none';
30
+ /** Split the value into spans for the mirror overlay; the texts must
31
+ * concatenate back to the value so glyphs line up 1:1. `mask` keeps a
32
+ * span's layout but hides its glyphs under a fill; `class` is added to
33
+ * the span. Unset: no overlay, no extra DOM. */
34
+ segments?: (value: string) => TextareaSegment[];
25
35
  id?: string;
26
36
  'aria-describedby'?: string | null;
27
37
  'aria-invalid'?: HTMLTextareaAttributes['aria-invalid'];
@@ -6,7 +6,7 @@
6
6
  // `toolbarEnd` move attach + send into a row under the field.
7
7
  import type { Snippet } from 'svelte';
8
8
  import Button from '../atoms/Button.svelte';
9
- import Textarea from '../atoms/Textarea.svelte';
9
+ import Textarea, { type TextareaSegment } from '../atoms/Textarea.svelte';
10
10
  import AttachmentList from './AttachmentList.svelte';
11
11
  import FileButton from './FileButton.svelte';
12
12
  import InputGroup from './InputGroup.svelte';
@@ -26,6 +26,7 @@
26
26
  maxHeight = '40vh',
27
27
  rows = 1,
28
28
  resize = 'none',
29
+ segments,
29
30
  sendLabel = 'Send',
30
31
  attachLabel = 'Attach',
31
32
  leading,
@@ -52,6 +53,8 @@
52
53
  rows?: number;
53
54
  /** Manual drag handle on the top edge; `none` hides it. */
54
55
  resize?: 'none' | 'top';
56
+ /** Mirror overlay segments, forwarded to the Textarea. */
57
+ segments?: (value: string) => TextareaSegment[];
55
58
  sendLabel?: string;
56
59
  attachLabel?: string;
57
60
  leading?: Snippet;
@@ -200,6 +203,7 @@
200
203
  {maxHeight}
201
204
  {placeholder}
202
205
  {resize}
206
+ {segments}
203
207
  aria-label={placeholder}
204
208
  disabled={disabled || busy}
205
209
  {onkeydown}
@@ -1,4 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import { type TextareaSegment } from '../atoms/Textarea.svelte';
2
3
  type $$ComponentProps = {
3
4
  value?: string;
4
5
  onsubmit: (value: string, files: File[]) => void;
@@ -17,6 +18,8 @@ type $$ComponentProps = {
17
18
  rows?: number;
18
19
  /** Manual drag handle on the top edge; `none` hides it. */
19
20
  resize?: 'none' | 'top';
21
+ /** Mirror overlay segments, forwarded to the Textarea. */
22
+ segments?: (value: string) => TextareaSegment[];
20
23
  sendLabel?: string;
21
24
  attachLabel?: string;
22
25
  leading?: Snippet;
package/dist/index.d.ts CHANGED
@@ -31,6 +31,7 @@ export { default as Spinner } from './components/atoms/Spinner.svelte';
31
31
  export { default as Swatch } from './components/atoms/Swatch.svelte';
32
32
  export { default as Switch } from './components/atoms/Switch.svelte';
33
33
  export { default as Text } from './components/atoms/Text.svelte';
34
+ export type { TextareaSegment } from './components/atoms/Textarea.svelte';
34
35
  export { default as Textarea } from './components/atoms/Textarea.svelte';
35
36
  export { default as Thinking } from './components/atoms/Thinking.svelte';
36
37
  export { default as TimeDisplay } from './components/atoms/TimeDisplay.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.82.1",
3
+ "version": "0.82.2",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",