@dorsk/tsumikit 0.23.0 → 0.24.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.
@@ -1,20 +1,22 @@
1
- import { type Snippet } from 'svelte';
1
+ import type { Snippet } from 'svelte';
2
2
  type $$ComponentProps = {
3
3
  /** Content shown while the panel is expanded. */
4
4
  panel: Snippet;
5
- /** Main content beside the panel. */
6
- children: Snippet;
5
+ /** Main content beside the panel (optional in overlay mode). */
6
+ children?: Snippet;
7
7
  /** Physical edge occupied by the panel. */
8
8
  side?: 'left' | 'right';
9
- /** Accessible name for the panel landmark. */
9
+ /** Accessible name for the panel landmark / dialog. */
10
10
  label?: string;
11
11
  /** Initial expanded width in pixels. */
12
12
  width?: number;
13
- minWidth?: number;
14
- maxWidth?: number;
13
+ /** Pixel number or CSS length (`'12rem'`, `'30vw'`). */
14
+ minWidth?: number | string;
15
+ /** Pixel number or CSS length (`'40rem'`, `'90vw'`). */
16
+ maxWidth?: number | string;
15
17
  /** localStorage key used to restore the expanded width. */
16
18
  widthKey?: string;
17
- /** Bindable collapsed state. */
19
+ /** Bindable collapsed state (inline mode). */
18
20
  collapsed?: boolean;
19
21
  /** Persist collapsed state as `${widthKey}:collapsed`. */
20
22
  persistCollapsed?: boolean;
@@ -25,7 +27,21 @@ type $$ComponentProps = {
25
27
  /** Keep the collapse handle in view when the panel scrolls past the
26
28
  * viewport, repositioning on scroll/resize via requestAnimationFrame. */
27
29
  stickyHandle?: boolean;
30
+ /** `inline` shares the row with `children`; `overlay` fixes the panel to
31
+ * its viewport edge as a non-modal drawer above the page. */
32
+ mode?: 'inline' | 'overlay';
33
+ /** Bindable drawer visibility (overlay mode). */
34
+ open?: boolean;
35
+ /** Overlay mode: Escape, scrim click or the edge control closed the drawer. */
36
+ onclose?: () => void;
37
+ /** Overlay mode: dim the page behind the drawer; clicking it closes (default true). */
38
+ scrim?: boolean;
39
+ /** Overlay mode: viewport width (CSS length) under which the drawer spans
40
+ * the full viewport and hides its resize handle and scrim. */
41
+ fullWidthBelow?: string;
42
+ /** Overlay mode: cap the width at the viewport and re-clamp on window resize. */
43
+ clampToViewport?: boolean;
28
44
  };
29
- declare const ResizablePanel: import("svelte").Component<$$ComponentProps, {}, "collapsed">;
45
+ declare const ResizablePanel: import("svelte").Component<$$ComponentProps, {}, "collapsed" | "open">;
30
46
  type ResizablePanel = ReturnType<typeof ResizablePanel>;
31
47
  export default ResizablePanel;
@@ -14,3 +14,73 @@ export function createFrameBatcher<T>(requestFrame: (callback: FrameRequestCallb
14
14
  flush(value: T): void;
15
15
  discard(): void;
16
16
  };
17
+ /**
18
+ * Resolve a width prop that is either a pixel number or a CSS length.
19
+ * Plain `px` strings parse directly; anything else is handed to `measure`,
20
+ * which lays the length out and returns its pixel size (or `undefined` when
21
+ * there is no DOM to measure in).
22
+ *
23
+ * @param {number | string} value
24
+ * @param {(css: string) => number | undefined} measure
25
+ * @returns {number | undefined}
26
+ */
27
+ export function resolveLength(value: number | string, measure: (css: string) => number | undefined): number | undefined;
28
+ /**
29
+ * @typedef {object} ResizeHandleParams
30
+ * @property {'left' | 'right'} side Edge the resized box sits on; dragging away from it grows the box.
31
+ * @property {(width: number) => void} onwidth Called once per animation frame while dragging and on every keyboard step.
32
+ * @property {(width: number) => void} [oncommit] Called with the settled width on pointer release and after each keyboard step.
33
+ * @property {() => void} [onreset] Double-click on the handle.
34
+ * @property {(active: boolean) => void} [onactive] Drag start/end, for a `resizing` class.
35
+ * @property {() => number} [measure] Current width in px; defaults to the handle's parent box.
36
+ * @property {number} [min]
37
+ * @property {number} [max]
38
+ * @property {number} [step] Pixels per arrow key press (default 16).
39
+ */
40
+ /**
41
+ * Svelte action turning any element into a pointer + keyboard width grip.
42
+ * One pointer-capture / rAF-coalesced implementation shared by
43
+ * ResizablePanel and consumer-built grips.
44
+ *
45
+ * Usage: <div role="separator" tabindex="0" use:resizeHandle={{ side, min, max, onwidth }}></div>
46
+ *
47
+ * @param {HTMLElement} node
48
+ * @param {ResizeHandleParams} params
49
+ */
50
+ export function resizeHandle(node: HTMLElement, params: ResizeHandleParams): {
51
+ /** @param {ResizeHandleParams} next */
52
+ update(next: ResizeHandleParams): void;
53
+ destroy(): void;
54
+ };
55
+ export type ResizeHandleParams = {
56
+ /**
57
+ * Edge the resized box sits on; dragging away from it grows the box.
58
+ */
59
+ side: "left" | "right";
60
+ /**
61
+ * Called once per animation frame while dragging and on every keyboard step.
62
+ */
63
+ onwidth: (width: number) => void;
64
+ /**
65
+ * Called with the settled width on pointer release and after each keyboard step.
66
+ */
67
+ oncommit?: ((width: number) => void) | undefined;
68
+ /**
69
+ * Double-click on the handle.
70
+ */
71
+ onreset?: (() => void) | undefined;
72
+ /**
73
+ * Drag start/end, for a `resizing` class.
74
+ */
75
+ onactive?: ((active: boolean) => void) | undefined;
76
+ /**
77
+ * Current width in px; defaults to the handle's parent box.
78
+ */
79
+ measure?: (() => number) | undefined;
80
+ min?: number | undefined;
81
+ max?: number | undefined;
82
+ /**
83
+ * Pixels per arrow key press (default 16).
84
+ */
85
+ step?: number | undefined;
86
+ };
@@ -61,3 +61,164 @@ export function createFrameBatcher(requestFrame, cancelFrame, apply) {
61
61
  },
62
62
  };
63
63
  }
