svelte-plots-basic 3.4.0 → 4.0.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.
@@ -11,24 +11,22 @@
11
11
  - `lineColor` - color of segment lines, default: `Colors.PRIMARY`.
12
12
  - `lineType` - type of segment lines (`1` - solid, `2` - dashed, `3` - dotted, `4` - dashdot).
13
13
  - `lineWidth` - width (thickness) of segment lines in pixels, default: `1`.
14
- - `className` - CSS class name of the SVG group, default: `'series-seg'`.
15
14
  - `title` - title of the segment series group.
16
- - `onclick` - function (callback) to be called when user clicks on any segment.
17
15
 
18
16
  Example:
19
- ```jsx
17
+ ```svelte
20
18
  <script>
21
19
  import { Axes, Segments } from 'svelte-plots-basic/3d';
22
20
 
23
- const xStart = [1, 2, 3, 4];
24
- const xEnd = [1, 2, 3, 4];
21
+ const xStart = [1, 2, 3, 4, 5];
22
+ const xEnd = [1, 2, 3, 4, 5];
25
23
  const yStart = [-2, -1, 0, -1, -2];
26
24
  const yEnd = [2, 1, 0, 1, 2];
27
- const zStart = [1, 2, 3, 4];
28
- const zEnd = [1, 2, 3, 4];
25
+ const zStart = [1, 2, 3, 4, 5];
26
+ const zEnd = [1, 2, 3, 4, 5];
29
27
  </script>
30
28
 
31
- <Axes limX={[0, 5]} limY={[-3, 3]} >
29
+ <Axes limX={[0, 6]} limY={[-3, 3]} limZ={[0, 6]}>
32
30
  <Segments {xStart} {yStart} {zStart} {xEnd} {yEnd} {zEnd} lineColor="red" lineType={3} />
33
31
  </Axes>
