@sveltia/ui 0.39.2 → 0.40.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.
@@ -41,7 +41,7 @@
41
41
  {#if label}
42
42
  <div role="row" class="row-group-caption">
43
43
  <!-- We need `colspan` here but cannot place `<th>` under `<div>`, so use a hack -->
44
- <svelte:element this={'th'} role="columnheader" id="{id}-label" colspan="9999">
44
+ <svelte:element this={"th"} role="columnheader" id="{id}-label" colspan="9999">
45
45
  {label}
46
46
  </svelte:element>
47
47
  </div>
@@ -53,16 +53,25 @@
53
53
  const sizes = $derived(ctx.sizes);
54
54
  // The value reported via aria-valuenow is the size of the pane immediately before this handle
55
55
  const currentPaneSize = $derived(sizes[handleIndex] ?? 0);
56
+ const currentPaneConstraints = $derived(ctx.getPaneConstraints(handleIndex));
56
57
  const currentPaneMin = $derived(
57
58
  Math.max(
58
- ctx.paneDefs[handleIndex]?.minSize ?? 0,
59
- 100 - ctx.paneDefs.reduce((sum, p, i) => sum + (i !== handleIndex ? p.maxSize : 0), 0),
59
+ currentPaneConstraints.minSize,
60
+ 100 -
61
+ ctx.paneDefs.reduce(
62
+ (sum, _pane, i) => sum + (i !== handleIndex ? ctx.getPaneConstraints(i).maxSize : 0),
63
+ 0,
64
+ ),
60
65
  ),
61
66
  );
62
67
  const currentPaneMax = $derived(
63
68
  Math.min(
64
- ctx.paneDefs[handleIndex]?.maxSize ?? 100,
65
- 100 - ctx.paneDefs.reduce((sum, p, i) => sum + (i !== handleIndex ? p.minSize : 0), 0),
69
+ currentPaneConstraints.maxSize,
70
+ 100 -
71
+ ctx.paneDefs.reduce(
72
+ (sum, _pane, i) => sum + (i !== handleIndex ? ctx.getPaneConstraints(i).minSize : 0),
73
+ 0,
74
+ ),
66
75
  ),
67
76
  );
68
77
 
@@ -21,6 +21,15 @@
21
21
  * whenever the pane sizes change.
22
22
  */
23
23
 
24
+ /**
25
+ * @typedef {{
26
+ * id: string,
27
+ * defaultSize: number | string | undefined,
28
+ * minSize: number | string,
29
+ * maxSize: number | string,
30
+ * }} PaneDef
31
+ */
32
+
24
33
  /**
25
34
  * @type {Props & Record<string, any>}
26
35
  */
@@ -37,10 +46,13 @@
37
46
  /**
38
47
  * `ResizablePane` definitions in registration order, populated synchronously by child
39
48
  * `<ResizablePane>` components.
40
- * @type {{ id: string, defaultSize: number | undefined, minSize: number, maxSize: number }[]}
49
+ * @type {PaneDef[]}
41
50
  */
42
51
  const _paneDefs = $state([]);
43
52
 
53
+ /** @type {HTMLDivElement | undefined} */
54
+ let element = $state();
55
+
44
56
  /**
45
57
  * Current pane sizes as percentages.
46
58
  * @type {number[]}
@@ -55,6 +67,85 @@
55
67
 
56
68
  let _handleCount = 0;
57
69
 
70
+ /**
71
+ * Get the pane group container element’s size in pixels for size conversion.
72
+ * @returns {number} Container size in pixels.
73
+ */
74
+ const getContainerSize = () => {
75
+ if (!element) return 0;
76
+
77
+ return direction === 'horizontal' ? element.clientWidth : element.clientHeight;
78
+ };
79
+
80
+ /**
81
+ * Resolve numeric or CSS string size values to percentage points.
82
+ * @param {number | string | undefined} value Size as percentage number or CSS size string.
83
+ * @param {number} fallback Fallback percentage when resolution fails.
84
+ * @returns {number} Size in percentage points.
85
+ */
86
+ const resolveToPercent = (value, fallback) => {
87
+ if (typeof value === 'number') {
88
+ return value;
89
+ }
90
+
91
+ if (!value || typeof value !== 'string') {
92
+ return fallback;
93
+ }
94
+
95
+ const trimmed = value.trim();
96
+ const matchedPercent = trimmed.match(/^(-?\d+(?:\.\d+)?)%$/);
97
+
98
+ if (matchedPercent) {
99
+ return Number(matchedPercent[1]);
100
+ }
101
+
102
+ const containerSize = getContainerSize();
103
+
104
+ if (!containerSize) {
105
+ return fallback;
106
+ }
107
+
108
+ const matchedPx = trimmed.match(/^(-?\d+(?:\.\d+)?)px$/i);
109
+
110
+ if (matchedPx) {
111
+ return (Number(matchedPx[1]) / containerSize) * 100;
112
+ }
113
+
114
+ const matchedViewport = trimmed.match(/^(-?\d+(?:\.\d+)?)(dvw|vw|dvh|vh)$/i);
115
+
116
+ if (matchedViewport) {
117
+ const viewportValue = Number(matchedViewport[1]);
118
+ const unit = matchedViewport[2].toLowerCase();
119
+ const viewportSize = unit.endsWith('w') ? window.innerWidth : window.innerHeight;
120
+ const pixels = (viewportValue / 100) * viewportSize;
121
+
122
+ return (pixels / containerSize) * 100;
123
+ }
124
+
125
+ return fallback;
126
+ };
127
+
128
+ /**
129
+ * Get pane constraints converted to percentages for the current container size.
130
+ * @param {number} paneIndex Pane index.
131
+ * @returns {{ minSize: number, maxSize: number }} Min/max in percentages.
132
+ */
133
+ const getPaneConstraints = (paneIndex) => {
134
+ const paneDef = _paneDefs[paneIndex];
135
+
136
+ if (!paneDef) {
137
+ return { minSize: 0, maxSize: 100 };
138
+ }
139
+
140
+ const minSize = Math.max(0, resolveToPercent(paneDef.minSize, 0));
141
+ const maxSize = Math.min(100, resolveToPercent(paneDef.maxSize, 100));
142
+
143
+ return {
144
+ minSize,
145
+ maxSize: Math.max(minSize, maxSize),
146
+ };
147
+ };
148
+
58
149
  /**
59
150
  * Initialize pane sizes from `defaultSize` props. Called from `onMount` once all panes have
60
151
  * registered. Panes without `defaultSize` share the remaining space equally.
@@ -62,11 +153,26 @@
62
153
  const initSizes = () => {
63
154
  if (!_paneDefs.length) return;
64
155
 
65
- const totalSpecified = _paneDefs.reduce((sum, p) => sum + (p.defaultSize ?? 0), 0);
66
- const unspecifiedCount = _paneDefs.filter((p) => p.defaultSize === undefined).length;
156
+ // Resolve each pane’s defaultSize to a percentage (NaN if unspecified or unresolvable).
157
+ // Resolving once ensures totalSpecified and newSizes use the same resolved value.
158
+ const resolvedDefaults = _paneDefs.map((p) => {
159
+ if (p.defaultSize === undefined) {
160
+ return NaN;
161
+ }
162
+
163
+ const resolved = resolveToPercent(p.defaultSize, NaN);
164
+
165
+ return resolved;
166
+ });
167
+
168
+ const totalSpecified = resolvedDefaults
169
+ .filter((v) => !Number.isNaN(v))
170
+ .reduce((sum, v) => sum + v, 0);
171
+
172
+ const unspecifiedCount = resolvedDefaults.filter((v) => Number.isNaN(v)).length;
67
173
  const remaining = Math.max(0, 100 - totalSpecified);
68
174
  const defaultSize = unspecifiedCount > 0 ? remaining / unspecifiedCount : 0;
69
- const newSizes = _paneDefs.map((p) => p.defaultSize ?? defaultSize);
175
+ const newSizes = resolvedDefaults.map((v) => (Number.isNaN(v) ? defaultSize : v));
70
176
 
71
177
  sizes.splice(0, sizes.length, ...newSizes);
72
178
  };
@@ -82,8 +188,8 @@
82
188
 
83
189
  if (beforeIdx < 0 || afterIdx >= sizes.length) return;
84
190
 
85
- const { minSize: minBefore = 0, maxSize: maxBefore = 100 } = _paneDefs[beforeIdx];
86
- const { minSize: minAfter = 0, maxSize: maxAfter = 100 } = _paneDefs[afterIdx];
191
+ const { minSize: minBefore, maxSize: maxBefore } = getPaneConstraints(beforeIdx);
192
+ const { minSize: minAfter, maxSize: maxAfter } = getPaneConstraints(afterIdx);
87
193
  const prevBefore = sizes[beforeIdx];
88
194
  const prevAfter = sizes[afterIdx];
89
195
  // Clamp delta so neither pane exceeds its min/max constraints
@@ -105,7 +211,7 @@
105
211
  * @param {number} handleIndex Index of the resize handle.
106
212
  */
107
213
  const toggleCollapse = (handleIndex) => {
108
- const { minSize: minBefore = 0 } = _paneDefs[handleIndex];
214
+ const { minSize: minBefore } = getPaneConstraints(handleIndex);
109
215
 
110
216
  if (_savedSizes[handleIndex] !== undefined) {
111
217
  const delta = /** @type {number} */ (_savedSizes[handleIndex]) - sizes[handleIndex];
@@ -142,6 +248,7 @@
142
248
  },
143
249
  resize,
144
250
  toggleCollapse,
251
+ getPaneConstraints,
145
252
  paneDefs: _paneDefs,
146
253
  }),
