@sveltia/ui 0.77.1 → 0.77.2
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.
- package/dist/components/calendar/calendar.d.ts +1 -1
- package/dist/components/calendar/calendar.js +12 -13
- package/dist/components/calendar/calendar.svelte +11 -7
- package/dist/components/checkbox/checkbox.svelte +1 -1
- package/dist/components/emoji/emoji-suggestions.svelte +1 -1
- package/dist/components/file/file-picker.svelte +4 -1
- package/dist/components/listbox/listbox.svelte +1 -1
- package/dist/components/progressbar/progressbar.svelte +6 -1
- package/dist/components/resizable-pane/resizable-handle.svelte +7 -0
- package/dist/components/scroll/infinite-scroll.svelte +14 -1
- package/dist/components/slider/slider.js +21 -3
- package/dist/components/slider/slider.svelte +4 -0
- package/dist/components/text-editor/code-editor.js +1 -1
- package/dist/components/text-editor/core.d.ts +2 -1
- package/dist/components/text-editor/core.js +110 -18
- package/dist/components/text-editor/lexical-root.svelte +3 -2
- package/dist/components/text-editor/markdown.js +30 -11
- package/dist/components/text-editor/shiki/version.d.ts +1 -1
- package/dist/components/text-editor/shiki/version.js +1 -1
- package/dist/components/text-editor/toolbar/insert-link-button.svelte +15 -6
- package/dist/components/text-field/number-input.js +16 -3
- package/dist/components/text-field/number-input.svelte +7 -5
- package/dist/components/text-field/secret-input.svelte +5 -0
- package/dist/components/toast/toast.svelte +5 -0
- package/dist/components/util/app-shell.js +11 -3
- package/dist/components/util/popup.svelte +1 -1
- package/dist/services/group.svelte.d.ts +6 -0
- package/dist/services/group.svelte.js +69 -31
- package/dist/services/popup.svelte.js +18 -3
- package/dist/services/select.svelte.js +42 -5
- package/dist/services/tree.svelte.d.ts +5 -0
- package/dist/services/tree.svelte.js +13 -1
- package/package.json +2 -2
|
@@ -14,4 +14,4 @@ export function getFirstDayOfMonth(date: Date): Date;
|
|
|
14
14
|
export function addMonths(firstDay: Date, delta: number): Date;
|
|
15
15
|
export function getCalendarDays(firstDay: Date): Date[];
|
|
16
16
|
export function toDateString(date: Date): string;
|
|
17
|
-
export function
|
|
17
|
+
export function getToday(now?: Date): Date;
|
|
@@ -38,11 +38,12 @@ export const MONTH_NAMES = Array.from({ length: 12 }, (__, i) =>
|
|
|
38
38
|
/**
|
|
39
39
|
* Get the first day of the month the given date is in.
|
|
40
40
|
* @param {Date} date Any date within the month.
|
|
41
|
-
* @returns {Date}
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
* @returns {Date} UTC midnight on the first day of the month, using the UTC year and month of the
|
|
42
|
+
* given date, so a date parsed from a `YYYY-MM-DD` string lands in the right month regardless of
|
|
43
|
+
* the time zone. The whole grid is UTC-based, like the `value`.
|
|
44
44
|
*/
|
|
45
|
-
export const getFirstDayOfMonth = (date) =>
|
|
45
|
+
export const getFirstDayOfMonth = (date) =>
|
|
46
|
+
new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
49
|
* Get the first day of another month, relative to the given one.
|
|
@@ -68,7 +69,7 @@ export const getCalendarDays = (firstDay) => {
|
|
|
68
69
|
const cursor = new Date(firstDay);
|
|
69
70
|
|
|
70
71
|
// Start from Sunday
|
|
71
|
-
cursor.
|
|
72
|
+
cursor.setUTCDate(1 - cursor.getUTCDay());
|
|
72
73
|
|
|
73
74
|
return Array.from({ length: DAY_GRID_SIZE }, () => {
|
|
74
75
|
const day = new Date(cursor);
|
|
@@ -87,12 +88,10 @@ export const getCalendarDays = (firstDay) => {
|
|
|
87
88
|
export const toDateString = (date) => date.toJSON().split('T')[0];
|
|
88
89
|
|
|
89
90
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* @param {Date}
|
|
93
|
-
* @returns {
|
|
91
|
+
* Get today’s local calendar day as a UTC-based date, so it can be compared with the days on the
|
|
92
|
+
* grid and turned into a `value` with {@link toDateString}.
|
|
93
|
+
* @param {Date} [now] Current date and time.
|
|
94
|
+
* @returns {Date} UTC midnight on today’s local date.
|
|
94
95
|
*/
|
|
95
|
-
export const
|
|
96
|
-
|
|
97
|
-
a.getMonth() === b.getMonth() &&
|
|
98
|
-
a.getDate() === b.getDate();
|
|
96
|
+
export const getToday = (now = new Date()) =>
|
|
97
|
+
new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
formatDate,
|
|
16
16
|
getCalendarDays,
|
|
17
17
|
getFirstDayOfMonth,
|
|
18
|
-
|
|
18
|
+
getToday,
|
|
19
19
|
MONTH_NAMES,
|
|
20
20
|
toDateString,
|
|
21
21
|
} from './calendar.js';
|
|
@@ -30,9 +30,11 @@
|
|
|
30
30
|
*/
|
|
31
31
|
let { value = $bindable(undefined) } = $props();
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
/** Today’s local date, UTC-based like the days on the grid. */
|
|
34
|
+
const today = getToday();
|
|
35
|
+
const todayString = toDateString(today);
|
|
34
36
|
|
|
35
|
-
const date = $derived(value ? new Date(value) :
|
|
37
|
+
const date = $derived(value ? new Date(value) : today);
|
|
36
38
|
/**
|
|
37
39
|
* First day of the month being displayed. Follows the {@link value}, and is reassigned by the
|
|
38
40
|
* month navigation below.
|
|
@@ -44,7 +46,9 @@
|
|
|
44
46
|
* Month and year being displayed, in the current locale. It’s the caption of the day grid as
|
|
45
47
|
* well as the label of the month switcher.
|
|
46
48
|
*/
|
|
47
|
-
const monthLabel = $derived(
|
|
49
|
+
const monthLabel = $derived(
|
|
50
|
+
formatDate(firstDay, { year: 'numeric', month: 'short', timeZone: 'UTC' }),
|
|
51
|
+
);
|
|
48
52
|
const id = $props.id();
|
|
49
53
|
/** @type {HTMLElement | undefined} */
|
|
50
54
|
let grid = $state();
|
|
@@ -172,7 +176,7 @@
|
|
|
172
176
|
>
|
|
173
177
|
{#each dayList.slice(0, 7) as { day } (day.getUTCDay())}
|
|
174
178
|
<div role="none" class="weekday">
|
|
175
|
-
{formatDate(day, { weekday: 'narrow' })}
|
|
179
|
+
{formatDate(day, { weekday: 'narrow', timeZone: 'UTC' })}
|
|
176
180
|
</div>
|
|
177
181
|
{/each}
|
|
178
182
|
{#each dayList as { day } (toDateString(day))}
|
|
@@ -180,7 +184,7 @@
|
|
|
180
184
|
<div
|
|
181
185
|
role="none"
|
|
182
186
|
class:other-month={day.getUTCMonth() !== firstDay.getUTCMonth()}
|
|
183
|
-
class:today={
|
|
187
|
+
class:today={dateString === todayString}
|
|
184
188
|
>
|
|
185
189
|
<Button
|
|
186
190
|
role="option"
|
|
@@ -208,7 +212,7 @@
|
|
|
208
212
|
<Spacer flex={true} />
|
|
209
213
|
<Button
|
|
210
214
|
onclick={() => {
|
|
211
|
-
value =
|
|
215
|
+
value = todayString;
|
|
212
216
|
}}
|
|
213
217
|
>
|
|
214
218
|
{_('_sui.calendar.today')}
|
|
@@ -412,7 +412,7 @@
|
|
|
412
412
|
margin: 0;
|
|
413
413
|
border-width: var(--sui-listbox-border-width, 1px);
|
|
414
414
|
border-style: var(--sui-listbox-border-style, solid);
|
|
415
|
-
border-color: var(--sui-listbox-border-
|
|
415
|
+
border-color: var(--sui-listbox-border-color, var(--sui-secondary-border-color));
|
|
416
416
|
border-radius: var(--sui-listbox-border-radius, 4px);
|
|
417
417
|
padding: var(--sui-listbox-padding, 4px);
|
|
418
418
|
width: 280px;
|
|
@@ -37,8 +37,11 @@
|
|
|
37
37
|
{multiple}
|
|
38
38
|
bind:this={filePicker}
|
|
39
39
|
onchange={({ target }) => {
|
|
40
|
-
const
|
|
40
|
+
const input = /** @type {HTMLInputElement} */ (target);
|
|
41
|
+
const files = [.../** @type {FileList} */ (input.files)];
|
|
41
42
|
|
|
43
|
+
// Clear the selection, so picking the same file again still fires the `change` event
|
|
44
|
+
input.value = '';
|
|
42
45
|
onSelect?.({ files, file: files[0] });
|
|
43
46
|
}}
|
|
44
47
|
oncancel={(event) => {
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
margin: var(--sui-focus-ring-width);
|
|
97
97
|
border-width: var(--sui-listbox-border-width, 1px);
|
|
98
98
|
border-style: var(--sui-listbox-border-style, solid);
|
|
99
|
-
border-color: var(--sui-listbox-border-
|
|
99
|
+
border-color: var(--sui-listbox-border-color, var(--sui-secondary-border-color));
|
|
100
100
|
border-radius: var(--sui-listbox-border-radius, 4px);
|
|
101
101
|
padding: var(--sui-listbox-padding, 4px);
|
|
102
102
|
min-width: var(--sui-listbox-min-width, calc(var(--sui-option-height) * 5));
|
|
@@ -28,6 +28,11 @@
|
|
|
28
28
|
...restProps
|
|
29
29
|
/* eslint-enable prefer-const */
|
|
30
30
|
} = $props();
|
|
31
|
+
|
|
32
|
+
/** Position of `now` within the range, as a percentage, clamped to the range. */
|
|
33
|
+
const percentage = $derived(
|
|
34
|
+
max > min ? Math.min(Math.max(((now - min) / (max - min)) * 100, 0), 100) : 0,
|
|
35
|
+
);
|
|
31
36
|
</script>
|
|
32
37
|
|
|
33
38
|
<div
|
|
@@ -40,7 +45,7 @@
|
|
|
40
45
|
aria-valuetext={text}
|
|
41
46
|
aria-label={ariaLabel}
|
|
42
47
|
>
|
|
43
|
-
<div role="none" style:width={`${
|
|
48
|
+
<div role="none" style:width={`${percentage}%`}></div>
|
|
44
49
|
</div>
|
|
45
50
|
|
|
46
51
|
<style>.progressbar {
|
|
@@ -216,6 +216,13 @@
|
|
|
216
216
|
onResizeEnd?.();
|
|
217
217
|
}
|
|
218
218
|
};
|
|
219
|
+
|
|
220
|
+
$effect(() => () => {
|
|
221
|
+
// Stop listening if the handle is removed in the middle of a drag
|
|
222
|
+
document.removeEventListener('pointermove', onPointerMove);
|
|
223
|
+
document.removeEventListener('pointerup', onPointerUp);
|
|
224
|
+
document.removeEventListener('pointercancel', onPointerUp);
|
|
225
|
+
});
|
|
219
226
|
</script>
|
|
220
227
|
|
|
221
228
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
@@ -47,10 +47,23 @@
|
|
|
47
47
|
}
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
// Observe the spinner anew once each chunk is rendered: the observer only reports changes, so a
|
|
51
|
+
// spinner that stays in view because the chunk didn’t fill the viewport would stall the loading.
|
|
52
|
+
// Observing an element always reports its current state first. Stop observing on unmount too.
|
|
50
53
|
$effect(() => {
|
|
54
|
+
void loadedItemSize;
|
|
55
|
+
|
|
51
56
|
if (spinner) {
|
|
52
|
-
|
|
57
|
+
const target = spinner;
|
|
58
|
+
|
|
59
|
+
observer.observe(target);
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
observer.unobserve(target);
|
|
63
|
+
};
|
|
53
64
|
}
|
|
65
|
+
|
|
66
|
+
return undefined;
|
|
54
67
|
});
|
|
55
68
|
</script>
|
|
56
69
|
|
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
* calculations can be tested without a rendered track.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Count the fraction digits of a number, including one written in exponential notation, such as
|
|
8
|
+
* `1e-7`.
|
|
9
|
+
* @param {number} num Number.
|
|
10
|
+
* @returns {number} Number of fraction digits, `0` for an integer.
|
|
11
|
+
*/
|
|
12
|
+
const countFractionDigits = (num) => {
|
|
13
|
+
const [mantissa, exponent = '0'] = String(num).split('e');
|
|
14
|
+
|
|
15
|
+
return Math.min(Math.max((mantissa.split('.')[1] ?? '').length - Number(exponent), 0), 100);
|
|
16
|
+
};
|
|
17
|
+
|
|
6
18
|
/**
|
|
7
19
|
* Get the values a thumb can take and where each of them sits along the track.
|
|
8
20
|
* @param {object} options Options.
|
|
@@ -14,12 +26,18 @@
|
|
|
14
26
|
* increments, and the matching pixel offsets from the logical start of the track.
|
|
15
27
|
*/
|
|
16
28
|
export const getSliderSteps = ({ min, max, step, barWidth }) => {
|
|
17
|
-
const
|
|
18
|
-
|
|
29
|
+
const stepRatio = (max - min) / step;
|
|
30
|
+
// Tolerate floating point errors, e.g. `0.3 / 0.1` is `2.9999999999999996`, which would otherwise
|
|
31
|
+
// drop the maximum
|
|
32
|
+
const stepCount = Math.floor(stepRatio + 1e-9) + 1;
|
|
33
|
+
const stepWidth = barWidth / stepRatio;
|
|
19
34
|
const emptyArray = Array.from({ length: stepCount });
|
|
35
|
+
// Round the values to the precision of the minimum and the step, e.g. `3 * 0.1` is
|
|
36
|
+
// `0.30000000000000004`, which would otherwise never match the `0.3` value
|
|
37
|
+
const decimals = Math.max(countFractionDigits(min), countFractionDigits(step));
|
|
20
38
|
|
|
21
39
|
return {
|
|
22
|
-
valueList: emptyArray.map((_, index) => index * step + min),
|
|
40
|
+
valueList: emptyArray.map((_, index) => Number((index * step + min).toFixed(decimals))),
|
|
23
41
|
positionList: emptyArray.map((_, index) => index * stepWidth),
|
|
24
42
|
};
|
|
25
43
|
};
|
|
@@ -313,6 +313,10 @@
|
|
|
313
313
|
return () => {
|
|
314
314
|
observer.disconnect();
|
|
315
315
|
query.removeEventListener('change', init);
|
|
316
|
+
// Stop listening if the slider is removed in the middle of a drag
|
|
317
|
+
document.removeEventListener('pointermove', onPointerMove);
|
|
318
|
+
document.removeEventListener('pointerup', onPointerUp);
|
|
319
|
+
document.removeEventListener('pointercancel', onPointerUp);
|
|
316
320
|
};
|
|
317
321
|
});
|
|
318
322
|
|
|
@@ -22,7 +22,7 @@ export const toCodeBlock = (lang, code) =>
|
|
|
22
22
|
*/
|
|
23
23
|
export const parseCodeBlock = (markdown) => {
|
|
24
24
|
const { lang = 'plain', code = '' } =
|
|
25
|
-
markdown.match(/^```(?<lang
|
|
25
|
+
markdown.match(/^```(?<lang>[^\s`]+)?\n(?:(?<code>.*)\n)?```/s)?.groups ?? {};
|
|
26
26
|
|
|
27
27
|
return { lang, code };
|
|
28
28
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
export function isSafeLinkURL(url: string): boolean;
|
|
1
2
|
export function getSelectionTypes(): TextEditorSelectionState;
|
|
2
|
-
export function onEditorUpdate(editor: LexicalEditor, enabledTransformers: Transformer[]):
|
|
3
|
+
export function onEditorUpdate(editor: LexicalEditor, enabledTransformers: Transformer[], cachedValue?: string): string;
|
|
3
4
|
export function initEditor({ enabledButtons, components, useMarkdownShortcuts, isCodeEditor, defaultLanguage, }: TextEditorConfig): InitEditorResult;
|
|
4
5
|
export function loadCodeHighlighter(lang: string): Promise<void>;
|
|
5
6
|
export function convertMarkdownToLexical(editor: LexicalEditor, value: string, enabledTransformers: Transformer[]): Promise<void>;
|
|
@@ -99,6 +99,40 @@ import { TABLE } from './transformers/table.js';
|
|
|
99
99
|
* @property {() => void} dispose Remove all registered Lexical listeners.
|
|
100
100
|
*/
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Maximum number of entries in the undo stack.
|
|
104
|
+
*/
|
|
105
|
+
const HISTORY_MAX_DEPTH = 200;
|
|
106
|
+
/**
|
|
107
|
+
* URL schemes allowed in a link, the same as the ones Lexical renders as is.
|
|
108
|
+
*/
|
|
109
|
+
const SAFE_LINK_URL_SCHEMES = ['http', 'https', 'mailto', 'sms', 'tel'];
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether the given URL is safe to be used as a link, meaning it’s either relative or uses one of
|
|
113
|
+
* the {@link SAFE_LINK_URL_SCHEMES}. This keeps a URL like `javascript:alert(1)` out of the
|
|
114
|
+
* Markdown, where the site that renders it may not sanitize it.
|
|
115
|
+
* @param {string} url URL.
|
|
116
|
+
* @returns {boolean} Result.
|
|
117
|
+
*/
|
|
118
|
+
export const isSafeLinkURL = (url) => {
|
|
119
|
+
// Browsers ignore control characters and spaces around a URL as well as tabs and newlines within
|
|
120
|
+
// it, e.g. `java\tscript:`, so strip them all before looking for the scheme
|
|
121
|
+
// eslint-disable-next-line no-control-regex
|
|
122
|
+
const normalizedURL = url.replace(/[\u0000-\u0020]/g, '');
|
|
123
|
+
|
|
124
|
+
// Markdown decodes character references and backslash escapes in a link destination, so a scheme
|
|
125
|
+
// can be hidden with them, e.g. `javascript:`, `javascript:` or `javascript\:`. Reject
|
|
126
|
+
// any of them before the path, query or fragment.
|
|
127
|
+
if (/^[^/?#]*[&\\]/.test(normalizedURL)) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const scheme = /^([a-z][a-z\d+.-]*):/i.exec(normalizedURL)?.[1];
|
|
132
|
+
|
|
133
|
+
return !scheme || SAFE_LINK_URL_SCHEMES.includes(scheme.toLowerCase());
|
|
134
|
+
};
|
|
135
|
+
|
|
102
136
|
/**
|
|
103
137
|
* Get the current selection’s block node key as well as block and inline level types.
|
|
104
138
|
* @internal
|
|
@@ -126,7 +160,9 @@ export const getSelectionTypes = () => {
|
|
|
126
160
|
|
|
127
161
|
if (isLinkNode(parent)) {
|
|
128
162
|
inlineTypes.push('link');
|
|
129
|
-
|
|
163
|
+
// `getNearestNodeOfType()` would return the link itself, so start from its parent to find the
|
|
164
|
+
// block the link is in
|
|
165
|
+
parent = parent.getParent();
|
|
130
166
|
}
|
|
131
167
|
|
|
132
168
|
if (isListItemNode(parent)) {
|
|
@@ -178,31 +214,40 @@ export const getSelectionTypes = () => {
|
|
|
178
214
|
* @internal
|
|
179
215
|
* @param {LexicalEditor} editor Editor instance.
|
|
180
216
|
* @param {Transformer[]} enabledTransformers Enabled Markdown transformers.
|
|
217
|
+
* @param {string} [cachedValue] Markdown value from a previous call, to be reused instead of
|
|
218
|
+
* converting the whole document again when only the selection has changed.
|
|
219
|
+
* @returns {string} Markdown value.
|
|
181
220
|
*/
|
|
182
|
-
export const onEditorUpdate = (editor, enabledTransformers) => {
|
|
221
|
+
export const onEditorUpdate = (editor, enabledTransformers, cachedValue) => {
|
|
183
222
|
const transformers = enabledTransformers.filter(
|
|
184
223
|
(/** @type {any} */ { tag }) => !DISABLED_MARKDOWN_TAGS.includes(tag),
|
|
185
224
|
);
|
|
186
225
|
|
|
226
|
+
const value =
|
|
227
|
+
cachedValue ??
|
|
228
|
+
trimBlankBlockquoteLines(
|
|
229
|
+
convertToMarkdownString(transformers)
|
|
230
|
+
// Remove unnecessary backslash for underscore and backslash characters
|
|
231
|
+
// @see https://github.com/sveltia/sveltia-cms/issues/430
|
|
232
|
+
// @see https://github.com/sveltia/sveltia-cms/issues/512
|
|
233
|
+
.replace(/\\([_\\])/g, '$1')
|
|
234
|
+
// Replace encoded spaces with regular spaces. The HTML entity can appear with a
|
|
235
|
+
// combination of bold and italic text
|
|
236
|
+
// @see https://github.com/sveltia/sveltia-cms/issues/511
|
|
237
|
+
// @see https://github.com/sveltia/sveltia-cms/issues/534
|
|
238
|
+
.replace(/ /g, ' '),
|
|
239
|
+
);
|
|
240
|
+
|
|
187
241
|
editor.getRootElement()?.dispatchEvent(
|
|
188
242
|
new CustomEvent('Update', {
|
|
189
243
|
detail: {
|
|
190
|
-
value
|
|
191
|
-
convertToMarkdownString(transformers)
|
|
192
|
-
// Remove unnecessary backslash for underscore and backslash characters
|
|
193
|
-
// @see https://github.com/sveltia/sveltia-cms/issues/430
|
|
194
|
-
// @see https://github.com/sveltia/sveltia-cms/issues/512
|
|
195
|
-
.replace(/\\([_\\])/g, '$1')
|
|
196
|
-
// Replace encoded spaces with regular spaces. The HTML entity can appear with a
|
|
197
|
-
// combination of bold and italic text
|
|
198
|
-
// @see https://github.com/sveltia/sveltia-cms/issues/511
|
|
199
|
-
// @see https://github.com/sveltia/sveltia-cms/issues/534
|
|
200
|
-
.replace(/ /g, ' '),
|
|
201
|
-
),
|
|
244
|
+
value,
|
|
202
245
|
selection: getSelectionTypes(),
|
|
203
246
|
},
|
|
204
247
|
}),
|
|
205
248
|
);
|
|
249
|
+
|
|
250
|
+
return value;
|
|
206
251
|
};
|
|
207
252
|
|
|
208
253
|
/**
|
|
@@ -267,7 +312,17 @@ export const initEditor = ({
|
|
|
267
312
|
|
|
268
313
|
addUnregister(registerRichText(editor));
|
|
269
314
|
addUnregister(registerDragonSupport(editor));
|
|
270
|
-
|
|
315
|
+
// Cap the undo stack, as each entry holds a snapshot of the whole document
|
|
316
|
+
addUnregister(
|
|
317
|
+
registerHistory(
|
|
318
|
+
editor,
|
|
319
|
+
createEmptyHistoryState(),
|
|
320
|
+
1000,
|
|
321
|
+
undefined,
|
|
322
|
+
undefined,
|
|
323
|
+
HISTORY_MAX_DEPTH,
|
|
324
|
+
),
|
|
325
|
+
);
|
|
271
326
|
|
|
272
327
|
if (useMarkdownShortcuts) {
|
|
273
328
|
addUnregister(registerMarkdownShortcuts(editor, enabledTransformers));
|
|
@@ -291,6 +346,11 @@ export const initEditor = ({
|
|
|
291
346
|
editor.registerCommand(
|
|
292
347
|
TOGGLE_LINK_COMMAND,
|
|
293
348
|
(payload) => {
|
|
349
|
+
// Ignore an unsafe URL rather than linking to it
|
|
350
|
+
if (typeof payload === 'string' && !isSafeLinkURL(payload)) {
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
|
|
294
354
|
toggleLink(typeof payload === 'string' ? payload : null);
|
|
295
355
|
|
|
296
356
|
return true;
|
|
@@ -316,7 +376,8 @@ export const initEditor = ({
|
|
|
316
376
|
|
|
317
377
|
const clipboardText = event.clipboardData.getData('text').trim();
|
|
318
378
|
|
|
319
|
-
|
|
379
|
+
// Paste an unsafe URL as plain text rather than as a link
|
|
380
|
+
if (!isURL(clipboardText) || !isSafeLinkURL(clipboardText)) {
|
|
320
381
|
return false;
|
|
321
382
|
}
|
|
322
383
|
|
|
@@ -380,15 +441,41 @@ export const initEditor = ({
|
|
|
380
441
|
);
|
|
381
442
|
}
|
|
382
443
|
|
|
444
|
+
/** Incremented on every update, so only the latest one in a burst triggers the Update event. */
|
|
445
|
+
let updateCount = 0;
|
|
446
|
+
/** Whether the content has changed since the Update event was last triggered. */
|
|
447
|
+
let contentChanged = true;
|
|
448
|
+
/** @type {string | undefined} */
|
|
449
|
+
let lastValue = undefined;
|
|
450
|
+
let disposed = false;
|
|
451
|
+
|
|
452
|
+
addUnregister(() => {
|
|
453
|
+
disposed = true;
|
|
454
|
+
});
|
|
455
|
+
|
|
383
456
|
addUnregister(
|
|
384
|
-
editor.registerUpdateListener(() => {
|
|
457
|
+
editor.registerUpdateListener(({ dirtyElements, dirtyLeaves } = /** @type {any} */ ({})) => {
|
|
458
|
+
// An update that only moves the selection leaves no dirty nodes. Remember whether any update
|
|
459
|
+
// in the burst changed the content, so the document is only converted again when needed.
|
|
460
|
+
// This includes the updates made while composing, which are otherwise skipped.
|
|
461
|
+
contentChanged ||= !dirtyElements || dirtyElements.size > 0 || dirtyLeaves.size > 0;
|
|
462
|
+
|
|
385
463
|
if (editor?.isComposing()) {
|
|
386
464
|
return;
|
|
387
465
|
}
|
|
388
466
|
|
|
467
|
+
updateCount += 1;
|
|
468
|
+
|
|
469
|
+
const currentCount = updateCount;
|
|
470
|
+
|
|
389
471
|
(async () => {
|
|
390
472
|
await sleep(100);
|
|
391
473
|
|
|
474
|
+
// Skip if a newer update has been made in the meantime, or the editor has been disposed
|
|
475
|
+
if (currentCount !== updateCount || disposed) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
392
479
|
editor.update(() => {
|
|
393
480
|
// Prevent CodeNode from being removed
|
|
394
481
|
/* v8 ignore next */
|
|
@@ -409,7 +496,12 @@ export const initEditor = ({
|
|
|
409
496
|
}
|
|
410
497
|
}
|
|
411
498
|
|
|
412
|
-
onEditorUpdate(
|
|
499
|
+
lastValue = onEditorUpdate(
|
|
500
|
+
editor,
|
|
501
|
+
enabledTransformers,
|
|
502
|
+
contentChanged ? undefined : lastValue,
|
|
503
|
+
);
|
|
504
|
+
contentChanged = false;
|
|
413
505
|
});
|
|
414
506
|
})();
|
|
415
507
|
}),
|
|
@@ -78,11 +78,12 @@
|
|
|
78
78
|
};
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
|
-
* Listen to `click` events on the editor. Ignore a click on a link
|
|
81
|
+
* Listen to `click` events on the editor. Ignore a click on a link, including one on the text
|
|
82
|
+
* within it, which Lexical renders as a child element of the `<a>`.
|
|
82
83
|
* @param {MouseEvent} event `click` event.
|
|
83
84
|
*/
|
|
84
85
|
const onClick = (event) => {
|
|
85
|
-
if (/** @type {HTMLElement} */ (event.target)?.
|
|
86
|
+
if (/** @type {HTMLElement} */ (event.target)?.closest('a')) {
|
|
86
87
|
event.preventDefault();
|
|
87
88
|
}
|
|
88
89
|
};
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export const splitMultilineFormatting = (value) =>
|
|
8
8
|
value
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
.replace(/(
|
|
12
|
-
.replace(/(
|
|
9
|
+
// The surrounding whitespace is checked with lookarounds rather than matched with `\s+`, which
|
|
10
|
+
// would take quadratic time on a long run of whitespace not followed by a formatting marker
|
|
11
|
+
.replace(/(?<=\s)_([^_\n]+?)\n([^_\n]+?)_(?=\s)/g, '_$1_\n_$2_')
|
|
12
|
+
.replace(/(?<=\s)\*\*([^*\n]+?)\n([^*\n]+?)\*\*(?=\s)/g, '**$1**\n**$2**')
|
|
13
|
+
.replace(/(?<=\s)~~([^~\n]+?)\n([^~\n]+?)~~(?=\s)/g, '~~$1~~\n~~$2~~')
|
|
14
|
+
.replace(/(?<=\s)`([^`\n]+?)\n([^`\n]+?)`(?=\s)/g, '`$1`\n`$2`');
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* Apply a function to each line of a Markdown string, leaving the lines inside fenced code blocks
|
|
@@ -19,22 +21,39 @@ export const splitMultilineFormatting = (value) =>
|
|
|
19
21
|
* @returns {string} Processed Markdown string.
|
|
20
22
|
*/
|
|
21
23
|
const mapLinesOutsideCodeBlocks = (value, callback) => {
|
|
22
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Opening fence of the code block being processed, like ```` ``` ````, or an empty string when
|
|
26
|
+
* outside a code block.
|
|
27
|
+
*/
|
|
28
|
+
let openingFence = '';
|
|
23
29
|
|
|
24
30
|
return value
|
|
25
31
|
.split('\n')
|
|
26
32
|
.map((line) => {
|
|
27
33
|
// Like Lexical's `CODE_START_REGEX`, allow an indented fence
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
const fence = line.match(/^[ \t]*(`{3,}|~{3,})/)?.[1];
|
|
35
|
+
|
|
36
|
+
if (!openingFence) {
|
|
37
|
+
if (fence) {
|
|
38
|
+
openingFence = fence;
|
|
39
|
+
return line;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return callback(line);
|
|
31
43
|
}
|
|
32
44
|
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
// Like Lexical's `CODE_END_REGEX`, a code block is only closed by a bare fence of the same
|
|
46
|
+
// character that is at least as long as the opening one, so a shorter fence within the block,
|
|
47
|
+
// like ```` ``` ```` within ```` ```` ````, is part of the code
|
|
48
|
+
if (
|
|
49
|
+
fence?.[0] === openingFence[0] &&
|
|
50
|
+
fence.length >= openingFence.length &&
|
|
51
|
+
!line.trim().slice(fence.length)
|
|
52
|
+
) {
|
|
53
|
+
openingFence = '';
|
|
35
54
|
}
|
|
36
55
|
|
|
37
|
-
return
|
|
56
|
+
return line;
|
|
38
57
|
})
|
|
39
58
|
.join('\n');
|
|
40
59
|
};
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import Icon from '../../icon/icon.svelte';
|
|
22
22
|
import TextInput from '../../text-field/text-input.svelte';
|
|
23
23
|
import { AVAILABLE_BUTTONS } from '../constants.js';
|
|
24
|
-
import { focusEditor } from '../core.js';
|
|
24
|
+
import { focusEditor, isSafeLinkURL } from '../core.js';
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* @import { TextEditorStore } from '../../../typedefs';
|
|
@@ -45,6 +45,10 @@
|
|
|
45
45
|
let hasAnchor = $state(false);
|
|
46
46
|
let anchorURL = $state('');
|
|
47
47
|
let anchorText = $state('');
|
|
48
|
+
/**
|
|
49
|
+
* Whether the URL can be inserted: not empty, and safe to link to.
|
|
50
|
+
*/
|
|
51
|
+
const isValidURL = $derived(!!anchorURL.trim() && isSafeLinkURL(anchorURL));
|
|
48
52
|
|
|
49
53
|
/**
|
|
50
54
|
* Create a new link by showing a dialog to accept a URL and optionally text.
|
|
@@ -117,7 +121,7 @@
|
|
|
117
121
|
* @param {KeyboardEvent} event `keydown` event.
|
|
118
122
|
*/
|
|
119
123
|
const onInputKeyDown = (event) => {
|
|
120
|
-
if (matchesShortcuts(event, 'Enter') &&
|
|
124
|
+
if (matchesShortcuts(event, 'Enter') && isValidURL) {
|
|
121
125
|
openDialog = false;
|
|
122
126
|
}
|
|
123
127
|
};
|
|
@@ -167,8 +171,9 @@
|
|
|
167
171
|
|
|
168
172
|
/**
|
|
169
173
|
* Open the dialog with a keyboard shortcut: Accel+K.
|
|
174
|
+
* @returns {(() => void) | undefined} Function to unregister the command.
|
|
170
175
|
*/
|
|
171
|
-
const _registerCommand = () =>
|
|
176
|
+
const _registerCommand = () =>
|
|
172
177
|
editorStore.editor?.registerCommand(
|
|
173
178
|
KEY_DOWN_COMMAND,
|
|
174
179
|
(event) => {
|
|
@@ -181,12 +186,15 @@
|
|
|
181
186
|
},
|
|
182
187
|
COMMAND_PRIORITY_NORMAL,
|
|
183
188
|
);
|
|
184
|
-
};
|
|
185
189
|
|
|
186
190
|
$effect(() => {
|
|
187
191
|
if (editorStore.editor) {
|
|
188
|
-
|
|
192
|
+
// Unregister on unmount, e.g. when the toolbar swaps the button out while the caret is in a
|
|
193
|
+
// code block, so the shortcut isn’t handled by a stale instance
|
|
194
|
+
return _registerCommand();
|
|
189
195
|
}
|
|
196
|
+
|
|
197
|
+
return undefined;
|
|
190
198
|
});
|
|
191
199
|
</script>
|
|
192
200
|
|
|
@@ -210,7 +218,7 @@
|
|
|
210
218
|
? _('_sui.text_editor.insert_link')
|
|
211
219
|
: _('_sui.text_editor.update_link')}
|
|
212
220
|
bind:open={openDialog}
|
|
213
|
-
okDisabled={!
|
|
221
|
+
okDisabled={!isValidURL}
|
|
214
222
|
okLabel={dialogMode === 'create' ? _('_sui.insert') : _('_sui.update')}
|
|
215
223
|
restoreFocus={false}
|
|
216
224
|
onClose={(event) => {
|
|
@@ -224,6 +232,7 @@
|
|
|
224
232
|
id="{id}-url"
|
|
225
233
|
bind:value={anchorURL}
|
|
226
234
|
flex
|
|
235
|
+
invalid={!!anchorURL.trim() && !isSafeLinkURL(anchorURL)}
|
|
227
236
|
aria-label={_('_sui.text_editor.url')}
|
|
228
237
|
onkeydown={(event) => {
|
|
229
238
|
onInputKeyDown(event);
|
|
@@ -54,8 +54,9 @@ export const isAtMax = (inputValue, max) => typeof max === 'number' && toNumber(
|
|
|
54
54
|
* @param {1 | -1} options.direction `1` to increase, `-1` to decrease.
|
|
55
55
|
* @param {number} [options.min] Minimum allowed value.
|
|
56
56
|
* @param {number} [options.max] Maximum allowed value.
|
|
57
|
-
* @returns {string | undefined} New text for the field, or `undefined` if
|
|
58
|
-
* number or is already at the limit in that direction, in which case it’s left
|
|
57
|
+
* @returns {string | undefined} New text for the field, clamped to the range, or `undefined` if
|
|
58
|
+
* the field holds no number or is already at the limit in that direction, in which case it’s left
|
|
59
|
+
* as is.
|
|
59
60
|
*/
|
|
60
61
|
export const stepNumber = (inputValue, { step, direction, min, max }) => {
|
|
61
62
|
if (Number.isNaN(Number(inputValue))) {
|
|
@@ -66,7 +67,19 @@ export const stepNumber = (inputValue, { step, direction, min, max }) => {
|
|
|
66
67
|
return undefined;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
|
|
70
|
+
const maximumFractionDigits = getMaximumFractionDigits(step);
|
|
71
|
+
const newValue = Number((toNumber(inputValue) + step * direction).toFixed(maximumFractionDigits));
|
|
72
|
+
|
|
73
|
+
// Stop at the limit rather than stepping past it, as the native `<input type="number">` does
|
|
74
|
+
if (typeof max === 'number' && newValue > max) {
|
|
75
|
+
return String(max);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (typeof min === 'number' && newValue < min) {
|
|
79
|
+
return String(min);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return newValue.toFixed(maximumFractionDigits);
|
|
70
83
|
};
|
|
71
84
|
|
|
72
85
|
/**
|
|
@@ -69,11 +69,13 @@
|
|
|
69
69
|
const isMax = $derived(isAtMax(inputValue, max));
|
|
70
70
|
|
|
71
71
|
$effect(() => {
|
|
72
|
-
const
|
|
72
|
+
const newValue = value;
|
|
73
73
|
|
|
74
|
+
// Only overwrite the field when it doesn’t already hold the value, so text that is still being
|
|
75
|
+
// typed, like `1.` or `-`, isn’t wiped out when it parses to the same number or no number
|
|
74
76
|
untrack(() => {
|
|
75
|
-
if (inputValue !==
|
|
76
|
-
inputValue =
|
|
77
|
+
if (parseNumber(inputValue) !== newValue) {
|
|
78
|
+
inputValue = String(newValue ?? '');
|
|
77
79
|
}
|
|
78
80
|
});
|
|
79
81
|
});
|
|
@@ -126,7 +128,7 @@
|
|
|
126
128
|
<div role="none" class="buttons">
|
|
127
129
|
<Button
|
|
128
130
|
iconic
|
|
129
|
-
disabled={disabled || readonly || Number.isNaN(Number(
|
|
131
|
+
disabled={disabled || readonly || Number.isNaN(Number(inputValue)) || isMax}
|
|
130
132
|
aria-label={_('_sui.number_input.increase')}
|
|
131
133
|
aria-controls={id}
|
|
132
134
|
onclick={() => {
|
|
@@ -143,7 +145,7 @@
|
|
|
143
145
|
</Button>
|
|
144
146
|
<Button
|
|
145
147
|
iconic
|
|
146
|
-
disabled={disabled || readonly || Number.isNaN(Number(
|
|
148
|
+
disabled={disabled || readonly || Number.isNaN(Number(inputValue)) || isMin}
|
|
147
149
|
aria-label={_('_sui.number_input.decrease')}
|
|
148
150
|
aria-controls={id}
|
|
149
151
|
onclick={() => {
|
|
@@ -61,8 +61,13 @@
|
|
|
61
61
|
class:show
|
|
62
62
|
{hidden}
|
|
63
63
|
>
|
|
64
|
+
<!-- The field is masked with CSS rather than `type="password"`, so opt out of the features that
|
|
65
|
+
would remember the secret, like autofill history and the keyboard’s learned words -->
|
|
64
66
|
<TextInput
|
|
65
67
|
dir="ltr"
|
|
68
|
+
autocomplete="off"
|
|
69
|
+
autocapitalize="off"
|
|
70
|
+
autocorrect="off"
|
|
66
71
|
bind:element={inputElement}
|
|
67
72
|
{...restProps}
|
|
68
73
|
{id}
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* Helpers behind `<AppShell>`, kept apart from the component so they can be tested on their own.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* `<input>` types that hold editable text.
|
|
7
|
+
*/
|
|
8
|
+
const TEXT_INPUT_TYPES = ['text', 'search', 'url', 'tel', 'email', 'password', 'number'];
|
|
9
|
+
|
|
5
10
|
/**
|
|
6
11
|
* Whether the browser’s context menu should be allowed on the given element. The shell suppresses
|
|
7
12
|
* it to feel more like a native app, except where it’s actually useful: on text fields and rich
|
|
@@ -19,10 +24,13 @@ export const shouldAllowContextMenu = (target) => {
|
|
|
19
24
|
return false;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
// Allow context menu on text inputs and contentEditable elements.
|
|
23
|
-
//
|
|
27
|
+
// Allow context menu on text inputs and contentEditable elements. Only the input types that hold
|
|
28
|
+
// editable text qualify, not a checkbox or a button. (Every `<input>` has a `maxLength` property
|
|
29
|
+
// whatever its type, so that can’t tell them apart.)
|
|
24
30
|
return (
|
|
25
|
-
|
|
31
|
+
target instanceof HTMLTextAreaElement ||
|
|
32
|
+
// The `type` property is normalized: lowercased, and `text` for a missing or unknown type
|
|
33
|
+
(target instanceof HTMLInputElement && TEXT_INPUT_TYPES.includes(target.type)) ||
|
|
26
34
|
/** @type {HTMLElement | null} */ (target.closest('[role="textbox"]'))?.contentEditable ===
|
|
27
35
|
'true'
|
|
28
36
|
);
|
|
@@ -252,7 +252,7 @@
|
|
|
252
252
|
.content.listbox {
|
|
253
253
|
border-width: var(--sui-listbox-border-width, 1px);
|
|
254
254
|
border-style: var(--sui-listbox-border-style, solid);
|
|
255
|
-
border-color: var(--sui-listbox-border-
|
|
255
|
+
border-color: var(--sui-listbox-border-color, var(--sui-secondary-border-color));
|
|
256
256
|
border-radius: var(--sui-listbox-border-radius, 4px);
|
|
257
257
|
padding: var(--sui-listbox-padding, 4px);
|
|
258
258
|
}
|
|
@@ -143,6 +143,12 @@ export class Group {
|
|
|
143
143
|
* @param {HTMLElement} newTarget Target element.
|
|
144
144
|
*/
|
|
145
145
|
selectTarget(event: (MouseEvent | KeyboardEvent), newTarget: HTMLElement): void;
|
|
146
|
+
/**
|
|
147
|
+
* Scroll the given elements into view after a short delay, replacing any pending scroll, so a
|
|
148
|
+
* key held down doesn’t queue a scroll for every member it passes and jump back to an old one.
|
|
149
|
+
* @param {HTMLElement[]} elements Elements to scroll into view.
|
|
150
|
+
*/
|
|
151
|
+
scrollIntoViewLater(elements: HTMLElement[]): void;
|
|
146
152
|
/**
|
|
147
153
|
* Handle the `click` event on the widget.
|
|
148
154
|
* @param {MouseEvent} event `click` event.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isRTL } from '@sveltia/i18n';
|
|
2
2
|
import { generateElementId } from '@sveltia/utils/element';
|
|
3
3
|
import { sleep } from '@sveltia/utils/misc';
|
|
4
|
+
import { untrack } from 'svelte';
|
|
4
5
|
import { getSelectedItemDetail } from './select.svelte.js';
|
|
5
6
|
import { findTypeAheadMatch, TypeAhead } from './type-ahead.js';
|
|
6
7
|
|
|
@@ -197,6 +198,18 @@ export class Group {
|
|
|
197
198
|
*/
|
|
198
199
|
activated = false;
|
|
199
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Whether {@link destroy} has run, so a pending {@link activate} must not attach the listeners.
|
|
203
|
+
* @type {boolean}
|
|
204
|
+
*/
|
|
205
|
+
#destroyed = false;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Timer for the pending {@link scrollIntoViewLater} call, if any.
|
|
209
|
+
* @type {ReturnType<typeof setTimeout> | undefined}
|
|
210
|
+
*/
|
|
211
|
+
#scrollTimer = undefined;
|
|
212
|
+
|
|
200
213
|
/**
|
|
201
214
|
* Memoized member lists, discarded whenever the widget’s subtree changes. See {@link #members}.
|
|
202
215
|
* @type {{ all: HTMLElement[], active: HTMLElement[] } | undefined}
|
|
@@ -342,7 +355,12 @@ export class Group {
|
|
|
342
355
|
// Wait a bit before the relevant components, including the `aria-controls` target are mounted
|
|
343
356
|
(async () => {
|
|
344
357
|
await sleep(100);
|
|
345
|
-
|
|
358
|
+
|
|
359
|
+
// The widget may have been unmounted in the meantime, and the listeners added by `activate()`
|
|
360
|
+
// would then never be removed
|
|
361
|
+
if (!this.#destroyed) {
|
|
362
|
+
this.activate();
|
|
363
|
+
}
|
|
346
364
|
})();
|
|
347
365
|
}
|
|
348
366
|
|
|
@@ -360,11 +378,10 @@ export class Group {
|
|
|
360
378
|
element.getAttribute(this.childSelectedAttr) === 'true' ||
|
|
361
379
|
(defaultSelected ? element === defaultSelected : this.selectFirst && index === 0);
|
|
362
380
|
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
: null;
|
|
381
|
+
const controlTargetId = this.controlsPanel ? element.getAttribute('aria-controls') : null;
|
|
382
|
+
// Looked up by ID rather than with a selector, which would throw on an ID that isn’t a valid
|
|
383
|
+
// CSS identifier, such as one starting with a digit
|
|
384
|
+
const controlTarget = controlTargetId ? document.getElementById(controlTargetId) : null;
|
|
368
385
|
|
|
369
386
|
element.id ||= `${this.id}-item-${index + 1}`;
|
|
370
387
|
element.setAttribute(this.childSelectedAttr, String(isSelected));
|
|
@@ -697,6 +714,11 @@ export class Group {
|
|
|
697
714
|
* @type {boolean}
|
|
698
715
|
*/
|
|
699
716
|
let targetAffected = false;
|
|
717
|
+
/**
|
|
718
|
+
* Elements to be scrolled into view: the target and the panel it controls, if any.
|
|
719
|
+
* @type {HTMLElement[]}
|
|
720
|
+
*/
|
|
721
|
+
const scrollTargets = [];
|
|
700
722
|
|
|
701
723
|
this.activeMembers.forEach((element) => {
|
|
702
724
|
// Reading the role once and comparing it is markedly cheaper than putting every member
|
|
@@ -765,33 +787,20 @@ export class Group {
|
|
|
765
787
|
controlTarget.setAttribute('aria-hidden', String(!isTarget));
|
|
766
788
|
|
|
767
789
|
if (isTarget) {
|
|
768
|
-
|
|
769
|
-
try {
|
|
770
|
-
controlTarget.scrollIntoView({
|
|
771
|
-
block: 'nearest',
|
|
772
|
-
inline: 'nearest',
|
|
773
|
-
behavior: 'auto',
|
|
774
|
-
});
|
|
775
|
-
} catch {
|
|
776
|
-
controlTarget.scrollIntoView(true);
|
|
777
|
-
}
|
|
778
|
-
}, 300);
|
|
790
|
+
scrollTargets.push(controlTarget);
|
|
779
791
|
}
|
|
780
792
|
}
|
|
781
793
|
|
|
782
794
|
if (isTarget) {
|
|
783
795
|
this.parent.setAttribute('aria-activedescendant', element.id);
|
|
784
|
-
|
|
785
|
-
globalThis.setTimeout(() => {
|
|
786
|
-
try {
|
|
787
|
-
element.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'auto' });
|
|
788
|
-
} catch {
|
|
789
|
-
element.scrollIntoView(true);
|
|
790
|
-
}
|
|
791
|
-
}, 300);
|
|
796
|
+
scrollTargets.push(element);
|
|
792
797
|
}
|
|
793
798
|
});
|
|
794
799
|
|
|
800
|
+
if (scrollTargets.length) {
|
|
801
|
+
this.scrollIntoViewLater(scrollTargets);
|
|
802
|
+
}
|
|
803
|
+
|
|
795
804
|
if (this.focusChild) {
|
|
796
805
|
// Done right away rather than on the next frame: a key that repeats, or a second press
|
|
797
806
|
// before the frame, has to start from the member that was just reached, which it reads
|
|
@@ -811,6 +820,27 @@ export class Group {
|
|
|
811
820
|
);
|
|
812
821
|
}
|
|
813
822
|
|
|
823
|
+
/**
|
|
824
|
+
* Scroll the given elements into view after a short delay, replacing any pending scroll, so a
|
|
825
|
+
* key held down doesn’t queue a scroll for every member it passes and jump back to an old one.
|
|
826
|
+
* @param {HTMLElement[]} elements Elements to scroll into view.
|
|
827
|
+
*/
|
|
828
|
+
scrollIntoViewLater(elements) {
|
|
829
|
+
globalThis.clearTimeout(this.#scrollTimer);
|
|
830
|
+
|
|
831
|
+
this.#scrollTimer = globalThis.setTimeout(() => {
|
|
832
|
+
this.#scrollTimer = undefined;
|
|
833
|
+
|
|
834
|
+
elements.forEach((element) => {
|
|
835
|
+
try {
|
|
836
|
+
element.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'auto' });
|
|
837
|
+
} catch {
|
|
838
|
+
element.scrollIntoView(true);
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
}, 300);
|
|
842
|
+
}
|
|
843
|
+
|
|
814
844
|
/**
|
|
815
845
|
* Handle the `click` event on the widget.
|
|
816
846
|
* @param {MouseEvent} event `click` event.
|
|
@@ -1031,13 +1061,16 @@ export class Group {
|
|
|
1031
1061
|
newTarget = allMembers[Math.min(index + colCount, lastIndex)];
|
|
1032
1062
|
}
|
|
1033
1063
|
|
|
1034
|
-
// In RTL, ArrowLeft moves
|
|
1035
|
-
|
|
1036
|
-
|
|
1064
|
+
// In RTL, ArrowLeft moves to the next member, ArrowRight to the previous one
|
|
1065
|
+
const prevKey = _isRTL ? 'ArrowRight' : 'ArrowLeft';
|
|
1066
|
+
const nextKey = _isRTL ? 'ArrowLeft' : 'ArrowRight';
|
|
1067
|
+
|
|
1068
|
+
if (key === prevKey && index > 0) {
|
|
1069
|
+
newTarget = allMembers[index - 1];
|
|
1037
1070
|
}
|
|
1038
1071
|
|
|
1039
|
-
if (key ===
|
|
1040
|
-
newTarget = allMembers[index +
|
|
1072
|
+
if (key === nextKey && index !== -1 && index < lastIndex) {
|
|
1073
|
+
newTarget = allMembers[index + 1];
|
|
1041
1074
|
}
|
|
1042
1075
|
|
|
1043
1076
|
if (newTarget?.matches('[aria-disabled="true"], [aria-hidden="true"]')) {
|
|
@@ -1089,6 +1122,8 @@ export class Group {
|
|
|
1089
1122
|
* Clean up event listeners.
|
|
1090
1123
|
*/
|
|
1091
1124
|
destroy() {
|
|
1125
|
+
this.#destroyed = true;
|
|
1126
|
+
globalThis.clearTimeout(this.#scrollTimer);
|
|
1092
1127
|
this.#typeAhead.reset();
|
|
1093
1128
|
this.observer.disconnect();
|
|
1094
1129
|
this.parent.removeEventListener('click', this._onClick);
|
|
@@ -1134,7 +1169,10 @@ export class Group {
|
|
|
1134
1169
|
*/
|
|
1135
1170
|
export const activateGroup = (paramsOrGetter) => (parent) => {
|
|
1136
1171
|
const isGetter = typeof paramsOrGetter === 'function';
|
|
1137
|
-
|
|
1172
|
+
// Read the params without tracking them: attachments run in an effect, so a tracked read would
|
|
1173
|
+
// destroy and recreate the whole group whenever they change, e.g. on every search keystroke.
|
|
1174
|
+
// Changes are handled by `onUpdate()` below instead.
|
|
1175
|
+
const initialParams = isGetter ? untrack(() => paramsOrGetter()) : paramsOrGetter;
|
|
1138
1176
|
const group = new Group(/** @type {HTMLElement} */ (parent), initialParams);
|
|
1139
1177
|
|
|
1140
1178
|
/* v8 ignore next 4 */
|
|
@@ -86,6 +86,13 @@ class Popup {
|
|
|
86
86
|
*/
|
|
87
87
|
#removeEventListeners = undefined;
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Functions that remove the event listeners added to the {@link anchorElement}, which may outlive
|
|
91
|
+
* this instance.
|
|
92
|
+
* @type {(() => void)[]}
|
|
93
|
+
*/
|
|
94
|
+
#removeAnchorListeners = [];
|
|
95
|
+
|
|
89
96
|
/**
|
|
90
97
|
* Initialize a new `Popup` instance. Note that the `popupElement` is optional, because the
|
|
91
98
|
* element is typically mounted only while the popup is open. Use {@link attachPopupElement} to
|
|
@@ -105,13 +112,13 @@ class Popup {
|
|
|
105
112
|
|
|
106
113
|
this.anchorElement.setAttribute('aria-expanded', 'false');
|
|
107
114
|
|
|
108
|
-
on(anchorElement, 'click', () => {
|
|
115
|
+
const removeClickListener = on(anchorElement, 'click', () => {
|
|
109
116
|
if (!this.isDisabled && !this.isReadOnly) {
|
|
110
117
|
this.open = !this.open;
|
|
111
118
|
}
|
|
112
119
|
});
|
|
113
120
|
|
|
114
|
-
on(anchorElement, 'keydown', (event) => {
|
|
121
|
+
const removeKeyDownListener = on(anchorElement, 'keydown', (event) => {
|
|
115
122
|
const { key, ctrlKey, metaKey, shiftKey, altKey } = event;
|
|
116
123
|
const hasModifier = shiftKey || altKey || ctrlKey || metaKey;
|
|
117
124
|
|
|
@@ -145,12 +152,18 @@ class Popup {
|
|
|
145
152
|
}
|
|
146
153
|
});
|
|
147
154
|
|
|
148
|
-
on(anchorElement, 'transitionstart', () => {
|
|
155
|
+
const removeTransitionStartListener = on(anchorElement, 'transitionstart', () => {
|
|
149
156
|
if (this.anchorElement.closest('.hiding, .hidden, [hidden]')) {
|
|
150
157
|
this.hideImmediately();
|
|
151
158
|
}
|
|
152
159
|
});
|
|
153
160
|
|
|
161
|
+
this.#removeAnchorListeners = [
|
|
162
|
+
removeClickListener,
|
|
163
|
+
removeKeyDownListener,
|
|
164
|
+
removeTransitionStartListener,
|
|
165
|
+
];
|
|
166
|
+
|
|
154
167
|
this.intersectionObserver = new IntersectionObserver(([entry]) => {
|
|
155
168
|
if (!entry.isIntersecting && this.open) {
|
|
156
169
|
this.hideImmediately();
|
|
@@ -514,6 +527,8 @@ class Popup {
|
|
|
514
527
|
*/
|
|
515
528
|
destroy() {
|
|
516
529
|
this.detachPopupElement();
|
|
530
|
+
this.#removeAnchorListeners.forEach((removeListener) => removeListener());
|
|
531
|
+
this.#removeAnchorListeners = [];
|
|
517
532
|
this.intersectionObserver?.disconnect();
|
|
518
533
|
this.resizeObserver?.disconnect();
|
|
519
534
|
this.viewportResizeObserver?.disconnect();
|
|
@@ -29,11 +29,46 @@ export class OptionRegistry {
|
|
|
29
29
|
expanded = $state(false);
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Registered options. This is a
|
|
33
|
-
*
|
|
32
|
+
* Registered options. This is a plain set rather than a state, so that registering an option
|
|
33
|
+
* doesn’t copy the whole list, which would make mounting many options quadratic. The entries,
|
|
34
|
+
* which expose their properties as getters onto the `<Option>`’s props, are not wrapped in a
|
|
35
|
+
* reactive proxy either. Changes are signalled through {@link #version}.
|
|
36
|
+
* @type {Set<OptionEntry>}
|
|
37
|
+
*/
|
|
38
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Signalled manually, see above
|
|
39
|
+
#entrySet = new Set();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Incremented whenever an option is registered or unregistered, to notify the readers.
|
|
43
|
+
* @type {number}
|
|
44
|
+
*/
|
|
45
|
+
#version = $state(0);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Array snapshot of {@link #entrySet}, rebuilt lazily after a change.
|
|
34
49
|
* @type {OptionEntry[]}
|
|
35
50
|
*/
|
|
36
|
-
#
|
|
51
|
+
#snapshot = [];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Value of {@link #version} when {@link #snapshot} was last rebuilt.
|
|
55
|
+
* @type {number}
|
|
56
|
+
*/
|
|
57
|
+
#snapshotVersion = -1;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Registered options, in registration order. The returned array must not be mutated.
|
|
61
|
+
* @returns {OptionEntry[]} Entries.
|
|
62
|
+
*/
|
|
63
|
+
get #entries() {
|
|
64
|
+
// Reading `#version` also subscribes the caller to changes
|
|
65
|
+
if (this.#snapshotVersion !== this.#version) {
|
|
66
|
+
this.#snapshot = [...this.#entrySet];
|
|
67
|
+
this.#snapshotVersion = this.#version;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return this.#snapshot;
|
|
71
|
+
}
|
|
37
72
|
|
|
38
73
|
/**
|
|
39
74
|
* Number of registered options.
|
|
@@ -65,10 +100,12 @@ export class OptionRegistry {
|
|
|
65
100
|
* @returns {() => void} Function to unregister the option.
|
|
66
101
|
*/
|
|
67
102
|
register(entry) {
|
|
68
|
-
this.#
|
|
103
|
+
this.#entrySet.add(entry);
|
|
104
|
+
this.#version += 1;
|
|
69
105
|
|
|
70
106
|
return () => {
|
|
71
|
-
this.#
|
|
107
|
+
this.#entrySet.delete(entry);
|
|
108
|
+
this.#version += 1;
|
|
72
109
|
};
|
|
73
110
|
}
|
|
74
111
|
|
|
@@ -45,6 +45,11 @@ export class Tree {
|
|
|
45
45
|
* @type {ReturnType<typeof globalThis.setTimeout> | undefined}
|
|
46
46
|
*/
|
|
47
47
|
typeAheadTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Whether {@link destroy} has run, so a pending {@link activate} must not set anything up.
|
|
50
|
+
* @type {boolean}
|
|
51
|
+
*/
|
|
52
|
+
destroyed: boolean;
|
|
48
53
|
/** @type {(event: MouseEvent) => void} */
|
|
49
54
|
_onClick: (event: MouseEvent) => void;
|
|
50
55
|
/** @type {(event: KeyboardEvent) => void} */
|
|
@@ -67,6 +67,12 @@ export class Tree {
|
|
|
67
67
|
*/
|
|
68
68
|
this.typeAheadTimer = undefined;
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Whether {@link destroy} has run, so a pending {@link activate} must not set anything up.
|
|
72
|
+
* @type {boolean}
|
|
73
|
+
*/
|
|
74
|
+
this.destroyed = false;
|
|
75
|
+
|
|
70
76
|
// eslint-disable-next-line jsdoc/require-description
|
|
71
77
|
/** @type {(event: MouseEvent) => void} */
|
|
72
78
|
this._onClick = (event) => {
|
|
@@ -96,7 +102,12 @@ export class Tree {
|
|
|
96
102
|
// Wait a bit before the child components are mounted
|
|
97
103
|
(async () => {
|
|
98
104
|
await sleep(100);
|
|
99
|
-
|
|
105
|
+
|
|
106
|
+
// The widget may have been unmounted in the meantime, and the listeners and the observer set
|
|
107
|
+
// up by `activate()` would then never be removed
|
|
108
|
+
if (!this.destroyed) {
|
|
109
|
+
this.activate();
|
|
110
|
+
}
|
|
100
111
|
})();
|
|
101
112
|
}
|
|
102
113
|
|
|
@@ -759,6 +770,7 @@ export class Tree {
|
|
|
759
770
|
* Clean up event listeners.
|
|
760
771
|
*/
|
|
761
772
|
destroy() {
|
|
773
|
+
this.destroyed = true;
|
|
762
774
|
globalThis.clearTimeout(this.typeAheadTimer);
|
|
763
775
|
this.observer.disconnect();
|
|
764
776
|
this.parent.removeEventListener('click', this._onClick);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sveltia/ui",
|
|
3
|
-
"version": "0.77.
|
|
3
|
+
"version": "0.77.2",
|
|
4
4
|
"description": "A collection of Svelte components and utilities for building user interfaces.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"@shikijs/langs": "4.4.3",
|
|
58
58
|
"@shikijs/themes": "4.4.3",
|
|
59
59
|
"@sveltia/i18n": "^1.4.0",
|
|
60
|
-
"@sveltia/utils": "^0.12.
|
|
60
|
+
"@sveltia/utils": "^0.12.2",
|
|
61
61
|
"lexical": "^0.51.0"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|