34
32
  ```
@@ -37,7 +35,7 @@
37
35
  import { cbind } from 'mdatools/arrays';
38
36
  import { getContext } from 'svelte';
39
37
  import { Colors, LINE_STYLES } from '../constants';
40
- import { checkCoords, transform3D } from '../methods';
38
+ import { checkCoords, transform3D, normalizeLineType } from '../methods';
41
39
 
42
40
 
43
41
  let {
@@ -62,6 +60,7 @@
62
60
  const ux2 = $derived(uz1 ? checkCoords(xEnd, 'Segments (3D)', ux1.length) : null);
63
61
  const uy2 = $derived(ux2 ? checkCoords(yEnd, 'Segments (3D)', ux1.length) : null);
64
62
  const uz2 = $derived(uy2 ? checkCoords(zEnd, 'Segments (3D)', ux1.length) : null);
63
+ const validLineType = $derived(normalizeLineType(lineType, 'Segments (3D)'));
65
64
 
66
65
  // combine world coordinates to matrices
67
66
  const uW1 = $derived(ux1 && uy1 && uz1 ? cbind(ux1, uy1, uz1) : null);
@@ -72,14 +71,13 @@
72
71
  const s1 = $derived(uW1 ? transform3D(uW1, axes.tM()) : null);
73
72
  const s2 = $derived(uW2 ? transform3D(uW2, axes.tM()) : null);
74
73
 
75
- const lineStyleStr = $derived(`stroke:${lineColor};stroke-width: ${lineWidth}px;stroke-dasharray:${LINE_STYLES[axes.scale()][lineType-1]}`);
74
+ const lineStyleStr = $derived(`stroke:${lineColor};stroke-width: ${lineWidth}px;stroke-dasharray:${LINE_STYLES[axes.scale()][validLineType - 1]}`);
76
75
  </script>
77
76
 
78
- <g class="series series-segments" data-title={title} style={lineStyleStr}>
79
77
  {#if s1 && s2}
78
+ <g class="series series-segments" data-title={title} style={lineStyleStr}>
80
79
  {#each s1.x as v, i}
81
80
  <line x1={s1.x[i]} x2={s2.x[i]} y1={s1.y[i]} y2={s2.y[i]} />
82
81
  {/each}
83
- {/if}
84
82
  </g>
85
-
83
+ {/if}
@@ -2,18 +2,22 @@
2
2
  @component Adds a series of text labels at given coordinates.
3
3
 
4
4
  Main properties:
5
- - `xValues` - array of vector with x-coordinates of the points.
6
- - `yValues` - array of vector with y-coordinates of the points.
7
- - `zValues` - array of vector with z-coordinates of the points.
8
- - `labels` - array with labels.
9
- - `faceColor` - face (fill) color of the markers, default: `'transparent'`.
10
- - `borderColor` - color markers' borders, default: `Colors.PRIMARY`.
11
- - `borderWidth` - width (thickness) of the markers' borders in pixels, default: `1`.
12
- - `textSize` - size of the markers in em, default: `1`.
13
- - `title` - title of the line series group.
5
+ - `xValues` - array or vector with x-coordinates of the labels.
6
+ - `yValues` - array or vector with y-coordinates of the labels.
7
+ - `zValues` - array or vector with z-coordinates of the labels.
8
+ - `labels` - single string used at every coordinate or array with one string per coordinate.
9
+ - `faceColor` - text fill color, default: `Colors.PRIMARY_TEXT`.
10
+ - `borderColor` - text outline color, default: `'transparent'`.
11
+ - `borderWidth` - text outline width in pixels, default: `0`.
12
+ - `textSize` - text size in em, default: `1`.
13
+ - `fontWeight` - CSS font weight, default: `'normal'`.
14
+ - `className` - CSS class name for the labels group, default: `'series-textlabel'`.
15
+ - `title` - title of the labels group, default: `''`.
16
+
17
+ Values in `labels` are rendered as raw SVG/HTML markup. Only pass trusted content.
14
18
 
15
19
  Example:
16
- ```jsx
20
+ ```svelte
17
21
  <script>
18
22
  import { Axes, TextLabels } from 'svelte-plots-basic/3d';
19
23
 
@@ -56,10 +60,19 @@
56
60
 
57
61
  const l = $derived.by(() => {
58
62
  if (!ux) return null;
59
- if(Array.isArray(labels) && labels.length !== ux.length ) {
60
- console.error('TextLabels: labels provided as an array should have the same number of elements as number of coordinates.');
63
+
64
+ if (typeof labels === 'string') return labels;
65
+
66
+ if (!Array.isArray(labels)) {
67
+ console.error('TextLabels (3D): parameter "labels" must be a string or an array.');
61
68
  return null;
62
69
  }
70
+
71
+ if (labels.length !== ux.length) {
72
+ console.error('TextLabels (3D): labels provided as an array must have the same number of elements as coordinates.');
73
+ return null;
74
+ }
75
+
63
76
  return labels;
64
77
  });
65
78
 
@@ -75,7 +88,7 @@
75
88
  {#if s}
76
89
  <g class="series {className}" title={title} style={textStyleStr} >
77
90
 
78
- {#if typeof labels === 'string'}
91
+ {#if typeof l === 'string'}
79
92
  {#each s.x as v, i}
80
93
  <text data-id={i} x={s.x[i]} y={s.y[i]} dx={0} dy={0}>{@html l}</text>
81
94
  {/each}
@@ -91,4 +104,4 @@
91
104
  text, text > :global(tspan) {
92
105
  dominant-baseline: middle;
93
106
  }
94
- </style>
107
+ </style>
@@ -11,12 +11,11 @@
11
11
  - `textColor` - color of axis text labels, default: `Colors.DARKGRAY`.
12
12
 
13
13
  Example:
14
- ```jsx
14
+ ```svelte
15
15
  <script>
16
16
  import {Axes, XAxis} from 'svelte-plots-basic/3d';
17
17
  </script>
18
18
  <Axes>
19
- // all other plotting components are here
20
19
  <XAxis label="X" showGrid={true} />
21
20
  </Axes>
22
21
  ```