147
254
  /* eslint-enable jsdoc/require-jsdoc */
@@ -155,6 +262,7 @@
155
262
  </script>
156
263
 
157
264
  <div
265
+ bind:this={element}
158
266
  {...restProps}
159
267
  role="none"
160
268
  class="sui resizable-pane-group {direction} {className ?? ''}"
@@ -13,10 +13,13 @@
13
13
 
14
14
  /**
15
15
  * @typedef {object} Props
16
- * @property {number} [defaultSize] Default size as a percentage (0100). Panes without
16
+ * @property {number | string} [defaultSize] Default size. Numbers are percentages (0-100);
17
+ * strings can be any CSS length/percentage such as `240px`, `20%` or `20dvw`. Panes without
17
18
  * `defaultSize` share the remaining space equally.
18
- * @property {number} [minSize] Minimum size as a percentage. Defaults to `0`.
19
- * @property {number} [maxSize] Maximum size as a percentage. Defaults to `100`.
19
+ * @property {number | string} [minSize] Minimum size. Numbers are percentages, strings are CSS
20
+ * lengths. Defaults to `0`.
21
+ * @property {number | string} [maxSize] Maximum size. Numbers are percentages, strings are CSS
22
+ * lengths. Defaults to `100`.
20
23
  * @property {string} [class] The `class` attribute on the wrapper element.
21
24
  * @property {Snippet} [children] Primary slot content.
22
25
  * @property {(detail: { size: number }) => void} [onResize] Called whenever this pane’s size (in
@@ -52,7 +55,17 @@
52
55
 
53
56
  const direction = $derived(ctx.direction);
54
57
  const size = $derived(ctx.sizes[paneIndex]);
55
- const sizeStyle = $derived(size !== undefined ? size : (defaultSize ?? 0));
58
+ const sizeStyle = $derived.by(() => {
59
+ if (size !== undefined) {
60
+ return `${size}%`;
61
+ }
62
+
63
+ if (typeof defaultSize === 'number') {
64
+ return `${defaultSize}%`;
65
+ }
66
+
67
+ return defaultSize ?? '0%';
68
+ });
56
69
 
57
70
  $effect(() => {
58
71
  if (size !== undefined) {
@@ -66,7 +79,7 @@
66
79
  {id}
67
80
  role="none"
68
81
  class="sui resizable-pane {className ?? ''}"
69
- style:flex-basis="{sizeStyle}%"
82
+ style:flex-basis={sizeStyle}
70
83
  style:flex-grow="0"
71
84
  style:flex-shrink="0"
72
85
  style:overflow-x={direction === 'horizontal' ? 'auto' : undefined}
@@ -9,18 +9,21 @@ type ResizablePane = {
9
9
  */
