@dorsk/tsumikit 0.82.1 → 0.82.3

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.
@@ -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'];
@@ -514,7 +514,7 @@
514
514
  height: var(--sp-8);
515
515
  border-radius: var(--r-pill);
516
516
  background: var(--border-strong);
517
- transform: translate(-50%, -50%);
517
+ translate: -50% -50%;
518
518
  transition:
519
519
  background var(--dur-fast) var(--ease),
520
520
  scale var(--dur-fast) var(--ease);
@@ -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';
@@ -2,7 +2,7 @@
2
2
  * All-in-one stylesheet: Inter @font-face, tokens + built-in themes, reset + element defaults,
3
3
  * generic utilities and the syntax-highlight token mapping (global so it
4
4
  * reaches {@html} output). Import the parts individually to compose your own
5
- * stack — see README "Theming".
5
+ * stack: see docs/theming.md.
6
6
  */
7
7
  @import './font.css';
8
8
  @import './variables.css';
@@ -3,7 +3,7 @@
3
3
  * Class-based highlighters (highlight.js `hljs-*`, Prism `token.*`) are themed
4
4
  * here by mapping their token classes onto the --syn-* theme tokens, so colors
5
5
  * track every theme. This must be GLOBAL: Svelte scoped styles don't reach
6
- * {@html} content. (Shiki emits inline colors instead — see the README for how
6
+ * {@html} content. (Shiki emits inline colors instead; docs/components.md explains how
7
7
  * to theme it via its css-variables theme.) */
8
8
  .hljs-keyword,
9
9
  .hljs-built_in,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.82.1",
4
- "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
3
+ "version": "0.82.3",
4
+ "description": "Svelte 5 UI components styled with plain CSS variables: 31 themes, WCAG AA contrast, no runtime dependencies.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Dorsk <dorsk.dev@gmail.com>",
@@ -73,7 +73,8 @@
73
73
  "design:bundle": "node .design-sync/build-bundle.mjs",
74
74
  "design:verify": "node .design-sync/verify-bundle.mjs",
75
75
  "design:clean": "rm -rf ds-bundle .ds-proto",
76
- "demo:props": "node scripts/gen-demo-props.mjs"
76
+ "demo:props": "node scripts/gen-demo-props.mjs",
77
+ "readme:shots": "node scripts/readme-screenshots.mjs"
77
78
  },
78
79
  "peerDependencies": {
79
80
  "svelte": "^5.0.0"
@@ -91,6 +92,7 @@
91
92
  "esbuild-svelte": "^0.9.5",
92
93
  "jsdom": "^30.0.1",
93
94
  "lefthook": "^1.7.0",
95
+ "playwright": "~1.63.0",
94
96
  "publint": "^0.2.0",
95
97
  "react": "^19.2.8",
96
98
  "react-dom": "^19.2.8",