64
+
65
+ /**
66
+ * Resolve a width prop that is either a pixel number or a CSS length.
67
+ * Plain `px` strings parse directly; anything else is handed to `measure`,
68
+ * which lays the length out and returns its pixel size (or `undefined` when
69
+ * there is no DOM to measure in).
70
+ *
71
+ * @param {number | string} value
72
+ * @param {(css: string) => number | undefined} measure
73
+ * @returns {number | undefined}
74
+ */
75
+ export function resolveLength(value, measure) {
76
+ if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
77
+ const px = /^\s*(-?\d*\.?\d+)px\s*$/.exec(value);
78
+ if (px) return Number(px[1]);
79
+ const measured = measure(value);
80
+ return measured !== undefined && Number.isFinite(measured) ? measured : undefined;
81
+ }
82
+
83
+ /**
84
+ * @typedef {object} ResizeHandleParams
85
+ * @property {'left' | 'right'} side Edge the resized box sits on; dragging away from it grows the box.
86
+ * @property {(width: number) => void} onwidth Called once per animation frame while dragging and on every keyboard step.
87
+ * @property {(width: number) => void} [oncommit] Called with the settled width on pointer release and after each keyboard step.
88
+ * @property {() => void} [onreset] Double-click on the handle.
89
+ * @property {(active: boolean) => void} [onactive] Drag start/end, for a `resizing` class.
90
+ * @property {() => number} [measure] Current width in px; defaults to the handle's parent box.
91
+ * @property {number} [min]
92
+ * @property {number} [max]
93
+ * @property {number} [step] Pixels per arrow key press (default 16).
94
+ */
95
+
96
+ /**
97
+ * Svelte action turning any element into a pointer + keyboard width grip.
98
+ * One pointer-capture / rAF-coalesced implementation shared by
99
+ * ResizablePanel and consumer-built grips.
100
+ *
101
+ * Usage: <div role="separator" tabindex="0" use:resizeHandle={{ side, min, max, onwidth }}></div>
102
+ *
103
+ * @param {HTMLElement} node
104
+ * @param {ResizeHandleParams} params
105
+ */
106
+ export function resizeHandle(node, params) {
107
+ let current = params;
108
+ let active = false;
109
+ let startX = 0;
110
+ let startWidth = 0;
111
+
112
+ const frames = createFrameBatcher(
113
+ (callback) => requestAnimationFrame(callback),
114
+ (handle) => cancelAnimationFrame(handle),
115
+ /** @param {number} width */
116
+ (width) => current.onwidth(width),
117
+ );
118
+
119
+ /** @param {number} width */
120
+ function clamp(width) {
121
+ const min = current.min ?? 1;
122
+ const max = current.max ?? Number.POSITIVE_INFINITY;
123
+ return Math.round(Math.max(min, Math.min(width, Math.max(min, max))));
124
+ }
125
+
126
+ function direction() {
127
+ return current.side === 'right' ? -1 : 1;
128
+ }
129
+
130
+ function measure() {
131
+ if (current.measure) return current.measure();
132
+ return node.parentElement?.getBoundingClientRect().width ?? 0;
133
+ }
134
+
135
+ /** @param {number} clientX */
136
+ function widthAt(clientX) {
137
+ return clamp(startWidth + (clientX - startX) * direction());
138
+ }
139
+
140
+ /** @param {PointerEvent} event */
141
+ function down(event) {
142
+ if (event.button !== 0) return;
143
+ active = true;
144
+ startX = event.clientX;
145
+ startWidth = measure();
146
+ node.setPointerCapture(event.pointerId);
147
+ event.preventDefault();
148
+ current.onactive?.(true);
149
+ }
150
+
151
+ /** @param {PointerEvent} event */
152
+ function move(event) {
153
+ if (!active) return;
154
+ frames.schedule(widthAt(event.clientX));
155
+ }
156
+
157
+ /** @param {PointerEvent} event */
158
+ function up(event) {
159
+ if (!active) return;
160
+ active = false;
161
+ const width = widthAt(event.clientX);
162
+ frames.flush(width);
163
+ try {
164
+ node.releasePointerCapture(event.pointerId);
165
+ } catch {
166
+ // Pointer capture may already have been released by the browser.
167
+ }
168
+ current.onactive?.(false);
169
+ current.oncommit?.(width);
170
+ }
171
+
172
+ /** @param {KeyboardEvent} event */
173
+ function keydown(event) {
174
+ const step = current.step ?? 16;
175
+ /** @type {number | undefined} */
176
+ let next;
177
+ switch (event.key) {
178
+ case 'ArrowLeft':
179
+ next = measure() - step * direction();
180
+ break;
181
+ case 'ArrowRight':
182
+ next = measure() + step * direction();
183
+ break;
184
+ case 'Home':
185
+ next = current.min;
186
+ break;
187
+ case 'End':
188
+ next = current.max;
189
+ break;
190
+ }
191
+ if (next === undefined) return;
192
+ event.preventDefault();
193
+ const width = clamp(next);
194
+ current.onwidth(width);
195
+ current.oncommit?.(width);
196
+ }
197
+
198
+ function reset() {
199
+ current.onreset?.();
200
+ }
201
+
202
+ node.addEventListener('pointerdown', down);
203
+ node.addEventListener('pointermove', move);
204
+ node.addEventListener('pointerup', up);
205
+ node.addEventListener('pointercancel', up);
206
+ node.addEventListener('keydown', keydown);
207
+ node.addEventListener('dblclick', reset);
208
+
209
+ return {
210
+ /** @param {ResizeHandleParams} next */
211
+ update(next) {
212
+ current = next;
213
+ },
214
+ destroy() {
215
+ node.removeEventListener('pointerdown', down);
216
+ node.removeEventListener('pointermove', move);
217
+ node.removeEventListener('pointerup', up);
218
+ node.removeEventListener('pointercancel', up);
219
+ node.removeEventListener('keydown', keydown);
220
+ node.removeEventListener('dblclick', reset);
221
+ frames.discard();
222
+ },
223
+ };
224
+ }
@@ -1,23 +1,21 @@
1
1
  <script lang="ts">
