@dorsk/tsumikit 0.12.0 → 0.13.0

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