@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 +30 -0
- package/dist/components/atoms/Textarea.svelte +101 -0
- package/dist/components/atoms/Textarea.svelte.d.ts +10 -0
- package/dist/components/molecules/Composer.svelte +5 -1
- package/dist/components/molecules/Composer.svelte.d.ts +3 -0
- package/dist/index.d.ts +1 -0
- package/package.json +1 -1
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