2
2
  // Theme switcher: a compact icon-button hosting a native <select> over the
3
- // full theme registry, wired to the global theme store. The native control
4
- // gives correct mobile/keyboard behaviour and outside-click handling for free.
5
- // The button glyph reflects the active theme's icon.
3
+ // full theme registry (built-ins + theme.register()), wired to the global
4
+ // theme store. The native control gives correct mobile/keyboard behaviour and
5
+ // outside-click handling for free. The button glyph reflects the active theme.
6
6
  import SelectButton from './SelectButton.svelte';
7
- import { theme, THEMES } from '../../stores/theme.svelte';
7
+ import { type ThemeDef, theme } from '../../stores/theme.svelte';
8
8
 
9
9
  let { class: klass = '' }: { class?: string } = $props();
10
10
 
11
- // Split the registry into light/dark sections (TSU-1) so the long list is
12
- // scannable. `<optgroup>` keeps native popup/keyboard behaviour for free.
13
- const toOption = (t: (typeof THEMES)[number]) => ({
11
+ const toOption = (t: ThemeDef) => ({
14
12
  value: t.id,
15
- label: `${t.icon} ${t.label}`
13
+ label: `${t.icon ?? theme.fallbackIcon} ${t.label}`
16
14
  });
17
- const groups = [
18
- { label: '— light', options: THEMES.filter((t) => t.mode === 'light').map(toOption) },
19
- { label: '— dark', options: THEMES.filter((t) => t.mode === 'dark').map(toOption) }
20
- ];
15
+ const groups = $derived([
16
+ { label: '— light', options: theme.all.filter((t) => t.mode === 'light').map(toOption) },
17
+ { label: '— dark', options: theme.all.filter((t) => t.mode === 'dark').map(toOption) }
18
+ ]);
21
19
  </script>
22
20
 
23
21
  <SelectButton
@@ -28,5 +26,5 @@
28
26
  title={`Theme: ${theme.label}`}
29
27
  value={theme.current}
30
28
  {groups}
31
- onchange={(v) => theme.set(v as (typeof THEMES)[number]['id'])}
29
+ onchange={(v) => theme.set(v)}
32
30
  />
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ export { default as Icon } from './components/atoms/Icon.svelte';
11
11
  export { default as Input } from './components/atoms/Input.svelte';
12
12
  export { default as Link } from './components/atoms/Link.svelte';
13
13
  export { default as Progress } from './components/atoms/Progress.svelte';
14
+ export { default as Scrim } from './components/atoms/Scrim.svelte';
14
15
  export { default as SegmentedProgress, type ProgressSegment, } from './components/atoms/SegmentedProgress.svelte';
15
16
  export { default as Select } from './components/atoms/Select.svelte';
16
17
  export { default as Slider } from './components/atoms/Slider.svelte';
@@ -25,6 +26,7 @@ export { default as Container } from './components/layouts/Container.svelte';
25
26
  export { default as NavItem } from './components/layouts/NavItem.svelte';
26
27
  export { default as NavSection } from './components/layouts/NavSection.svelte';
27
28
  export { default as ResizablePanel } from './components/layouts/ResizablePanel.svelte';
29
+ export { type ResizeHandleParams, resizeHandle, } from './components/layouts/resizable-panel-frame.js';
28
30
  export { default as Stack } from './components/layouts/Stack.svelte';
29
31
  export { type AccordionItem, default as Accordion, } from './components/molecules/Accordion.svelte';
30
32
  export { type BreadcrumbItem, default as Breadcrumb, } from './components/molecules/Breadcrumb.svelte';
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ export { default as Icon } from './components/atoms/Icon.svelte';
16
16
  export { default as Input } from './components/atoms/Input.svelte';
17
17
  export { default as Link } from './components/atoms/Link.svelte';
18
18
  export { default as Progress } from './components/atoms/Progress.svelte';
19
+ export { default as Scrim } from './components/atoms/Scrim.svelte';
19
20
  export { default as SegmentedProgress, } from './components/atoms/SegmentedProgress.svelte';
20
21
  export { default as Select } from './components/atoms/Select.svelte';
21
22
  export { default as Slider } from './components/atoms/Slider.svelte';
@@ -32,6 +33,7 @@ export { default as Container } from './components/layouts/Container.svelte';
32
33
  export { default as NavItem } from './components/layouts/NavItem.svelte';
33
34
  export { default as NavSection } from './components/layouts/NavSection.svelte';
34
35
  export { default as ResizablePanel } from './components/layouts/ResizablePanel.svelte';
36
+ export { resizeHandle, } from './components/layouts/resizable-panel-frame.js';
35
37
  export { default as Stack } from './components/layouts/Stack.svelte';
36
38
  export { default as Accordion, } from './components/molecules/Accordion.svelte';
37
39
  export { default as Breadcrumb, } from './components/molecules/Breadcrumb.svelte';
@@ -145,17 +145,33 @@ export declare const THEMES: readonly [{
145
145
  }];
146
146
  export type Mode = (typeof THEMES)[number]['id'];
147
147
  export type ThemeMode = (typeof THEMES)[number]['mode'];
148
- type ThemeOption = (typeof THEMES)[number];
148
+ export type ThemeId = Mode | (string & {});
149
+ export interface ThemeDef {
150
+ id: ThemeId;
151
+ label: string;
152
+ mode: ThemeMode;
153
+ icon?: string;
154
+ themeColor?: string;
155
+ }
149
156
  declare class Theme {
150
- current: "light" | "highcontrast" | "gruvboxlight" | "solarizedlight" | "everforestlight" | "rosepinedawn" | "latte" | "nordlight" | "tokyoday" | "kanagawalotus" | "sepia" | "dark" | "colorblind" | "mocha" | "dracula" | "nord" | "tokyonight" | "gruvbox" | "solarized" | "rosepine" | "onedark" | "everforest" | "monokai" | "amoled";
157
+ current: ThemeId;
158
+ readonly fallbackIcon = "\u25C8";
159
+ private registered;
160
+ private fallback;
161
+ private saved;
151
162
  constructor();
163
+ get all(): readonly ThemeDef[];
164
+ has(id: string | null | undefined): id is ThemeId;
165
+ register(defs: ThemeDef | ThemeDef[]): void;
166
+ setDefault(id: ThemeId): void;
167
+ private resolve;
152
168
  private apply;
153
- get option(): ThemeOption;
169
+ get option(): ThemeDef;
154
170
  get label(): string;
155
171
  get icon(): string;
156
- get next(): ThemeOption;
172
+ get next(): ThemeDef;
157
173
  toggle(): void;
158
- set(mode: Mode): void;
174
+ set(mode: ThemeId): void;
159
175
  }
160
176
  export declare const theme: Theme;
161
177
  export {};
@@ -1,8 +1,9 @@
1
1
  import { browser } from '../env';
2
- // Theme registry the single list the picker and the store both read. Adding a
3
- // theme = one entry here + one [data-theme="id"] block in variables.css. Nothing
4
- // else changes. `themeColor` drives the mobile browser-chrome <meta theme-color>;
5
- // `mode` groups the theme into the picker's light/dark sections (TSU-1).
2
+ // Theme registry. Built-ins live in THEMES (+ one [data-theme="id"] block in
3
+ // styles/themes.css); consumers append their own with theme.register() and ship
4
+ // the matching block in their own stylesheet. `themeColor` drives the mobile
5
+ // browser-chrome <meta theme-color>; `mode` groups the theme into the picker's
6
+ // light/dark sections.
6
7
  const KEY = 'tsumikit-theme';
7
8
  export const THEMES = [
8
9
  // ── Light ── bright, paper-white surfaces
@@ -51,50 +52,75 @@ export const THEMES = [
51
52
  { id: 'monokai', label: 'Monokai', icon: '✸', themeColor: '#272822', mode: 'dark' },
52
53
  { id: 'amoled', label: 'AMOLED (high contrast)', icon: '◼', themeColor: '#000000', mode: 'dark' },
53
54
  ];
54
- const ORDER = THEMES.map((t) => t.id);
55
- function isMode(value) {
56
- return THEMES.some((t) => t.id === value);
57
- }
58
- function optionFor(mode) {
59
- return THEMES.find((t) => t.id === mode) ?? THEMES[0];
60
- }
55
+ const FALLBACK_ICON = '◈';
61
56
  class Theme {
62
57
  current = $state('dark');
58
+ fallbackIcon = FALLBACK_ICON;
59
+ registered = $state([]);
60
+ fallback = 'dark';
61
+ saved = null;
63
62
  constructor() {
64
63
  if (browser) {
65
- const saved = localStorage.getItem(KEY);
66
- this.current = isMode(saved) ? saved : 'dark';
67
- this.apply();
64
+ this.saved = localStorage.getItem(KEY);
65
+ this.resolve();
68
66
  }
69
67
  }
68
+ get all() {
69
+ const byId = new Map();
70
+ for (const t of THEMES)
71
+ byId.set(t.id, t);
72
+ for (const t of this.registered)
73
+ byId.set(t.id, t);
74
+ return [...byId.values()];
75
+ }
76
+ has(id) {
77
+ return id != null && this.all.some((t) => t.id === id);
78
+ }
79
+ register(defs) {
80
+ const list = Array.isArray(defs) ? defs : [defs];
81
+ this.registered = [...this.registered.filter((r) => !list.some((d) => d.id === r.id)), ...list];
82
+ this.resolve();
83
+ }
84
+ setDefault(id) {
85
+ this.fallback = id;
86
+ this.resolve();
87
+ }
88
+ resolve() {
89
+ this.current = this.has(this.saved) ? this.saved : this.fallback;
90
+ this.apply();
91
+ }
70
92
  apply() {
71
93
  if (!browser)
72
94
  return;
73
95
  document.documentElement.setAttribute('data-theme', this.current);
74
- document
75
- .querySelector('meta[name="theme-color"]')
76
- ?.setAttribute('content', this.option.themeColor);
96
+ const color = this.option.themeColor;
97
+ if (color)
98
+ document
99
+ .querySelector('meta[name="theme-color"]')
100
+ ?.setAttribute('content', color);
77
101
  }
78
102
  get option() {
79
- return optionFor(this.current);
103
+ return this.all.find((t) => t.id === this.current) ?? this.all[0];
80
104
  }
81
105
  get label() {
82
106
  return this.option.label;
83
107
  }
84
108
  get icon() {
85
- return this.option.icon;
109
+ return this.option.icon ?? FALLBACK_ICON;
86
110
  }
87
111
  get next() {
88
- const i = ORDER.indexOf(this.current);
89
- return optionFor(ORDER[(i + 1) % ORDER.length]);
112
+ const all = this.all;
113
+ const i = all.findIndex((t) => t.id === this.current);
114
+ return all[(i + 1) % all.length];
90
115
  }
91
116
  toggle() {
92
117
  this.set(this.next.id);
93
118
  }
94
119
  set(mode) {
120
+ this.saved = mode;
95
121
  this.current = mode;
96
122
  if (browser)
97
- localStorage.setItem(KEY, this.current);
123
+ localStorage.setItem(KEY, mode);
98
124
  this.apply();
99
125
  }
100
126
  }