@dorsk/tsumikit 0.12.0 → 0.13.1

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.
@@ -0,0 +1,355 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { browser } from '../../env';
4
+ import Icon from '../atoms/Icon.svelte';
5
+ import { parseStoredCollapsed, parseStoredWidth } from './resizable-panel-persistence';
6
+
7
+ let {
8
+ panel,
9
+ children,
10
+ side = 'left',
11
+ label = 'Side panel',
12
+ width = 280,
13
+ minWidth = 180,
14
+ maxWidth = 480,
15
+ widthKey,
16
+ collapsed = $bindable(false),
17
+ persistCollapsed = true,
18
+ resizeStep = 16
19
+ }: {
20
+ /** Content shown while the panel is expanded. */
21
+ panel: Snippet;
22
+ /** Main content beside the panel. */
23
+ children: Snippet;
24
+ /** Physical edge occupied by the panel. */
25
+ side?: 'left' | 'right';
26
+ /** Accessible name for the panel landmark. */
27
+ label?: string;
28
+ /** Initial expanded width in pixels. */
29
+ width?: number;
30
+ minWidth?: number;
31
+ maxWidth?: number;
32
+ /** localStorage key used to restore the expanded width. */
33
+ widthKey?: string;
34
+ /** Bindable collapsed state. */
35
+ collapsed?: boolean;
36
+ /** Persist collapsed state as `${widthKey}:collapsed`. */
37
+ persistCollapsed?: boolean;
38
+ /** Pixels added or removed by each resize-separator arrow key press. */
39
+ resizeStep?: number;
40
+ } = $props();
41
+
42
+ let root: HTMLDivElement;
43
+ let panelWidth = $state<number>();
44
+ let resizing = $state(false);
45
+ let restored = false;
46
+
47
+ const boundedMin = $derived(Math.max(1, Math.min(minWidth, maxWidth)));
48
+ const boundedMax = $derived(Math.max(boundedMin, maxWidth));
49
+ const currentWidth = $derived(
50
+ Math.round(Math.max(boundedMin, Math.min(panelWidth ?? width, boundedMax)))
51
+ );
52
+ const toggleLabel = $derived(collapsed ? `Expand ${label}` : `Collapse ${label}`);
53
+ const toggleIcon = $derived(
54
+ collapsed
55
+ ? side === 'left'
56
+ ? 'chevron-right'
57
+ : 'chevron-left'
58
+ : side === 'left'
59
+ ? 'chevron-left'
60
+ : 'chevron-right'
61
+ );
62
+
63
+ $effect(() => {
64
+ if (!browser || restored) return;
65
+ restored = true;
66
+ if (!widthKey) return;
67
+
68
+ const savedWidth = parseStoredWidth(
69
+ localStorage.getItem(widthKey),
70
+ boundedMin,
71
+ boundedMax
72
+ );
73
+ if (savedWidth !== undefined) panelWidth = savedWidth;
74
+
75
+ if (persistCollapsed) {
76
+ const savedCollapsed = parseStoredCollapsed(
77
+ localStorage.getItem(`${widthKey}:collapsed`)
78
+ );
79
+ if (savedCollapsed !== undefined) collapsed = savedCollapsed;
80
+ }
81
+ });
82
+
83
+ function persistWidth() {
84
+ if (browser && widthKey) localStorage.setItem(widthKey, String(currentWidth));
85
+ }
86
+
87
+ function setWidth(nextWidth: number, persist = true) {
88
+ panelWidth = Math.round(Math.max(boundedMin, Math.min(nextWidth, boundedMax)));
89
+ if (persist) persistWidth();
90
+ }
91
+
92
+ function toggle() {
93
+ collapsed = !collapsed;
94
+ if (browser && widthKey && persistCollapsed) {
95
+ localStorage.setItem(`${widthKey}:collapsed`, String(collapsed));
96
+ }
97
+ }
98
+
99
+ function widthFromPointer(clientX: number) {
100
+ const bounds = root.getBoundingClientRect();
101
+ return side === 'left' ? clientX - bounds.left : bounds.right - clientX;
102
+ }
103
+
104
+ function startResize(event: PointerEvent) {
105
+ resizing = true;
106
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
107
+ event.preventDefault();
108
+ }
109
+
110
+ function resize(event: PointerEvent) {
111
+ if (!resizing) return;
112
+ setWidth(widthFromPointer(event.clientX), false);
113
+ }
114
+
115
+ function finishResize(event: PointerEvent) {
116
+ if (!resizing) return;
117
+ resizing = false;
118
+ try {
119
+ (event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
120
+ } catch {
121
+ // Pointer capture may already have been released by the browser.
122
+ }
123
+ persistWidth();
124
+ }
125
+
126
+ function resizeWithKeyboard(event: KeyboardEvent) {
127
+ let nextWidth: number | undefined;
128
+ const direction = side === 'left' ? 1 : -1;
129
+
130
+ switch (event.key) {
131
+ case 'ArrowLeft':
132
+ nextWidth = currentWidth - resizeStep * direction;
133
+ break;
134
+ case 'ArrowRight':
135
+ nextWidth = currentWidth + resizeStep * direction;
136
+ break;
137
+ case 'Home':
138
+ nextWidth = boundedMin;
139
+ break;
140
+ case 'End':
141
+ nextWidth = boundedMax;
142
+ break;
143
+ }
144
+
145
+ if (nextWidth === undefined) return;
146
+ event.preventDefault();
147
+ setWidth(nextWidth);
148
+ }
149
+ </script>
150
+
151
+ <div
152
+ bind:this={root}
153
+ class="panel-layout"
154
+ class:left={side === 'left'}
155
+ class:right={side === 'right'}
156
+ class:collapsed
157
+ class:resizing
158
+ style="--panel-width: {currentWidth}px"
159
+ data-tsu="ResizablePanel"
160
+ >
161
+ <aside aria-label={label} class="panel">
162
+ {#if !collapsed}
163
+ <div class="panel-content">{@render panel()}</div>
164
+ <!-- The separator role is interactive when focusable and wired to the
165
+ required arrow/Home/End keyboard behavior. -->
166
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
167
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
168
+ <div
169
+ class="resize-handle"
170
+ role="separator"
171
+ tabindex="0"
172
+ aria-label={`Resize ${label}`}
173
+ aria-orientation="vertical"
174
+ aria-valuemin={boundedMin}
175
+ aria-valuemax={boundedMax}
176
+ aria-valuenow={currentWidth}
177
+ onkeydown={resizeWithKeyboard}
178
+ onpointerdown={startResize}
179
+ onpointermove={resize}
180
+ onpointerup={finishResize}
181
+ onpointercancel={finishResize}
182
+ ></div>
183
+ {/if}
184
+
185
+ <button
186
+ type="button"
187
+ class="collapse-control"
188
+ aria-label={toggleLabel}
189
+ aria-expanded={!collapsed}
190
+ title={toggleLabel}
191
+ onclick={toggle}
192
+ >
193
+ <Icon name={toggleIcon} size={20} />
194
+ {#if !collapsed}<span>{toggleLabel}</span>{/if}
195
+ </button>
196
+ </aside>
197
+
198
+ <div class="main">{@render children()}</div>
199
+ </div>
200
+
201
+ <style>
202
+ .panel-layout {
203
+ --panel-collapsed-width: var(--control-height);
204
+ position: relative;
205
+ display: grid;
206
+ grid-template-columns: var(--panel-current-width) minmax(0, 1fr);
207
+ min-width: 0;
208
+ min-height: 0;
209
+ container: resizable-panel / inline-size;
210
+ isolation: isolate;
211
+ }
212
+ .panel-layout.right {
213
+ grid-template-columns: minmax(0, 1fr) var(--panel-current-width);
214
+ }
215
+ .panel-layout {
216
+ --panel-current-width: var(--panel-width);
217
+ }
218
+ .panel-layout.collapsed {
219
+ --panel-current-width: var(--panel-collapsed-width);
220
+ }
221
+ .panel {
222
+ position: relative;
223
+ display: flex;
224
+ flex-direction: column;
225
+ min-width: 0;
226
+ min-height: 0;
227
+ width: var(--panel-current-width);
228
+ background: var(--bg-elevated);
229
+ border-right: 1px solid var(--border);
230
+ transition: width 0.18s var(--ease), box-shadow 0.18s var(--ease);
231
+ }
232
+ .right .panel {
233
+ grid-column: 2;
234
+ grid-row: 1;
235
+ border-right: 0;
236
+ border-left: 1px solid var(--border);
237
+ }
238
+ .main {
239
+ min-width: 0;
240
+ min-height: 0;
241
+ container: panel-main / inline-size;
242
+ }
243
+ .right .main {
244
+ grid-column: 1;
245
+ grid-row: 1;
246
+ }
247
+ .panel-content {
248
+ flex: 1;
249
+ min-height: 0;
250
+ overflow: auto;
251
+ }
252
+ .collapse-control {
253
+ display: flex;
254
+ align-items: center;
255
+ justify-content: center;
256
+ gap: var(--sp-2);
257
+ width: 100%;
258
+ min-height: var(--control-height);
259
+ margin-top: auto;
260
+ padding: var(--sp-2) var(--sp-3);
261
+ color: var(--text-muted);
262
+ font: inherit;
263
+ font-size: var(--fs-sm);
264
+ font-weight: var(--fw-medium);
265
+ background: var(--bg-elevated-2);
266
+ border: 0;
267
+ border-top: 1px solid var(--border);
268
+ cursor: pointer;
269
+ }
270
+ .collapse-control:hover {
271
+ color: var(--text);
272
+ background: var(--surface);
273
+ }
274
+ .collapse-control:focus-visible,
275
+ .resize-handle:focus-visible {
276
+ outline: 2px solid var(--accent);
277
+ outline-offset: -2px;
278
+ }
279
+ .resize-handle {
280
+ position: absolute;
281
+ top: 0;
282
+ right: -6px;
283
+ bottom: var(--control-height);
284
+ z-index: 2;
285
+ width: 12px;
286
+ padding: 0;
287
+ background: transparent;
288
+ border: 0;
289
+ cursor: ew-resize;
290
+ touch-action: none;
291
+ }
292
+ .right .resize-handle {
293
+ right: auto;
294
+ left: -6px;
295
+ }
296
+ .resize-handle::after {
297
+ content: '';
298
+ position: absolute;
299
+ top: 50%;
300
+ left: 50%;
301
+ width: 3px;
302
+ height: var(--sp-8);
303
+ border-radius: var(--r-pill);
304
+ background: var(--border-strong);
305
+ transform: translate(-50%, -50%);
306
+ transition: background 0.12s var(--ease), height 0.12s var(--ease);
307
+ }
308
+ .resize-handle:hover::after,
309
+ .resize-handle:focus-visible::after,
310
+ .resizing .resize-handle::after {
311
+ height: var(--sp-12);
312
+ background: var(--accent);
313
+ }
314
+ .resizing {
315
+ cursor: ew-resize;
316
+ user-select: none;
317
+ }
318
+
319
+ /* In a narrow host, the expanded panel overlays the main area instead of
320
+ squeezing it. The bottom control remains visible as a persistent rail. */
321
+ @container resizable-panel (max-width: 40rem) {
322
+ .panel-layout {
323
+ display: block;
324
+ }
325
+ .panel {
326
+ position: absolute;
327
+ inset-block: 0;
328
+ left: 0;
329
+ z-index: 1;
330
+ width: min(var(--panel-current-width), 85cqw);
331
+ box-shadow: var(--shadow-md);
332
+ }
333
+ .right .panel {
334
+ right: 0;
335
+ left: auto;
336
+ }
337
+ .collapsed .panel {
338
+ box-shadow: none;
339
+ }
340
+ .main {
341
+ height: 100%;
342
+ padding-left: var(--panel-collapsed-width);
343
+ }
344
+ .right .main {
345
+ padding-right: var(--panel-collapsed-width);
346
+ padding-left: 0;
347
+ }
348
+ }
349
+
350
+ @media (prefers-reduced-motion: reduce) {
351
+ .panel {
352
+ transition: none;
353
+ }
354
+ }
355
+ </style>
@@ -0,0 +1,26 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ /** Content shown while the panel is expanded. */
4
+ panel: Snippet;
5
+ /** Main content beside the panel. */
6
+ children: Snippet;
7
+ /** Physical edge occupied by the panel. */
8
+ side?: 'left' | 'right';
9
+ /** Accessible name for the panel landmark. */
10
+ label?: string;
11
+ /** Initial expanded width in pixels. */
12
+ width?: number;
13
+ minWidth?: number;
14
+ maxWidth?: number;
15
+ /** localStorage key used to restore the expanded width. */
16
+ widthKey?: string;
17
+ /** Bindable collapsed state. */
18
+ collapsed?: boolean;
19
+ /** Persist collapsed state as `${widthKey}:collapsed`. */
20
+ persistCollapsed?: boolean;
21
+ /** Pixels added or removed by each resize-separator arrow key press. */
22
+ resizeStep?: number;
23
+ };
24
+ declare const ResizablePanel: import("svelte").Component<$$ComponentProps, {}, "collapsed">;
25
+ type ResizablePanel = ReturnType<typeof ResizablePanel>;
26
+ export default ResizablePanel;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Parse a persisted panel width without treating a missing or blank value as zero.
3
+ * @param {string | null} value
4
+ * @param {number} minWidth
5
+ * @param {number} maxWidth
6
+ * @returns {number | undefined}
7
+ */
8
+ export function parseStoredWidth(value: string | null, minWidth: number, maxWidth: number): number | undefined;
9
+ /**
10
+ * Only values written by the panel itself may restore its collapsed state.
11
+ * @param {string | null} value
12
+ * @returns {boolean | undefined}
13
+ */
14
+ export function parseStoredCollapsed(value: string | null): boolean | undefined;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Parse a persisted panel width without treating a missing or blank value as zero.
3
+ * @param {string | null} value
4
+ * @param {number} minWidth
5
+ * @param {number} maxWidth
6
+ * @returns {number | undefined}
7
+ */
8
+ export function parseStoredWidth(value, minWidth, maxWidth) {
9
+ if (value === null || value.trim() === '') return undefined;
10
+
11
+ const width = Number(value);
12
+ return Number.isFinite(width) ? Math.max(minWidth, Math.min(width, maxWidth)) : undefined;
13
+ }
14
+
15
+ /**
16
+ * Only values written by the panel itself may restore its collapsed state.
17
+ * @param {string | null} value
18
+ * @returns {boolean | undefined}
19
+ */
20
+ export function parseStoredCollapsed(value) {
21
+ if (value === 'true') return true;
22
+ if (value === 'false') return false;
23
+ return undefined;
24
+ }
package/dist/index.d.ts CHANGED
@@ -23,6 +23,7 @@ export { default as Cluster } from './components/layouts/Cluster.svelte';
23
23
  export { default as Container } from './components/layouts/Container.svelte';
24
24
  export { default as NavItem } from './components/layouts/NavItem.svelte';
25
25
  export { default as NavSection } from './components/layouts/NavSection.svelte';
26
+ export { default as ResizablePanel } from './components/layouts/ResizablePanel.svelte';
26
27
  export { default as Stack } from './components/layouts/Stack.svelte';
27
28
  export { type AccordionItem, default as Accordion, } from './components/molecules/Accordion.svelte';
28
29
  export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ export { default as Cluster } from './components/layouts/Cluster.svelte';
30
30
  export { default as Container } from './components/layouts/Container.svelte';
31
31
  export { default as NavItem } from './components/layouts/NavItem.svelte';
32
32
  export { default as NavSection } from './components/layouts/NavSection.svelte';
33
+ export { default as ResizablePanel } from './components/layouts/ResizablePanel.svelte';
33
34
  export { default as Stack } from './components/layouts/Stack.svelte';
34
35
  export { default as Accordion, } from './components/molecules/Accordion.svelte';
35
36
  export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
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",
@@ -45,6 +45,7 @@
45
45
  "dev": "vite dev",
46
46
  "build": "vite build",
47
47
  "preview": "vite preview",
48
+ "test": "node --test tests/resizable-panel-persistence.test.js",
48
49
  "prepare": "svelte-kit sync && (lefthook install || true)",
49
50
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
50
51
  "lint": "biome check .",