10
10
  declare const ResizablePane: import("svelte").Component<{
11
11
  /**
12
- * Default size as a percentage (0100). Panes without
12
+ * Default size. Numbers are percentages (0-100);
13
+ * strings can be any CSS length/percentage such as `240px`, `20%` or `20dvw`. Panes without
13
14
  * `defaultSize` share the remaining space equally.
14
15
  */
15
- defaultSize?: number | undefined;
16
+ defaultSize?: string | number | undefined;
16
17
  /**
17
- * Minimum size as a percentage. Defaults to `0`.
18
+ * Minimum size. Numbers are percentages, strings are CSS
19
+ * lengths. Defaults to `0`.
18
20
  */
19
- minSize?: number | undefined;
21
+ minSize?: string | number | undefined;
20
22
  /**
21
- * Maximum size as a percentage. Defaults to `100`.
23
+ * Maximum size. Numbers are percentages, strings are CSS
24
+ * lengths. Defaults to `100`.
22
25
  */
23
- maxSize?: number | undefined;
26
+ maxSize?: string | number | undefined;
24
27
  /**
25
28
  * The `class` attribute on the wrapper element.
26
29
  */
@@ -39,18 +42,21 @@ declare const ResizablePane: import("svelte").Component<{
39
42
  } & Record<string, any>, {}, "">;
40
43
  type Props = {
41
44
  /**
42
- * Default size as a percentage (0100). Panes without
45
+ * Default size. Numbers are percentages (0-100);
46
+ * strings can be any CSS length/percentage such as `240px`, `20%` or `20dvw`. Panes without
43
47
  * `defaultSize` share the remaining space equally.
44
48
  */
45
- defaultSize?: number | undefined;
49
+ defaultSize?: string | number | undefined;
46
50
  /**
47
- * Minimum size as a percentage. Defaults to `0`.
51
+ * Minimum size. Numbers are percentages, strings are CSS
52
+ * lengths. Defaults to `0`.
48
53
  */
49
- minSize?: number | undefined;
54
+ minSize?: string | number | undefined;
50
55
  /**
51
- * Maximum size as a percentage. Defaults to `100`.
56
+ * Maximum size. Numbers are percentages, strings are CSS
57
+ * lengths. Defaults to `100`.
52
58
  */
53
- maxSize?: number | undefined;
59
+ maxSize?: string | number | undefined;
54
60
  /**
55
61
  * The `class` attribute on the wrapper element.
56
62
  */
@@ -41,7 +41,7 @@
41
41
  {#if label}
42
42
  <div role="row" class="row-group-caption">
43
43
  <!-- We need `colspan` here but cannot place `<th>` under `<div>`, so use a hack -->
44
- <svelte:element this={'th'} role="columnheader" id="{id}-label" colspan="9999">
44
+ <svelte:element this={"th"} role="columnheader" id="{id}-label" colspan="9999">
45
45
  {label}
46
46
  </svelte:element>
47
47
  </div>
@@ -731,6 +731,7 @@ export type SelectedItemDetail = {
731
731
  */
732
732
  value: any;
733
733
  };
734
+ export type ResizablePaneSize = number | string;
734
735
  export type ResizablePaneDefinition = {
735
736
  /**
736
737
  * ResizablePane element ID (generated via `$props.id()` in the pane
@@ -738,17 +739,20 @@ export type ResizablePaneDefinition = {
738
739
  */
739
740
  id: string;
740
741
  /**
741
- * Default size as a percentage (0–100).
742
+ * Default size as percentage number or CSS
743
+ * length/percentage string.
742
744
  */
743
- defaultSize: number | undefined;
745
+ defaultSize: ResizablePaneSize | undefined;
744
746
  /**
745
- * Minimum size as a percentage.
747
+ * Minimum size as percentage number or CSS length/percentage
748
+ * string.
746
749
  */
747
- minSize: number;
750
+ minSize: ResizablePaneSize;
748
751
  /**
749
- * Maximum size as a percentage.
752
+ * Maximum size as percentage number or CSS length/percentage
753
+ * string.
750
754
  */
751
- maxSize: number;
755
+ maxSize: ResizablePaneSize;
752
756
  };
753
757
  export type PaneGroupContext = {
754
758
  /**
@@ -780,6 +784,13 @@ export type PaneGroupContext = {
780
784
  * before the given handle.
781
785
  */
782
786
  toggleCollapse: (handleIndex: number) => void;
787
+ /**
788
+ * Resolve pane constraints to percentages for the current container size.
789
+ */
790
+ getPaneConstraints: (paneIndex: number) => {
791
+ minSize: number;
792
+ maxSize: number;
793
+ };
783
794
  /**
784
795
  * Registered pane definitions.
785
796
  */
package/dist/typedefs.js CHANGED
@@ -306,13 +306,20 @@
306
306
  * @property {any} value The `data-value` attribute on the element. Casted to the `valueType`.
307
307
  */
308
308
 
309
+ /**
310
+ * @typedef {number | string} ResizablePaneSize
311
+ */
312
+
309
313
  /**
310
314
  * @typedef {object} ResizablePaneDefinition
311
315
  * @property {string} id ResizablePane element ID (generated via `$props.id()` in the pane
312
316
  * component).
313
- * @property {number | undefined} defaultSize Default size as a percentage (0–100).
314
- * @property {number} minSize Minimum size as a percentage.
315
- * @property {number} maxSize Maximum size as a percentage.
317
+ * @property {ResizablePaneSize | undefined} defaultSize Default size as percentage number or CSS
318
+ * length/percentage string.
319
+ * @property {ResizablePaneSize} minSize Minimum size as percentage number or CSS length/percentage
320
+ * string.
321
+ * @property {ResizablePaneSize} maxSize Maximum size as percentage number or CSS length/percentage
322
+ * string.
316
323
  */
317
324
 
318
325
  /**
@@ -326,6 +333,8 @@
326
333
  * handle by the given delta.
327
334
  * @property {(handleIndex: number) => void} toggleCollapse Toggle collapse of the primary pane
328
335
  * before the given handle.
336
+ * @property {(paneIndex: number) => { minSize: number, maxSize: number }} getPaneConstraints
337
+ * Resolve pane constraints to percentages for the current container size.
329
338
  * @property {ResizablePaneDefinition[]} paneDefs Registered pane definitions.
330
339
  */
331
340
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sveltia/ui",
3
- "version": "0.39.2",
3
+ "version": "0.40.0",
4
4
  "description": "A collection of Svelte components and utilities for building user interfaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,32 +56,32 @@
56
56
  "@sveltejs/kit": "^2.60.1",
57
57
  "@sveltejs/package": "^2.5.7",
58
58
  "@sveltejs/vite-plugin-svelte": "^7.1.2",
59
- "@vitest/coverage-v8": "^4.1.6",
59
+ "@vitest/coverage-v8": "^4.1.7",
60
60
  "cspell": "^10.0.0",
61
61
  "eslint": "^9.39.4",
62
62
  "eslint-config-airbnb-extended": "^3.1.0",
63
63
  "eslint-config-prettier": "^10.1.8",
64
64
  "eslint-plugin-import": "^2.32.0",
65
- "eslint-plugin-jsdoc": "^62.9.0",
66
- "eslint-plugin-package-json": "^0.91.2",
65
+ "eslint-plugin-jsdoc": "^63.0.0",
66
+ "eslint-plugin-package-json": "^1.1.0",
67
67
  "eslint-plugin-svelte": "^3.17.1",
68
68
  "globals": "^17.6.0",
69
69
  "happy-dom": "^20.9.0",
70
- "oxlint": "^1.65.0",
71
- "postcss": "^8.5.14",
70
+ "oxlint": "^1.66.0",
71
+ "postcss": "^8.5.15",
72
72
  "postcss-html": "^1.8.1",
73
73
  "prettier": "^3.8.3",
74
- "prettier-plugin-svelte": "^3.5.2",
74
+ "prettier-plugin-svelte": "^4.0.1",
75
75
  "sass": "^1.99.0",
76
- "stylelint": "^17.11.1",
76
+ "stylelint": "^17.12.0",
77
77
  "stylelint-config-recommended-scss": "^17.0.1",
78
78
  "stylelint-scss": "^7.1.1",
79
- "svelte": "^5.55.7",
79
+ "svelte": "^5.55.9",
80
80
  "svelte-check": "^4.4.8",
81
81
  "svelte-preprocess": "^6.0.3",
82
82
  "tslib": "^2.8.1",
83
- "vite": "^8.0.13",
84
- "vitest": "^4.1.6"
83
+ "vite": "^8.0.14",
84
+ "vitest": "^4.1.7"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "svelte": "^5.0.0"