@@ -36,7 +35,7 @@
36
35
  } = $props();
37
36
 
38
37
 
39
- // get axes context and adjust x margins
38
+ // get axes context and synchronize x-axis settings
40
39
  const axes = getContext('axes');
41
40
  $effect(() => {
42
41
  axes.setXAxis({show: true, ticks, tickLabels, showGrid, label, lineColor, gridColor, textColor});
@@ -46,4 +45,3 @@
46
45
  axes.setXAxis({show: false});
47
46
  });
48
47
  </script>
49
-
@@ -3,20 +3,19 @@
3
3
 
4
4
  Main properties:
5
5
  - `label` - text label for the axis (optional).
6
- - `showGrid` - logical, show or not vertical grid lines, default: `false`.
7
- - `ticks` - array or vector with tick positions (by default will be generated automatically based on `limX`).
6
+ - `showGrid` - logical, show or hide grid lines, default: `false`.
7
+ - `ticks` - array or vector with tick positions (by default will be generated automatically based on `limY`).
8
8
  - `tickLabels` - array with tick labels to show at each tick (by default will be generated automatically).
9
9
  - `lineColor` - color of axis line, default: `Colors.DARKGRAY`.
10
10
  - `gridColor` - color of axis grid line, default: `Colors.MIDDLEGRAY`.
11
11
  - `textColor` - color of axis text labels, default: `Colors.DARKGRAY`.
12
12
 
13
13
  Example:
14
- ```jsx
14
+ ```svelte
15
15
  <script>
16
16
  import {Axes, YAxis} from 'svelte-plots-basic/3d';
17
17
  </script>
18
18
  <Axes>
19
- // all other plotting components are here
20
19
  <YAxis label="Y" showGrid={true} />
21
20
  </Axes>
22
21
  ```
@@ -36,7 +35,7 @@
36
35
  } = $props();
37
36
 
38
37
 
39
- // get axes context and adjust x margins
38
+ // get axes context and synchronize y-axis settings
40
39
  const axes = getContext('axes');
41
40
  $effect(() => {
42
41
  axes.setYAxis({show: true, ticks, tickLabels, showGrid, label, lineColor, gridColor, textColor});
@@ -46,4 +45,3 @@
46
45
  axes.setYAxis({show: false});
47
46
  });
48
47
  </script>
49
-
@@ -3,20 +3,19 @@
3
3
 
4
4
  Main properties:
5
5
  - `label` - text label for the axis (optional).
6
- - `showGrid` - logical, show or not vertical grid lines, default: `false`.
7
- - `ticks` - array or vector with tick positions (by default will be generated automatically based on `limX`).
6
+ - `showGrid` - logical, show or hide grid lines, default: `false`.
7
+ - `ticks` - array or vector with tick positions (by default will be generated automatically based on `limZ`).
8
8
  - `tickLabels` - array with tick labels to show at each tick (by default will be generated automatically).
9
9
  - `lineColor` - color of axis line, default: `Colors.DARKGRAY`.
10
10
  - `gridColor` - color of axis grid line, default: `Colors.MIDDLEGRAY`.
11
11
  - `textColor` - color of axis text labels, default: `Colors.DARKGRAY`.
12
12
 
13
13
  Example:
14
- ```jsx
14
+ ```svelte
15
15
  <script>
16
16
  import {Axes, ZAxis} from 'svelte-plots-basic/3d';
17
17
  </script>
18
18
  <Axes>
19
- // all other plotting components are here
20
19
  <ZAxis label="Z" showGrid={true} />
21
20
  </Axes>
22
21
  ```
@@ -36,7 +35,7 @@
36
35
  } = $props();
37
36
 
38
37
 
39
- // get axes context and adjust x margins
38
+ // get axes context and synchronize z-axis settings
40
39
  const axes = getContext('axes');
