@sveltia/ui 0.71.1 → 0.72.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.
Files changed (88) hide show
  1. package/dist/components/alert/infobar.svelte +1 -1
  2. package/dist/components/button/button.svelte +1 -1
  3. package/dist/components/button/select-button.svelte +2 -1
  4. package/dist/components/calendar/calendar.d.ts +20 -0
  5. package/dist/components/calendar/calendar.js +81 -0
  6. package/dist/components/calendar/calendar.svelte +23 -45
  7. package/dist/components/checkbox/checkbox-group.svelte +1 -1
  8. package/dist/components/checkbox/checkbox.svelte +6 -1
  9. package/dist/components/dialog/dialog.svelte +1 -1
  10. package/dist/components/drawer/drawer.svelte +1 -1
  11. package/dist/components/emoji/emoji-suggestions.svelte +14 -40
  12. package/dist/components/emoji/position.d.ts +44 -0
  13. package/dist/components/emoji/position.js +60 -0
  14. package/dist/components/progressbar/progressbar.svelte +1 -1
  15. package/dist/components/radio/radio-group.svelte +1 -1
  16. package/dist/components/resizable-pane/resizable-handle.svelte +15 -47
  17. package/dist/components/resizable-pane/resizable-pane-group.svelte +37 -100
  18. package/dist/components/resizable-pane/resizable-pane.svelte +1 -1
  19. package/dist/components/resizable-pane/sizing.d.ts +48 -0
  20. package/dist/components/resizable-pane/sizing.js +190 -0
  21. package/dist/components/scroll/infinite-scroll.svelte +2 -0
  22. package/dist/components/select/combobox.svelte +3 -1
  23. package/dist/components/select/select-tags.d.ts +17 -0
  24. package/dist/components/select/select-tags.js +95 -0
  25. package/dist/components/select/select-tags.svelte +26 -28
  26. package/dist/components/slider/slider.d.ts +17 -0
  27. package/dist/components/slider/slider.js +96 -0
  28. package/dist/components/slider/slider.svelte +30 -56
  29. package/dist/components/tabs/tab-box.svelte +1 -1
  30. package/dist/components/tabs/tab-list.d.ts +6 -0
  31. package/dist/components/tabs/tab-list.js +19 -0
  32. package/dist/components/tabs/tab-list.svelte +2 -14
  33. package/dist/components/text-editor/code-editor.d.ts +5 -0
  34. package/dist/components/text-editor/code-editor.js +28 -0
  35. package/dist/components/text-editor/code-editor.svelte +7 -8
  36. package/dist/components/text-editor/emoji-autocomplete.svelte +9 -1
  37. package/dist/components/text-editor/lexical-root.svelte +3 -1
  38. package/dist/components/text-editor/shiki/facade.js +1 -1
  39. package/dist/components/text-editor/shiki/highlighter.js +40 -15
  40. package/dist/components/text-editor/shiki/version.d.ts +1 -1
  41. package/dist/components/text-editor/shiki/version.js +1 -1
  42. package/dist/components/text-editor/store.svelte.js +17 -7
  43. package/dist/components/text-editor/text-editor.svelte +4 -0
  44. package/dist/components/text-editor/toolbar/code-editor-toolbar.svelte +1 -1
  45. package/dist/components/text-editor/toolbar/code-language-switcher.svelte +4 -1
  46. package/dist/components/text-editor/toolbar/format-text-button.svelte +3 -1
  47. package/dist/components/text-editor/toolbar/insert-item-button.svelte +1 -1
  48. package/dist/components/text-editor/toolbar/insert-link-button.svelte +10 -3
  49. package/dist/components/text-editor/toolbar/text-editor-toolbar.svelte +4 -5
  50. package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte +2 -0
  51. package/dist/components/text-field/emoji-autocomplete.svelte +2 -0
  52. package/dist/components/text-field/number-input.d.ts +15 -0
  53. package/dist/components/text-field/number-input.js +98 -0
  54. package/dist/components/text-field/number-input.svelte +27 -23
  55. package/dist/components/text-field/text-input.svelte +9 -3
  56. package/dist/components/toast/toast.svelte +7 -2
  57. package/dist/components/toolbar/toolbar.svelte +1 -1
  58. package/dist/components/typography/truncated-text.svelte +2 -1
  59. package/dist/components/util/app-shell.d.ts +1 -0
  60. package/dist/components/util/app-shell.js +29 -0
  61. package/dist/components/util/app-shell.svelte +4 -18
  62. package/dist/components/util/modal.d.ts +5 -0
  63. package/dist/components/util/modal.js +22 -0
  64. package/dist/components/util/modal.svelte +9 -10
  65. package/dist/components/util/popup.svelte +11 -0
  66. package/dist/locales/ar.yaml +2 -2
  67. package/dist/locales/ca.yaml +2 -2
  68. package/dist/locales/cs.yaml +2 -2
  69. package/dist/locales/da.yaml +2 -2
  70. package/dist/locales/de.yaml +1 -1
  71. package/dist/locales/el.yaml +2 -2
  72. package/dist/locales/en-CA.yaml +1 -1
  73. package/dist/locales/en-GB.yaml +1 -1
  74. package/dist/locales/en-US.yaml +1 -1
  75. package/dist/locales/es-CO.yaml +2 -2
  76. package/dist/locales/fa.yaml +2 -2
  77. package/dist/locales/fi.yaml +1 -1
  78. package/dist/locales/fr.yaml +2 -2
  79. package/dist/locales/hr.yaml +116 -0
  80. package/dist/locales/it.yaml +2 -2
  81. package/dist/locales/nl.yaml +1 -1
  82. package/dist/locales/pl.yaml +2 -2
  83. package/dist/locales/pt-BR.yaml +2 -2
  84. package/dist/locales/pt-PT.yaml +2 -2
  85. package/dist/locales/sv.yaml +2 -2
  86. package/dist/locales/zh-CN.yaml +1 -1
  87. package/dist/locales/zh-TW.yaml +1 -1
  88. package/package.json +8 -4
