@dorsk/tsumikit 0.2.9 → 0.2.11
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/dist/components/atoms/Icon.svelte +5 -0
- package/dist/components/atoms/Icon.svelte.d.ts +2 -0
- package/dist/components/atoms/Textarea.svelte +137 -25
- package/dist/components/atoms/Textarea.svelte.d.ts +3 -0
- package/dist/components/molecules/FileButton.svelte +30 -5
- package/dist/components/molecules/FileButton.svelte.d.ts +6 -1
- package/package.json +1 -1
|
@@ -87,6 +87,11 @@
|
|
|
87
87
|
|
|
88
88
|
export type IconName = keyof typeof ICONS;
|
|
89
89
|
|
|
90
|
+
/** True when `s` is a registered glyph name (vs. e.g. a raw emoji string). */
|
|
91
|
+
export function isIconName(s: string): s is IconName {
|
|
92
|
+
return s in ICONS;
|
|
93
|
+
}
|
|
94
|
+
|
|
90
95
|
// Glyphs that read better filled than stroked.
|
|
91
96
|
const FILLED = new Set<IconName>(['stop', 'star', 'live']);
|
|
92
97
|
</script>
|
|
@@ -67,6 +67,8 @@ declare const ICONS: {
|
|
|
67
67
|
readonly 'alert-circle': "<circle cx=\"12\" cy=\"12\" r=\"10\" /><line x1=\"12\" x2=\"12\" y1=\"8\" y2=\"12\" /><line x1=\"12\" x2=\"12.01\" y1=\"16\" y2=\"16\" />";
|
|
68
68
|
};
|
|
69
69
|
export type IconName = keyof typeof ICONS;
|
|
70
|
+
/** True when `s` is a registered glyph name (vs. e.g. a raw emoji string). */
|
|
71
|
+
export declare function isIconName(s: string): s is IconName;
|
|
70
72
|
import type { Snippet } from 'svelte';
|
|
71
73
|
type $$ComponentProps = {
|
|
72
74
|
/** Named glyph from the registry. Omit when supplying `children`. */
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
// switches to the monospace family. `autoresize` opts into the grow-with-
|
|
6
6
|
// content action without the call-site wiring `use:` itself. `bind:el`
|
|
7
7
|
// exposes the underlying element for focus/measure.
|
|
8
|
+
//
|
|
9
|
+
// `resize` replaces the native (un-themeable) resize grip with our own drag
|
|
10
|
+
// handle on the top or bottom edge, styled like the Modal/AppShell grips: a
|
|
11
|
+
// centered pill that's a thicker portion of the border. `autoresize` wins —
|
|
12
|
+
// content-driven sizing leaves no manual handle.
|
|
8
13
|
import type { HTMLTextareaAttributes } from 'svelte/elements';
|
|
9
14
|
import { autoresize as autoresizeAction } from '../../autoresize';
|
|
10
15
|
|
|
@@ -12,6 +17,9 @@
|
|
|
12
17
|
mono?: boolean;
|
|
13
18
|
autoresize?: boolean;
|
|
14
19
|
size?: 'sm' | 'md';
|
|
20
|
+
/** Manual resize handle edge, or 'none' to disable. Ignored when
|
|
21
|
+
* `autoresize` is set. Defaults to a bottom handle. */
|
|
22
|
+
resize?: 'none' | 'top' | 'bottom';
|
|
15
23
|
/** Error state: danger border + aria-invalid (also styles if a consumer
|
|
16
24
|
* sets aria-invalid directly). */
|
|
17
25
|
invalid?: boolean;
|
|
@@ -24,38 +32,100 @@
|
|
|
24
32
|
mono = false,
|
|
25
33
|
autoresize = false,
|
|
26
34
|
size = 'md',
|
|
35
|
+
resize = 'bottom',
|
|
27
36
|
invalid = false,
|
|
28
37
|
class: klass = '',
|
|
29
38
|
value = $bindable(),
|
|
30
39
|
el = $bindable(null),
|
|
31
40
|
...rest
|
|
32
41
|
}: Props = $props();
|
|
42
|
+
|
|
43
|
+
const showHandle = $derived(!autoresize && resize !== 'none');
|
|
44
|
+
|
|
45
|
+
// --- manual resize drag (mirrors AppShell/Modal: rAF-throttled pointer drag) ---
|
|
46
|
+
let dragging = $state(false);
|
|
47
|
+
let rafId = 0;
|
|
48
|
+
let startY = 0;
|
|
49
|
+
let startH = 0;
|
|
50
|
+
let lastY = 0;
|
|
51
|
+
|
|
52
|
+
function startDrag(e: PointerEvent) {
|
|
53
|
+
if (!el) return;
|
|
54
|
+
dragging = true;
|
|
55
|
+
startY = lastY = e.clientY;
|
|
56
|
+
startH = el.offsetHeight;
|
|
57
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
}
|
|
60
|
+
function onDrag(e: PointerEvent) {
|
|
61
|
+
if (!dragging || !el) return;
|
|
62
|
+
lastY = e.clientY;
|
|
63
|
+
if (rafId) return;
|
|
64
|
+
rafId = requestAnimationFrame(() => {
|
|
65
|
+
rafId = 0;
|
|
66
|
+
if (!el) return;
|
|
67
|
+
// Top handle grows upward (drag up = taller), bottom grows downward.
|
|
68
|
+
const delta = resize === 'top' ? startY - lastY : lastY - startY;
|
|
69
|
+
el.style.height = `${Math.max(0, startH + delta)}px`;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function endDrag(e: PointerEvent) {
|
|
73
|
+
if (!dragging) return;
|
|
74
|
+
dragging = false;
|
|
75
|
+
if (rafId) {
|
|
76
|
+
cancelAnimationFrame(rafId);
|
|
77
|
+
rafId = 0;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
|
81
|
+
} catch {
|
|
82
|
+
/* already released */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
33
85
|
</script>
|
|
34
86
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
87
|
+
<div class="textarea-wrap" class:dragging>
|
|
88
|
+
{#if autoresize}
|
|
89
|
+
<textarea
|
|
90
|
+
bind:this={el}
|
|
91
|
+
class="textarea {klass}"
|
|
92
|
+
class:mono
|
|
93
|
+
class:textarea-sm={size === 'sm'}
|
|
94
|
+
bind:value
|
|
95
|
+
use:autoresizeAction={typeof value === 'string' ? value : ''}
|
|
96
|
+
{...rest}
|
|
97
|
+
aria-invalid={invalid || undefined}
|
|
98
|
+
></textarea>
|
|
99
|
+
{:else}
|
|
100
|
+
<textarea
|
|
101
|
+
bind:this={el}
|
|
102
|
+
class="textarea {klass}"
|
|
103
|
+
class:mono
|
|
104
|
+
class:textarea-sm={size === 'sm'}
|
|
105
|
+
bind:value
|
|
106
|
+
{...rest}
|
|
107
|
+
aria-invalid={invalid || undefined}
|
|
108
|
+
></textarea>
|
|
109
|
+
{/if}
|
|
110
|
+
{#if showHandle}
|
|
111
|
+
<div
|
|
112
|
+
class="resize-handle resize-{resize}"
|
|
113
|
+
onpointerdown={startDrag}
|
|
114
|
+
onpointermove={onDrag}
|
|
115
|
+
onpointerup={endDrag}
|
|
116
|
+
onpointercancel={endDrag}
|
|
117
|
+
role="separator"
|
|
118
|
+
aria-orientation="horizontal"
|
|
119
|
+
aria-label="Resize"
|
|
120
|
+
></div>
|
|
121
|
+
{/if}
|
|
122
|
+
</div>
|
|
57
123
|
|
|
58
124
|
<style>
|
|
125
|
+
.textarea-wrap {
|
|
126
|
+
position: relative;
|
|
127
|
+
display: flex;
|
|
128
|
+
}
|
|
59
129
|
.textarea {
|
|
60
130
|
width: 100%;
|
|
61
131
|
padding: var(--sp-3);
|
|
@@ -64,8 +134,11 @@
|
|
|
64
134
|
border-radius: var(--r-md);
|
|
65
135
|
color: var(--text);
|
|
66
136
|
transition: border-color 0.12s var(--ease);
|
|
67
|
-
|
|
68
|
-
|
|
137
|
+
/* Custom handle replaces the native grip; never show the native one. */
|
|
138
|
+
resize: none;
|
|
139
|
+
/* Match the single-row height of Button/Input so a `rows={1}` textarea
|
|
140
|
+
lines up with them; the native `rows` attribute grows it from here. */
|
|
141
|
+
min-height: 2.5rem;
|
|
69
142
|
font-family: inherit;
|
|
70
143
|
}
|
|
71
144
|
.textarea:focus {
|
|
@@ -75,7 +148,7 @@
|
|
|
75
148
|
.textarea-sm {
|
|
76
149
|
padding: var(--sp-2);
|
|
77
150
|
font-size: var(--fs-sm);
|
|
78
|
-
min-height:
|
|
151
|
+
min-height: 2rem;
|
|
79
152
|
}
|
|
80
153
|
.textarea[aria-invalid='true'],
|
|
81
154
|
.textarea[aria-invalid='true']:focus {
|
|
@@ -84,4 +157,43 @@
|
|
|
84
157
|
.mono {
|
|
85
158
|
font-family: var(--font-mono);
|
|
86
159
|
}
|
|
160
|
+
|
|
161
|
+
/* Drag handle: a thin full-width strip on an edge, with a centered pill grip
|
|
162
|
+
(a thicker portion of the border) that brightens to the accent on hover /
|
|
163
|
+
while dragging — mirroring the Modal and AppShell resize grips. */
|
|
164
|
+
.resize-handle {
|
|
165
|
+
position: absolute;
|
|
166
|
+
left: 0;
|
|
167
|
+
right: 0;
|
|
168
|
+
height: 12px;
|
|
169
|
+
cursor: ns-resize;
|
|
170
|
+
touch-action: none;
|
|
171
|
+
}
|
|
172
|
+
.resize-bottom {
|
|
173
|
+
bottom: 0;
|
|
174
|
+
}
|
|
175
|
+
.resize-top {
|
|
176
|
+
top: 0;
|
|
177
|
+
}
|
|
178
|
+
.resize-handle::after {
|
|
179
|
+
content: '';
|
|
180
|
+
position: absolute;
|
|
181
|
+
left: 50%;
|
|
182
|
+
transform: translateX(-50%);
|
|
183
|
+
width: 28px;
|
|
184
|
+
height: 3px;
|
|
185
|
+
border-radius: 999px;
|
|
186
|
+
background: var(--border-strong);
|
|
187
|
+
transition: background 0.12s var(--ease);
|
|
188
|
+
}
|
|
189
|
+
.resize-bottom::after {
|
|
190
|
+
bottom: 3px;
|
|
191
|
+
}
|
|
192
|
+
.resize-top::after {
|
|
193
|
+
top: 3px;
|
|
194
|
+
}
|
|
195
|
+
.resize-handle:hover::after,
|
|
196
|
+
.dragging .resize-handle::after {
|
|
197
|
+
background: var(--accent);
|
|
198
|
+
}
|
|
87
199
|
</style>
|
|
@@ -3,6 +3,9 @@ type Props = HTMLTextareaAttributes & {
|
|
|
3
3
|
mono?: boolean;
|
|
4
4
|
autoresize?: boolean;
|
|
5
5
|
size?: 'sm' | 'md';
|
|
6
|
+
/** Manual resize handle edge, or 'none' to disable. Ignored when
|
|
7
|
+
* `autoresize` is set. Defaults to a bottom handle. */
|
|
8
|
+
resize?: 'none' | 'top' | 'bottom';
|
|
6
9
|
/** Error state: danger border + aria-invalid (also styles if a consumer
|
|
7
10
|
* sets aria-invalid directly). */
|
|
8
11
|
invalid?: boolean;
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
// File-picker button. A real <input type="file"> visually hidden inside a
|
|
3
3
|
// <label> styled as a button — so it's keyboard-focusable and works with zero
|
|
4
4
|
// JS to open the dialog. Emits the chosen files via `onfiles`. Dependency-free.
|
|
5
|
-
import Icon from '../atoms/Icon.svelte';
|
|
5
|
+
import Icon, { isIconName } from '../atoms/Icon.svelte';
|
|
6
6
|
import type { IconName } from '../atoms/Icon.svelte';
|
|
7
7
|
|
|
8
8
|
let {
|
|
9
9
|
onfiles,
|
|
10
10
|
label = 'Choose file',
|
|
11
|
-
icon = '
|
|
11
|
+
icon = '📎',
|
|
12
|
+
iconOnly = false,
|
|
12
13
|
accept,
|
|
13
14
|
multiple = false,
|
|
14
15
|
disabled = false,
|
|
@@ -17,7 +18,12 @@
|
|
|
17
18
|
}: {
|
|
18
19
|
onfiles: (files: File[]) => void;
|
|
19
20
|
label?: string;
|
|
20
|
-
|
|
21
|
+
/** A registered glyph name (rendered as SVG) or any string such as an
|
|
22
|
+
* emoji (rendered as text). Defaults to the 📎 paperclip emoji. */
|
|
23
|
+
icon?: IconName | (string & {});
|
|
24
|
+
/** Hide the label visually, showing only the icon. The label is kept for
|
|
25
|
+
* assistive tech (and used as the accessible name). */
|
|
26
|
+
iconOnly?: boolean;
|
|
21
27
|
accept?: string;
|
|
22
28
|
multiple?: boolean;
|
|
23
29
|
disabled?: boolean;
|
|
@@ -37,7 +43,9 @@
|
|
|
37
43
|
class="file-btn {klass}"
|
|
38
44
|
class:primary={variant === 'primary'}
|
|
39
45
|
class:ghost={variant === 'ghost'}
|
|
46
|
+
class:icon-only={iconOnly}
|
|
40
47
|
class:disabled
|
|
48
|
+
aria-label={iconOnly ? label : undefined}
|
|
41
49
|
>
|
|
42
50
|
<input
|
|
43
51
|
class="sr-only"
|
|
@@ -47,8 +55,12 @@
|
|
|
47
55
|
{disabled}
|
|
48
56
|
onchange={onchange}
|
|
49
57
|
/>
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
{#if isIconName(icon)}
|
|
59
|
+
<Icon name={icon} />
|
|
60
|
+
{:else}
|
|
61
|
+
<span class="emoji" aria-hidden="true">{icon}</span>
|
|
62
|
+
{/if}
|
|
63
|
+
<span class:sr-only={iconOnly}>{label}</span>
|
|
52
64
|
</label>
|
|
53
65
|
|
|
54
66
|
<style>
|
|
@@ -80,6 +92,19 @@
|
|
|
80
92
|
.file-btn:hover:not(.disabled) {
|
|
81
93
|
border-color: var(--accent);
|
|
82
94
|
}
|
|
95
|
+
/* icon-only: square it up and drop the label gap. */
|
|
96
|
+
.file-btn.icon-only {
|
|
97
|
+
gap: 0;
|
|
98
|
+
padding: var(--sp-2);
|
|
99
|
+
aspect-ratio: 1;
|
|
100
|
+
}
|
|
101
|
+
/* Emoji glyph is bumped above the text size — at 1em a paperclip is hard to
|
|
102
|
+
read, so render it a touch larger for legibility. */
|
|
103
|
+
.emoji {
|
|
104
|
+
display: inline-flex;
|
|
105
|
+
font-size: 1.35em;
|
|
106
|
+
line-height: 1;
|
|
107
|
+
}
|
|
83
108
|
.file-btn.primary {
|
|
84
109
|
background: var(--accent);
|
|
85
110
|
border-color: var(--accent);
|
|
@@ -2,7 +2,12 @@ import type { IconName } from '../atoms/Icon.svelte';
|
|
|
2
2
|
type $$ComponentProps = {
|
|
3
3
|
onfiles: (files: File[]) => void;
|
|
4
4
|
label?: string;
|
|
5
|
-
|
|
5
|
+
/** A registered glyph name (rendered as SVG) or any string such as an
|
|
6
|
+
* emoji (rendered as text). Defaults to the 📎 paperclip emoji. */
|
|
7
|
+
icon?: IconName | (string & {});
|
|
8
|
+
/** Hide the label visually, showing only the icon. The label is kept for
|
|
9
|
+
* assistive tech (and used as the accessible name). */
|
|
10
|
+
iconOnly?: boolean;
|
|
6
11
|
accept?: string;
|
|
7
12
|
multiple?: boolean;
|
|
8
13
|
disabled?: boolean;
|
package/package.json
CHANGED