41
40
  $effect(() => {
42
41
  axes.setZAxis({show: true, ticks, tickLabels, showGrid, label, lineColor, gridColor, textColor});
@@ -46,4 +45,3 @@
46
45
  axes.setZAxis({show: false});
47
46
  });
48
47
  </script>
49
-
@@ -9,6 +9,7 @@
9
9
  - `onclose` - callback when dialog is closed.
10
10
  -->
11
11
  <script>
12
+ import { onDestroy } from 'svelte';
12
13
  import { downloadPNG } from './methods.js';
13
14
 
14
15
  let {
@@ -19,24 +20,40 @@
19
20
  onclose
20
21
  } = $props();
21
22
 
22
- // aspect ratio from the actual SVG element
23
- const svgWidth = plotElement ? (plotElement.clientWidth || plotElement.getBoundingClientRect().width) : 1;
24
- const svgHeight = plotElement ? (plotElement.clientHeight || plotElement.getBoundingClientRect().height) : 1;
25
- const aspectRatio = svgWidth / svgHeight;
26
-
27
- // editable state — height always derived from width to preserve aspect ratio
28
23
  const resOptions = [100, 150, 300, 600];
29
- const parsedInitialRes = Number(initialRes);
30
- const defaultRes = resOptions.includes(parsedInitialRes) ? parsedInitialRes : 300;
31
24
 
32
- let width = $state(initialWidth);
25
+ // The dialog intentionally takes a snapshot of its initial props when it opens.
26
+ function getInitialValues() {
27
+ const svgWidth = plotElement ? (plotElement.clientWidth || plotElement.getBoundingClientRect().width) : 1;
28
+ const svgHeight = plotElement ? (plotElement.clientHeight || plotElement.getBoundingClientRect().height) : 1;
29
+ const parsedInitialRes = Number(initialRes);
30
+
31
+ return {
32
+ svgWidth,
33
+ svgHeight,
34
+ aspectRatio: svgWidth / svgHeight,
35
+ width: initialWidth,
36
+ res: resOptions.includes(parsedInitialRes) ? parsedInitialRes : 300,
37
+ name: typeof fileName === 'string' ? fileName : 'plot'
38
+ };
39
+ }
40
+
41
+ const initialValues = getInitialValues();
42
+ const svgWidth = initialValues.svgWidth;
43
+ const svgHeight = initialValues.svgHeight;
44
+ const aspectRatio = initialValues.aspectRatio;
45
+
46
+ // editable state — height always derived from width to preserve aspect ratio
47
+ let width = $state(initialValues.width);
33
48
  const height = $derived(Math.max(4, Math.min(20, Math.round(width / aspectRatio * 10) / 10)));
34
- let res = $state(defaultRes);
35
- let name = $state(fileName);
49
+ let res = $state(initialValues.res);
50
+ let name = $state(initialValues.name);
36
51
  const nameValid = $derived(name.trim().length > 0);
52
+ let exportError = $state('');
37
53
 
38
54
  // preview container ref
39
55
  let previewContainer = $state(null);
56
+ let dialogElement = $state(null);
40
57
 
41
58
  // max preview width in pixels
42
59
  const maxPreviewWidth = 400;
@@ -78,73 +95,206 @@
78
95
  }
79
96
 
80
97
  let saving = $state(false);
98
+ let activeExportCleanup = null;
99
+ let destroyed = false;
100
+
101
+ onDestroy(() => {
102
+ destroyed = true;
103
+ activeExportCleanup?.();
104
+ if (dialogElement?.open) dialogElement.close();
105
+ });
106
+
107
+ $effect(() => {
108
+ if (dialogElement && !dialogElement.open) {
109
+ dialogElement.showModal();
110
+ dialogElement.querySelector('#export-name')?.focus();
111
+ }
112
+ });
113
+
114
+ function closeDialog() {
115
+ if (dialogElement?.open) dialogElement.close();
116
+ onclose();
117
+ }
81
118
 