@@ -6,6 +6,12 @@
6
6
  -->
7
7
  <script>
8
8
  import { setContext } from 'svelte';
9
+ import {
10
+ clampResizeDelta,
11
+ getInitialSizes,
12
+ resolvePaneConstraints,
13
+ resolveToPercent,
14
+ } from './sizing.js';
9
15
 
10
16
  /**
11
17
  * @import { Snippet } from 'svelte';
@@ -68,113 +74,44 @@
68
74
  let _handleCount = 0;
69
75
 
70
76
  /**
71
- * Get the pane group container element’s size in pixels for size conversion.
72
- * @returns {number} Container size in pixels.
77
+ * Get the sizes the pane lengths are relative to.
78
+ * @returns {import('./sizing.js').SizeEnvironment} Environment.
73
79
  */
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
- };
80
+ const getSizeEnvironment = () => ({
81
+ containerSize: element
82
+ ? direction === 'horizontal'
83
+ ? element.clientWidth
84
+ : element.clientHeight
85
+ : 0,
86
+ viewportWidth: window.innerWidth,
87
+ viewportHeight: window.innerHeight,
88
+ });
127
89
 
128
90
  /**
129
91
  * Get pane constraints converted to percentages for the current container size.
130
92
  * @param {number} paneIndex Pane index.
131
93
  * @returns {{ minSize: number, maxSize: number }} Min/max in percentages.
132
94
  */
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
- };
95
+ const getPaneConstraints = (paneIndex) =>
96
+ resolvePaneConstraints(_paneDefs[paneIndex], getSizeEnvironment());
148
97
 
149
98
  /**
150
- * Initialize pane sizes from `defaultSize` props. Called from `onMount` once all panes have
151
- * registered. Panes without `defaultSize` share the remaining space equally.
99
+ * Initialize pane sizes from `defaultSize` props. Called once all panes have registered. Panes
100
+ * without `defaultSize` share the remaining space equally.
152
101
  */
