@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.
@@ -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
- {#if autoresize}
36
- <textarea
37
- bind:this={el}
38
- class="textarea {klass}"
39
- class:mono
40
- class:textarea-sm={size === 'sm'}
41
- bind:value
42
- use:autoresizeAction={typeof value === 'string' ? value : ''}
43
- {...rest}
44
- aria-invalid={invalid || undefined}
45
- ></textarea>
46
- {:else}
47
- <textarea
48
- bind:this={el}
49
- class="textarea {klass}"
50
- class:mono
51
- class:textarea-sm={size === 'sm'}
52
- bind:value
53
- {...rest}
54
- aria-invalid={invalid || undefined}
55
- ></textarea>
56
- {/if}
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
- resize: vertical;
68
- min-height: 5rem;
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: 4rem;
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 = 'upload',
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
- icon?: IconName;
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
- <Icon name={icon} />
51
- <span>{label}</span>
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
- icon?: IconName;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
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",