82
119
  function handleSave() {
83
120
  if (saving) return;
84
- saving = true;
121
+
122
+ exportError = '';
123
+
124
+ const exportWidth = Number(width);
125
+ const exportHeight = Number(height);
126
+ const exportRes = Number(res);
127
+
128
+ const hasValidSize = Number.isFinite(exportWidth) && exportWidth >= 1 && exportWidth <= 30 &&
129
+ Number.isFinite(exportHeight) && exportHeight >= 1 && exportHeight <= 30;
130
+ const hasValidResolution = Number.isFinite(exportRes) && exportRes >= 50 && exportRes <= 1200;
131
+
132
+ if (!hasValidSize || !hasValidResolution) {
133
+ exportError = 'Export size or resolution is invalid.';
134
+ return;
135
+ }
136
+
137
+ if (!plotElement || !(plotElement instanceof SVGElement) || !plotElement.parentElement) {
138
+ exportError = 'The plot is not available for export.';
139
+ return;
140
+ }
85
141
 
86
142
  const container = plotElement.parentElement;
87
143
  const origStyle = container.style.cssText;
144
+ let observer = null;
145
+ let fallbackTimer = null;
146
+ let firstFrame = null;
147
+ let secondFrame = null;
148
+ let exportStarted = false;
149
+ let restored = false;
150
+
151
+ function restorePlot() {
152
+ if (restored) return;
153
+ restored = true;
154
+
155
+ observer?.disconnect();
156
+ if (fallbackTimer !== null) clearTimeout(fallbackTimer);
157
+ if (firstFrame !== null) cancelAnimationFrame(firstFrame);
158
+ if (secondFrame !== null) cancelAnimationFrame(secondFrame);
159
+
160
+ container.style.cssText = origStyle;
161
+ saving = false;
162
+
163
+ if (activeExportCleanup === restorePlot) {
164
+ activeExportCleanup = null;
165
+ }
166
+ }
167
+
168
+ function failExport(error) {
169
+ console.error('Failed to prepare the plot for PNG export.', error);
170
+ exportError = 'PNG export could not be started.';
171
+ restorePlot();
172
+ }
173
+
174
+ function finishExport() {
175
+ restorePlot();
176
+ if (!destroyed) closeDialog();
177
+ }
178
+
179
+ activeExportCleanup = restorePlot;
180
+ saving = true;
88
181
 
89
182
  // scale reference width with export width so smaller plots get
90
183
  // a smaller scale category → relatively larger fonts and details
91
184
  // 4cm → 360px (small), 8cm → 520px (medium), 16cm → 840px (large), 20cm → 1000px (xlarge)
92
- const refWidth = Math.round(200 + width * 40);
185
+ const refWidth = Math.round(200 + exportWidth * 40);
93
186
  const refHeight = Math.round(refWidth / aspectRatio);
94
187
 
95
188
  function doExport() {
96
- requestAnimationFrame(() => {
97
- requestAnimationFrame(() => {
98
- // onSerialize callback fires after downloadPNG has serialized the SVG,
99
- // so we can safely restore the container and close the dialog
100
- downloadPNG(plotElement, name.trim(), width, height, res, () => {
101
- container.style.cssText = origStyle;
102
- saving = false;
103
- onclose();
189
+ if (exportStarted || restored) return;
190
+ exportStarted = true;
191
+
192
+ observer?.disconnect();
193
+ if (fallbackTimer !== null) clearTimeout(fallbackTimer);
194
+
195
+ try {
196
+ firstFrame = requestAnimationFrame(() => {
197
+ firstFrame = null;
198
+ secondFrame = requestAnimationFrame(() => {
199
+ secondFrame = null;
200
+
201
+ try {
202
+ // The callback fires after downloadPNG has serialized the SVG,
203
+ // so the live container can be restored before image encoding finishes.
204
+ const result = downloadPNG(
205
+ plotElement,
206
+ name.trim(),
207
+ exportWidth,
208
+ exportHeight,
209
+ exportRes,
210
+ finishExport
211
+ );
212
+
213
+ if (result === null) {
214
+ failExport(new Error('downloadPNG rejected the export settings.'));
215
+ }
216
+ } catch (error) {
217
+ failExport(error);
218
+ }
104
219
  });
105
220
  });
106
- });
221
+ } catch (error) {
222
+ failExport(error);
223
+ }
107
224
  }
108
225
 
109
- // temporarily resize plot container to a fixed reference size (off-screen,
110
- // hidden behind the dialog backdrop) so Svelte recomputes the layout
111
- container.style.cssText = `position:fixed;left:-9999px;top:-9999px;width:${refWidth}px;height:${refHeight}px;overflow:hidden;`;
112
-
113
- // wait for ResizeObserver → Svelte bindings → derived recomputation → DOM update
114
- // fallback timeout in case container is already at the target size
115
- const fallbackTimer = setTimeout(() => { observer.disconnect(); doExport(); }, 500);
116
- const observer = new ResizeObserver(() => {
117
- observer.disconnect();
118
- clearTimeout(fallbackTimer);
119
- doExport();
120
- });
121
- observer.observe(plotElement);
226
+ try {
227
+ // temporarily resize plot container to a fixed reference size (off-screen,
228
+ // hidden behind the dialog backdrop) so Svelte recomputes the layout
229
+ container.style.cssText = `position:fixed;left:-9999px;top:-9999px;width:${refWidth}px;height:${refHeight}px;overflow:hidden;`;
230
+
231
+ // wait for ResizeObserver → Svelte bindings → derived recomputation → DOM update
232
+ // fallback timeout in case container is already at the target size
233
+ fallbackTimer = setTimeout(doExport, 500);
234
+ observer = new ResizeObserver(doExport);
235
+ observer.observe(plotElement);
236
+ } catch (error) {
237
+ failExport(error);
238
+ }
122
239
  }
123
240
 
124
- function handleBackdropClick() {
125
- if (!saving) onclose();
241
+ function handleCancel(e) {
242
+ e.preventDefault();
243
+ if (!saving) closeDialog();
126
244
  }
127
245
 
128
246
  function handleDialogClick(e) {
129
247
  e.stopPropagation();
248
+ if (saving) return;
249
+
250
+ const bounds = e.currentTarget.getBoundingClientRect();
251
+ const clickedOutside = e.clientX < bounds.left || e.clientX > bounds.right ||
252
+ e.clientY < bounds.top || e.clientY > bounds.bottom;
253
+
254
+ if (clickedOutside) closeDialog();
130
255
  }
131
256
 
132
- // close on Escape key
133
- $effect(() => {
134
- function handleKeydown(e) {
135
- if (e.key === 'Escape' && !saving) {
136
- onclose();
137
- }
138
- }
139
- document.addEventListener('keydown', handleKeydown);
140
- return () => document.removeEventListener('keydown', handleKeydown);
141
- });
257
+ // Keep keyboard events from activating an enclosing custom modal's handlers.
258
+ // Native dialog behavior still handles Escape; the explicit Tab order avoids
259
+ // browser/OS differences in which form controls participate in navigation.
260
+ function handleKeydown(e) {
261
+ e.stopPropagation();
262
+
263
+ if (e.key !== 'Tab' || !dialogElement) return;
264
+
265
+ const resolutionInput = dialogElement.querySelector(
266
+ 'input[name="export-res"]:checked'
267
+ ) ?? dialogElement.querySelector('input[name="export-res"]');
268
+
269
+ const controls = [
270
+ dialogElement.querySelector('#export-name'),
271
+ dialogElement.querySelector('#export-width'),
272
+ resolutionInput,
273
+ ...dialogElement.querySelectorAll('.export-dialog-actions button:not(:disabled)')
274
+ ].filter(Boolean);
275
+
276
+ if (controls.length === 0) return;
277
+
278
+ const currentIndex = controls.indexOf(document.activeElement);
279
+ const nextIndex = currentIndex === -1
280
+ ? (e.shiftKey ? controls.length - 1 : 0)
281
+ : (currentIndex + (e.shiftKey ? -1 : 1) + controls.length) % controls.length;
282
+
283
+ e.preventDefault();
284
+ controls[nextIndex].focus();
285
+ }
142
286
  </script>
143
287
 
144
- <!-- svelte-ignore a11y_click_events_have_key_events -->
145
- <!-- svelte-ignore a11y_no_static_element_interactions -->
146
- <div class="export-dialog-backdrop" onclick={handleBackdropClick}>
147
- <div class="export-dialog" onclick={handleDialogClick}>
288
+ <!-- Native modal events provide backdrop closing and isolate an enclosing custom modal's keyboard handlers. -->
289
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
290
+ <dialog
291
+ class="export-dialog"
292
+ aria-label="Export as PNG"
293
+ bind:this={dialogElement}
294
+ oncancel={handleCancel}
295
+ onclick={handleDialogClick}
296
+ onkeydown={handleKeydown}
297
+ >
148
298
  <div class="export-dialog-title">Export as PNG</div>
149
299
 
150
300
  <div class="export-dialog-body">
@@ -177,37 +327,34 @@
177
327
  {/each}
178
328
  </div>
179
329
  </div>
330
+
331
+ {#if exportError}
332
+ <p class="export-dialog-error" role="alert">{exportError}</p>
333
+ {/if}
180
334
  </div>
181
335
  </div>
182
336
 
183
337
  <div class="export-dialog-actions">
184
- <button type="button" class="export-dialog-cancel" onclick={onclose} disabled={saving}>Cancel</button>
185
- <button type="button" class="export-dialog-save" onclick={handleSave} disabled={!nameValid}>Save</button>
338
+ <button type="button" class="export-dialog-cancel" onclick={closeDialog} disabled={saving}>Cancel</button>
339
+ <button type="button" class="export-dialog-save" onclick={handleSave} disabled={!nameValid || saving}>Save</button>
186
340
  </div>
187
- </div>
188
- </div>
341
+ </dialog>
189
342
 
190
343
  <style>
191
- .export-dialog-backdrop {
192
- position: fixed;
193
- top: 0;
194
- left: 0;
195
- right: 0;
196
- bottom: 0;
344
+ .export-dialog::backdrop {
197
345
  background: rgba(0, 0, 0, 0.4);
198
- z-index: 1000;
199
- display: flex;
200
- align-items: center;
201
- justify-content: center;
202
346
  }
203
347
 
204
348
  .export-dialog {
349
+ box-sizing: border-box;
205
350
  background: #fefefe;
351
+ border: none;
206
352
  border-radius: 0.5em;
207
353
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
208
354
  padding: 1.25em;
209
355
  min-width: 440px;
210
356
  max-width: 90vw;
357
+ margin: auto;
211
358
  font-family: Arial, Helvetica, sans-serif;
212
359
  color: #606060;
213
360
  }
@@ -316,7 +463,30 @@
316
463
  }
317
464
 
318
465
  .export-dialog-res-btn input {
319
- display: none;
466
+ position: absolute;
467
+ width: 1px;
468
+ height: 1px;
469
+ padding: 0;
470
+ margin: -1px;
471
+ overflow: hidden;
472
+ clip: rect(0, 0, 0, 0);
473
+ clip-path: inset(50%);
474
+ white-space: nowrap;
475
+ border: 0;
476
+ }
477
+
478
+ .export-dialog-name:focus-visible,
479
+ .export-dialog-control input[type="range"]:focus-visible,
480
+ .export-dialog-actions button:focus-visible,
481
+ .export-dialog-res-btn:focus-within {
482
+ outline: 2px solid #999191;
483
+ outline-offset: 2px;
484
+ }
485
+
486
+ .export-dialog-error {
487
+ margin: 0;
488
+ color: crimson;
489
+ font-size: 0.85em;
320
490
  }
321
491
 
322
492
  .export-dialog-actions {