153
102
  const initSizes = () => {
103
+ // Only called once panes have registered; see the effect below
104
+ /* v8 ignore next */
154
105
  if (!_paneDefs.length) return;
155
106
 
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
- });
107
+ const env = getSizeEnvironment();
167
108
 
168
- const totalSpecified = resolvedDefaults
169
- .filter((v) => !Number.isNaN(v))
170
- .reduce((sum, v) => sum + v, 0);
109
+ // Resolve each pane’s defaultSize to a percentage (NaN if unspecified or unresolvable)
110
+ const resolvedDefaults = _paneDefs.map((p) =>
111
+ p.defaultSize === undefined ? NaN : resolveToPercent(p.defaultSize, NaN, env),
112
+ );
171
113
 
172
- const unspecifiedCount = resolvedDefaults.filter((v) => Number.isNaN(v)).length;
173
- const remaining = Math.max(0, 100 - totalSpecified);
174
- const defaultSize = unspecifiedCount > 0 ? remaining / unspecifiedCount : 0;
175
- const newSizes = resolvedDefaults.map((v) => (Number.isNaN(v) ? defaultSize : v));
176
-
177
- sizes.splice(0, sizes.length, ...newSizes);
114
+ sizes.splice(0, sizes.length, ...getInitialSizes(resolvedDefaults));
178
115
  };
179
116
 
