@dorsk/tsumikit 0.82.0 → 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'];
@@ -57,8 +57,9 @@
57
57
  /** `'sidebar-full'`: the sidebar spans the whole height on desktop/tablet
58
58
  * and the header only covers the content column (brand sits top-left). */
59
59
  layout?: 'header-top' | 'sidebar-full';
60
- /** Pin the desktop sidebar to the viewport (100dvh, own scroll) so its
61
- * footer stays visible on long pages. */
60
+ /** Pin the desktop sidebar to the viewport (own scroll) so its footer
61
+ * stays visible on long pages. Sits below the header in `header-top`,
62
+ * spans the full viewport height in `sidebar-full`. */
62
63
  stickySidebar?: boolean;
63
64
  /** Inner padding of the sidebar; `'none'` when the nav owns its gutters. */
64
65
  sidebarPadding?: 'none' | 'sm' | 'md';
@@ -414,9 +415,13 @@
414
415
  }
415
416
  .shell.sticky-sidebar .shell-sidebar {
416
417
  position: sticky;
418
+ top: var(--shell-header-h);
419
+ height: calc(100dvh - var(--shell-header-h));
420
+ align-self: start;
421
+ }
422
+ .shell.sticky-sidebar.sidebar-full .shell-sidebar {
417
423
  top: 0;
418
424
  height: 100dvh;
419
- align-self: start;
420
425
  }
421
426
  .shell-scrim,
422
427
  .shell-menu-btn {
@@ -18,8 +18,9 @@ type $$ComponentProps = {
18
18
  /** `'sidebar-full'`: the sidebar spans the whole height on desktop/tablet
19
19
  * and the header only covers the content column (brand sits top-left). */
20
20
  layout?: 'header-top' | 'sidebar-full';
21
- /** Pin the desktop sidebar to the viewport (100dvh, own scroll) so its
22
- * footer stays visible on long pages. */
21
+ /** Pin the desktop sidebar to the viewport (own scroll) so its footer
22
+ * stays visible on long pages. Sits below the header in `header-top`,
23
+ * spans the full viewport height in `sidebar-full`. */
23
24
  stickySidebar?: boolean;
24
25
  /** Inner padding of the sidebar; `'none'` when the nav owns its gutters. */
25
26
  sidebarPadding?: 'none' | 'sm' | 'md';
@@ -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;
@@ -231,12 +231,15 @@
231
231
 
232
232
  <style>
233
233
  /* Reset the UA dialog box: we own the surface via the inner .sheet. The
234
- dialog itself is just the centering/backdrop layer. */
234
+ dialog itself is just the centering/backdrop layer. It must never scroll:
235
+ the UA `overflow: auto` lets the initial focus scroll the sheet into view
236
+ while it still sits at `translate: 0 100%`, which eats the slide-in. */
235
237
  .modal {
236
238
  margin: 0;
237
239
  padding: 0;
238
240
  border: 0;
239
241
  background: none;
242
+ overflow: clip;
240
243
  max-width: 100vw;
241
244
  max-height: 100dvh;
242
245
  width: 100%;
@@ -83,6 +83,7 @@
83
83
 
84
84
  const uid = Math.random().toString(36).slice(2, 8);
85
85
  const listId = `rs-list-${uid}`;
86
+ const nameId = `rs-name-${uid}`;
86
87
  const optionId = (i: number) => `rs-opt-${uid}-${i}`;
87
88
 
88
89
  let root = $state<HTMLDivElement | null>(null);
@@ -96,6 +97,7 @@
96
97
 
97
98
  const selectedIndex = $derived(options.findIndex((o) => getKey(o) === value));
98
99
  const selectedOption = $derived(selectedIndex >= 0 ? options[selectedIndex] : undefined);
100
+ const triggerName = $derived(selectedOption === undefined ? label : `${label}, ${getLabel(selectedOption)}`);
99
101
  const typeahead = createTypeahead();
100
102
 
101
103
  const triggerEl = () => root?.querySelector<HTMLElement>('.rs-trigger') ?? null;
@@ -150,6 +152,7 @@
150
152
  if (key !== value) {
151
153
  value = key;
152
154
  onchange?.(key, next);
155
+ if (!closeAfter) announcement = getLabel(next);
153
156
  playSound(closeAfter ? 'click' : 'tick', sound);
154
157
  }
155
158
  if (closeAfter) {
@@ -354,6 +357,7 @@
354
357
  onopen={onOpen}
355
358
  onclose={onClose}
356
359
  aria-controls={listId}
360
+ aria-labelledby={nameId}
357
361
  aria-activedescendant={open && active >= 0 ? optionId(active) : undefined}
358
362
  >
359
363
  {#snippet trigger()}
@@ -407,6 +411,7 @@
407
411
  <div class="rs-footer">{@render footer({ close })}</div>
408
412
  {/if}
409
413
  </Popover>
414
+ <span class="sr-only" id={nameId}>{triggerName}</span>
410
415
  <span class="sr-only" aria-live="polite">{announcement}</span>
411
416
  </div>
412
417
 
@@ -487,8 +492,10 @@
487
492
  .rich-select.is-open .rs-chevron {
488
493
  rotate: 180deg;
489
494
  }
490
- .rich-select :global(.pop-panel.rs-panel) {
495
+ .rich-select :global(.pop-panel.rs-panel:popover-open) {
491
496
  display: flex;
497
+ }
498
+ .rich-select :global(.pop-panel.rs-panel) {
492
499
  flex-direction: column;
493
500
  gap: var(--sp-1);
494
501
  box-sizing: border-box;
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.0",
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",
@@ -72,7 +72,8 @@
72
72
  "design": "npm run package && npm run design:bundle && npm run design:verify",
73
73
  "design:bundle": "node .design-sync/build-bundle.mjs",
74
74
  "design:verify": "node .design-sync/verify-bundle.mjs",
75
- "design:clean": "rm -rf ds-bundle .ds-proto"
75
+ "design:clean": "rm -rf ds-bundle .ds-proto",
76
+ "demo:props": "node scripts/gen-demo-props.mjs"
76
77
  },
77
78
  "peerDependencies": {
78
79
  "svelte": "^5.0.0"