180
117
  /**
@@ -188,16 +125,16 @@
188
125
 
189
126
  if (beforeIdx < 0 || afterIdx >= sizes.length) return;
190
127
 
191
- const { minSize: minBefore, maxSize: maxBefore } = getPaneConstraints(beforeIdx);
192
- const { minSize: minAfter, maxSize: maxAfter } = getPaneConstraints(afterIdx);
193
128
  const prevBefore = sizes[beforeIdx];
194
129
  const prevAfter = sizes[afterIdx];
195
- // Clamp delta so neither pane exceeds its min/max constraints
196
- const canGrow = Math.min(maxBefore - prevBefore, prevAfter - minAfter);
197
- const canShrink = Math.min(prevBefore - minBefore, maxAfter - prevAfter);
198
130
 
199
- const delta =
200
- deltaPercent > 0 ? Math.min(deltaPercent, canGrow) : -Math.min(-deltaPercent, canShrink);
131
+ // Clamp delta so neither pane exceeds its min/max constraints
132
+ const delta = clampResizeDelta(deltaPercent, {
133
+ sizeBefore: prevBefore,
134
+ sizeAfter: prevAfter,
135
+ constraintsBefore: getPaneConstraints(beforeIdx),
136
+ constraintsAfter: getPaneConstraints(afterIdx),
137
+ });
201
138
 
202
139
  sizes[beforeIdx] = prevBefore + delta;
203
140
  sizes[afterIdx] = prevAfter - delta;
@@ -265,7 +202,7 @@
265
202
  bind:this={element}
266
203
  {...restProps}
267
204
  role="none"
268
- class="sui resizable-pane-group {direction} {className ?? ''}"
205
+ class={['sui', 'resizable-pane-group', direction, className]}
269
206
  data-direction={direction}
270
207
  >
271
208
  {@render children?.()}
@@ -78,7 +78,7 @@
78
78
  {...restProps}
79
79
  {id}
80
80
  role="none"
81
- class="sui resizable-pane {className ?? ''}"
81
+ class="sui resizable-pane {className}"
82
82
  style:flex-basis={sizeStyle}
83
83
  style:flex-grow="0"
84
84
  style:flex-shrink="0"
@@ -0,0 +1,48 @@
1
+ export function resolveToPercent(value: number | string | undefined, fallback: number, env: SizeEnvironment): number;
2
+ export function resolvePaneConstraints(paneDef: {
3
+ minSize: number | string;
4
+ maxSize: number | string;
5
+ } | undefined, env: SizeEnvironment): PaneConstraints;
6
+ export function getInitialSizes(resolvedDefaults: number[]): number[];
7
+ export function clampResizeDelta(deltaPercent: number, { sizeBefore, sizeAfter, constraintsBefore, constraintsAfter }: {
8
+ sizeBefore: number;
9
+ sizeAfter: number;
10
+ constraintsBefore: PaneConstraints;
11
+ constraintsAfter: PaneConstraints;
12
+ }): number;
13
+ export function getHandleKeyAction({ key, shiftKey, isHorizontal, rtl }: {
14
+ key: string;
15
+ shiftKey: boolean;
16
+ isHorizontal: boolean;
17
+ rtl: boolean;
18
+ }): {
19
+ type: "toggle";
20
+ } | {
21
+ type: "resize";
22
+ delta: number;
23
+ } | undefined;
24
+ export type SizeEnvironment = {
25
+ /**
26
+ * Size of the pane group in pixels along its direction, or `0`
27
+ * if it hasn’t been laid out yet.
28
+ */
29
+ containerSize: number;
30
+ /**
31
+ * Width of the viewport in pixels.
32
+ */
33
+ viewportWidth: number;
34
+ /**
35
+ * Height of the viewport in pixels.
36
+ */
37
+ viewportHeight: number;
38
+ };
39
+ export type PaneConstraints = {
40
+ /**
41
+ * Minimum size.
42
+ */
43
+ minSize: number;
44
+ /**
45
+ * Maximum size, never below `minSize`.
46
+ */
47
+ maxSize: number;
48
+ };
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Size arithmetic behind `<ResizablePaneGroup>` and `<ResizableHandle>`, kept apart from the
3
+ * components so the conversions and clamping can be tested without laying anything out. Every
4
+ * size here is a percentage of the pane group unless stated otherwise.
5
+ */
6
+
7
+ /**
8
+ * @typedef {object} SizeEnvironment
9
+ * @property {number} containerSize Size of the pane group in pixels along its direction, or `0`
10
+ * if it hasn’t been laid out yet.
11
+ * @property {number} viewportWidth Width of the viewport in pixels.
12
+ * @property {number} viewportHeight Height of the viewport in pixels.
13
+ */
14
+
15
+ /**
16
+ * @typedef {object} PaneConstraints
17
+ * @property {number} minSize Minimum size.
18
+ * @property {number} maxSize Maximum size, never below `minSize`.
19
+ */
20
+
21
+ /**
22
+ * Resolve a size given as a percentage number or a CSS length string to a percentage.
23
+ * @param {number | string | undefined} value Size as a percentage number, or a CSS size string
24
+ * such as `240px`, `20%` or `20dvw`.
25
+ * @param {number} fallback Percentage to return when the value can’t be resolved.
26
+ * @param {SizeEnvironment} env Sizes the lengths are relative to. A length other than a percentage
27
+ * can’t be resolved while the container has no size.
28
+ * @returns {number} Size in percentage points.
29
+ */
30
+ export const resolveToPercent = (value, fallback, env) => {
31
+ if (typeof value === 'number') {
32
+ return value;
33
+ }
34
+
35
+ if (!value || typeof value !== 'string') {
36
+ return fallback;
37
+ }
38
+
39
+ const trimmed = value.trim();
40
+ const matchedPercent = trimmed.match(/^(-?\d+(?:\.\d+)?)%$/);
41
+
42
+ if (matchedPercent) {
43
+ return Number(matchedPercent[1]);
44
+ }
45
+
46
+ const { containerSize } = env;
47
+
48
+ if (!containerSize) {
49
+ return fallback;
50
+ }
51
+
52
+ const matchedPx = trimmed.match(/^(-?\d+(?:\.\d+)?)px$/i);
53
+
54
+ if (matchedPx) {
55
+ return (Number(matchedPx[1]) / containerSize) * 100;
56
+ }
57
+
58
+ const matchedViewport = trimmed.match(/^(-?\d+(?:\.\d+)?)(dvw|vw|dvh|vh)$/i);
59
+
60
+ if (matchedViewport) {
61
+ const viewportValue = Number(matchedViewport[1]);
62
+ const unit = matchedViewport[2].toLowerCase();
63
+ const viewportSize = unit.endsWith('w') ? env.viewportWidth : env.viewportHeight;
64
+ const pixels = (viewportValue / 100) * viewportSize;
65
+
66
+ return (pixels / containerSize) * 100;
67
+ }
68
+
69
+ return fallback;
70
+ };
71
+
72
+ /**
73
+ * Resolve a pane’s size constraints to percentages, clamped to the `0`–`100` range, with the
74
+ * maximum never below the minimum.
75
+ * @param {{ minSize: number | string, maxSize: number | string } | undefined} paneDef Pane
76
+ * definition, or `undefined` for a pane that hasn’t registered, which is unconstrained.
77
+ * @param {SizeEnvironment} env Sizes the lengths are relative to.
78
+ * @returns {PaneConstraints} Constraints.
79
+ */
80
+ export const resolvePaneConstraints = (paneDef, env) => {
81
+ if (!paneDef) {
82
+ return { minSize: 0, maxSize: 100 };
83
+ }
84
+
85
+ const minSize = Math.max(0, resolveToPercent(paneDef.minSize, 0, env));
86
+ const maxSize = Math.min(100, resolveToPercent(paneDef.maxSize, 100, env));
87
+
88
+ return {
89
+ minSize,
90
+ maxSize: Math.max(minSize, maxSize),
91
+ };
92
+ };
93
+
94
+ /**
95
+ * Work out the initial size of each pane. Panes with a default size get it; the others share
96
+ * whatever is left of the group equally.
97
+ * @param {number[]} resolvedDefaults Each pane’s default size as a percentage, or `NaN` for a pane
98
+ * that has none, or whose default couldn’t be resolved.
99
+ * @returns {number[]} Size of each pane.
100
+ */
101
+ export const getInitialSizes = (resolvedDefaults) => {
102
+ const specified = resolvedDefaults.filter((v) => !Number.isNaN(v));
103
+ const totalSpecified = specified.reduce((sum, v) => sum + v, 0);
104
+ const unspecifiedCount = resolvedDefaults.length - specified.length;
105
+ const remaining = Math.max(0, 100 - totalSpecified);
106
+ const defaultSize = unspecifiedCount > 0 ? remaining / unspecifiedCount : 0;
107
+
108
+ return resolvedDefaults.map((v) => (Number.isNaN(v) ? defaultSize : v));
109
+ };
110
+
111
+ /**
112
+ * Clamp the delta a handle is moved by, so neither of the two panes around it leaves its size
113
+ * constraints. A positive delta grows the pane before the handle at the expense of the one after
114
+ * it, and a negative delta does the opposite.
115
+ * @param {number} deltaPercent Requested delta.
116
+ * @param {object} panes The panes around the handle.
117
+ * @param {number} panes.sizeBefore Current size of the pane before the handle.
118
+ * @param {number} panes.sizeAfter Current size of the pane after the handle.
119
+ * @param {PaneConstraints} panes.constraintsBefore Constraints of the pane before the handle.
120
+ * @param {PaneConstraints} panes.constraintsAfter Constraints of the pane after the handle.
121
+ * @returns {number} Delta both panes can accommodate.
122
+ */
123
+ export const clampResizeDelta = (
124
+ deltaPercent,
125
+ { sizeBefore, sizeAfter, constraintsBefore, constraintsAfter },
126
+ ) => {
127
+ const canGrow = Math.min(
128
+ constraintsBefore.maxSize - sizeBefore,
129
+ sizeAfter - constraintsAfter.minSize,
130
+ );
131
+
132
+ const canShrink = Math.min(
133
+ sizeBefore - constraintsBefore.minSize,
134
+ constraintsAfter.maxSize - sizeAfter,
135
+ );
136
+
137
+ return deltaPercent > 0 ? Math.min(deltaPercent, canGrow) : -Math.min(-deltaPercent, canShrink);
138
+ };
139
+
140
+ /**
141
+ * Interpret a key pressed on a resize handle. Arrow keys move the handle by 1%, or 10% with Shift.
142
+ * Enter collapses or restores the pane before the handle. Home and End move it to its minimum and
143
+ * maximum; the delta for those is oversized, and gets clamped by {@link clampResizeDelta}.
144
+ * @param {object} options Options.
145
+ * @param {string} options.key The key pressed.
146
+ * @param {boolean} options.shiftKey Whether Shift was held.
147
+ * @param {boolean} options.isHorizontal Whether the panes are laid out horizontally, in which case
148
+ * the handle moves left and right.
149
+ * @param {boolean} options.rtl Whether the layout is right-to-left, which swaps the horizontal
150
+ * arrow keys.
151
+ * @returns {{ type: 'toggle' } | { type: 'resize', delta: number } | undefined} What to do, or
152
+ * `undefined` if the key has no meaning here.
153
+ */
154
+ export const getHandleKeyAction = ({ key, shiftKey, isHorizontal, rtl }) => {
155
+ if (key === 'Enter') {
156
+ return { type: 'toggle' };
157
+ }
158
+
159
+ if (key === 'Home') {
160
+ return { type: 'resize', delta: -100 };
161
+ }
162
+
163
+ if (key === 'End') {
164
+ return { type: 'resize', delta: 100 };
165
+ }
166
+
167
+ const step = shiftKey ? 10 : 1;
168
+
169
+ if (isHorizontal) {
170
+ if (key === 'ArrowLeft') {
171
+ return { type: 'resize', delta: rtl ? step : -step };
172
+ }
173
+
174
+ if (key === 'ArrowRight') {
175
+ return { type: 'resize', delta: rtl ? -step : step };
176
+ }
177
+
178
+ return undefined;
179
+ }
180
+
181
+ if (key === 'ArrowUp') {
182
+ return { type: 'resize', delta: -step };
183
+ }
184
+
185
+ if (key === 'ArrowDown') {
186
+ return { type: 'resize', delta: step };
187
+ }
188
+
189
+ return undefined;
190
+ };
@@ -37,6 +37,8 @@
37
37
 
38
38
  const observer = new IntersectionObserver(([{ isIntersecting }]) => {
39
39
  if (isIntersecting) {
40
+ // The spinner is unmounted as soon as everything is loaded, so it can’t intersect afterwards
41
+ /* v8 ignore else */
40
42
  if (loading) {
41
43
  loadedItemSize += itemChunkSize;
42
44
  } else {
@@ -184,6 +184,8 @@
184
184
  // Move the options into the popup while it’s expanded, and back out before it’s unmounted. Only
185
185
  // the wrapper is moved, never its children, so Svelte keeps full ownership of the subtree.
186
186
  $effect(() => {
187
+ // The host is bound before any effect runs
188
+ /* v8 ignore next */
187
189
  if (!optionHost) {
188
190
  return;
189
191
  }
@@ -274,7 +276,7 @@
274
276
  {invalid}
275
277
  aria-expanded={isPopupOpen}
276
278
  aria-haspopup="listbox"
277
- aria-label={ariaLabel}
279
+ {ariaLabel}
278
280
  />
279
281
  {/if}
280
282
  <Button
@@ -0,0 +1,17 @@
1
+ export function moveItem<T>(list: T[], from: number, to: number): T[];
2
+ export function getKeyboardMoveTarget({ key, index, length, rtl }: {
3
+ key: string;
4
+ index: number;
5
+ length: number;
6
+ rtl: boolean;
7
+ }): number;
8
+ export function getDropIndex({ index, clientX, rect, rtl }: {
9
+ index: number;
10
+ clientX: number;
11
+ rect: {
12
+ left: number;
13
+ width: number;
14
+ };
15
+ rtl: boolean;
16
+ }): number;
17
+ export function getDropTarget(fromIndex: number, dropIndex: number): number | undefined;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Reordering logic behind `<SelectTags>`, kept apart from the component so it can be tested
3
+ * without dragging anything.
4
+ */
5
+
6
+ /**
7
+ * Move an item within a list.
8
+ * @template T
9
+ * @param {T[]} list List.
10
+ * @param {number} from Index of the item to move.
11
+ * @param {number} to Index the item should end up at.
12
+ * @returns {T[]} A new list with the item moved, or the same list if `from` and `to` are equal.
13
+ */
14
+ export const moveItem = (list, from, to) => {
15
+ if (from === to) {
16
+ return list;
17
+ }
18
+
19
+ const newList = [...list];
20
+ const [item] = newList.splice(from, 1);
21
+
22
+ newList.splice(to, 0, item);
23
+
24
+ return newList;
25
+ };
26
+
27
+ /**
28
+ * Get the index a tag should move to when a key is pressed on it. The arrow keys swap it with a
29
+ * neighbor, following the visual direction of the list, and Home/End move it to either end.
30
+ * @param {object} options Options.
31
+ * @param {string} options.key The key pressed.
32
+ * @param {number} options.index Index of the tag.
33
+ * @param {number} options.length Number of tags.
34
+ * @param {boolean} options.rtl Whether the layout is right-to-left, in which case the arrow keys
35
+ * are swapped.
36
+ * @returns {number} Target index, or `-1` if the key doesn’t move the tag, including when it’s
37
+ * already at the end the key would move it to.
38
+ */
39
+ export const getKeyboardMoveTarget = ({ key, index, length, rtl }) => {
40
+ const prevKey = rtl ? 'ArrowRight' : 'ArrowLeft';
41
+ const nextKey = rtl ? 'ArrowLeft' : 'ArrowRight';
42
+
43
+ if (key === prevKey && index > 0) {
44
+ return index - 1;
45
+ }
46
+
47
+ if (key === nextKey && index < length - 1) {
48
+ return index + 1;
49
+ }
50
+
51
+ if (key === 'Home' && index > 0) {
52
+ return 0;
53
+ }
54
+
55
+ if (key === 'End' && index < length - 1) {
56
+ return length - 1;
57
+ }
58
+
59
+ return -1;
60
+ };
61
+
62
+ /**
63
+ * Get where a dragged tag would be inserted when the pointer is over another tag: before it when
64
+ * the pointer is in the half nearer to the start of the list, after it otherwise.
65
+ * @param {object} options Options.
66
+ * @param {number} options.index Index of the tag under the pointer.
67
+ * @param {number} options.clientX Horizontal position of the pointer.
68
+ * @param {{ left: number, width: number }} options.rect Bounds of the tag under the pointer.
69
+ * @param {boolean} options.rtl Whether the layout is right-to-left, in which case the start of the
70
+ * list is on the right.
71
+ * @returns {number} Insertion index: the dragged tag would be placed before the tag at this index,
72
+ * so `length` means after the last one.
73
+ */
74
+ export const getDropIndex = ({ index, clientX, rect, rtl }) => {
75
+ const inFirstHalf = clientX < rect.left + rect.width / 2;
76
+
77
+ return inFirstHalf !== rtl ? index : index + 1;
78
+ };
79
+
80
+ /**
81
+ * Turn an insertion index into the index the dragged tag ends up at once it’s been taken out of
82
+ * the list.
83
+ * @param {number} fromIndex Index of the dragged tag.
84
+ * @param {number} dropIndex Insertion index, as returned by {@link getDropIndex}.
85
+ * @returns {number | undefined} Destination index for {@link moveItem}, or `undefined` if the
86
+ * drop leaves the order unchanged, which is the case when the tag is dropped on either side of
87
+ * its own position.
88
+ */
89
+ export const getDropTarget = (fromIndex, dropIndex) => {
90
+ if (dropIndex === fromIndex || dropIndex === fromIndex + 1) {
91
+ return undefined;
92
+ }
93
+
94
+ return dropIndex > fromIndex ? dropIndex - 1 : dropIndex;
95
+ };
@@ -6,6 +6,7 @@
6
6
  import Icon from '../icon/icon.svelte';
7
7
  import Option from '../listbox/option.svelte';
8
8
  import Select from './select.svelte';
9
+ import { getDropIndex, getDropTarget, getKeyboardMoveTarget, moveItem } from './select-tags.js';
9
10
 
10
11
  /**
11
12
  * @import { Snippet } from 'svelte';
@@ -66,8 +67,6 @@
66
67
  * @type {Set<any>}
67
68
  */
68
69
  const selectedValues = $derived(new Set(values));
69
- const prevKey = $derived(isRTL() ? 'ArrowRight' : 'ArrowLeft');
70
- const nextKey = $derived(isRTL() ? 'ArrowLeft' : 'ArrowRight');
71
70
 
72
71
  /**
73
72
  * Reference to the wrapper element.
@@ -99,12 +98,12 @@
99
98
  * @param {number} to Destination index.
100
99
  */
101
100
  const moveValue = (from, to) => {
101
+ // The callers only ask for actual moves
102
+ /* v8 ignore next */
102
103
  if (from === to) return;
103
104
 
104
- const newValues = [...values];
105
- const [item] = newValues.splice(from, 1);
105
+ const newValues = moveItem(values, from, to);
106
106
 
107
- newValues.splice(to, 0, item);
108
107
  values = newValues;
109
108
  onReorder?.(new CustomEvent('Reorder', { detail: { values: newValues } }));
110
109
  };
@@ -162,10 +161,12 @@
162
161
  event.dataTransfer.dropEffect = 'move';
163
162
  }
164
163
 
165
- const rect = event.currentTarget.getBoundingClientRect();
166
- const inFirstHalf = event.clientX < rect.left + rect.width / 2;
167
-
168
- dropIndex = inFirstHalf !== isRTL() ? index : index + 1;
164
+ dropIndex = getDropIndex({
165
+ index,
166
+ clientX: event.clientX,
167
+ rect: event.currentTarget.getBoundingClientRect(),
168
+ rtl: isRTL(),
169
+ });
169
170
  }}
170
171
  ondrop={async (event) => {
171
172
  event.preventDefault();
@@ -176,13 +177,14 @@
176
177
  dragIndex = undefined;
177
178
  dropIndex = undefined;
178
179
 
179
- if (
180
- fromIndex !== undefined &&
181
- toIndex !== undefined &&
182
- toIndex !== fromIndex &&
183
- toIndex !== fromIndex + 1
184
- ) {
185
- await moveAndFocus(fromIndex, toIndex > fromIndex ? toIndex - 1 : toIndex);
180
+ if (fromIndex === undefined || toIndex === undefined) {
181
+ return;
182
+ }
183
+
184
+ const target = getDropTarget(fromIndex, toIndex);
185
+
186
+ if (target !== undefined) {
187
+ await moveAndFocus(fromIndex, target);
186
188
  }
187
189
  }}
188
190
  ondragend={() => {
@@ -197,18 +199,12 @@
197
199
  aria-selected="true"
198
200
  tabindex={disabled || readonly ? undefined : 0}
199
201
  onkeydown={async (event) => {
200
- const { key } = event;
201
-
202
- const targetIndex =
203
- key === prevKey && index > 0
204
- ? index - 1
205
- : key === nextKey && index < values.length - 1
206
- ? index + 1
207
- : key === 'Home' && index > 0
208
- ? 0
209
- : key === 'End' && index < values.length - 1
210
- ? values.length - 1
211
- : -1;
202
+ const targetIndex = getKeyboardMoveTarget({
203
+ key: event.key,
204
+ index,
205
+ length: values.length,
206
+ rtl: isRTL(),
207
+ });
212
208
 
213
209
  if (targetIndex === -1) return;
214
210
 
@@ -246,6 +242,8 @@
246
242
  {required}
247
243
  {invalid}
248
244
  onChange={() => {
245
+ // The select only reports a change once an option has been picked
246
+ /* v8 ignore else */
249
247
  if (selectedValue) {
250
248
  values = [...values, selectedValue];
251
249
  onAddValue?.(new CustomEvent('AddValue', { detail: { value: selectedValue } }));