@sveltia/ui 0.34.0 → 0.35.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.
- package/dist/components/button/button.svelte +2 -1
- package/dist/components/calendar/calendar.svelte +17 -25
- package/dist/components/divider/spacer.svelte +2 -1
- package/dist/components/select/combobox.svelte +10 -7
- package/dist/components/text-editor/code-editor.svelte +3 -0
- package/dist/components/text-editor/constants.test.d.ts +1 -0
- package/dist/components/text-editor/constants.test.js +98 -0
- package/dist/components/text-editor/core.js +13 -8
- package/dist/components/text-editor/store.svelte.test.d.ts +1 -0
- package/dist/components/text-editor/store.svelte.test.js +196 -0
- package/dist/components/text-editor/text-editor.svelte +3 -0
- package/dist/components/text-editor/transformers/hr.test.d.ts +1 -0
- package/dist/components/text-editor/transformers/hr.test.js +108 -0
- package/dist/components/text-editor/transformers/table.test.d.ts +1 -0
- package/dist/components/text-editor/transformers/table.test.js +28 -0
- package/dist/components/text-field/number-input.svelte +2 -1
- package/dist/components/text-field/password-input.svelte +2 -1
- package/dist/components/text-field/search-bar.svelte +2 -1
- package/dist/components/text-field/secret-input.svelte +2 -1
- package/dist/components/text-field/text-area.svelte +2 -1
- package/dist/components/text-field/text-input.svelte +41 -2
- package/dist/components/toast/toast.svelte +7 -3
- package/dist/services/events.svelte.js +66 -8
- package/dist/services/events.test.d.ts +1 -0
- package/dist/services/events.test.js +221 -0
- package/dist/services/group.svelte.d.ts +1 -0
- package/dist/services/group.svelte.js +15 -10
- package/dist/services/group.test.d.ts +1 -0
- package/dist/services/group.test.js +763 -0
- package/dist/services/i18n.d.ts +6 -0
- package/dist/services/i18n.js +4 -2
- package/dist/services/i18n.test.d.ts +1 -0
- package/dist/services/i18n.test.js +106 -0
- package/dist/services/popup.svelte.d.ts +1 -0
- package/dist/services/popup.svelte.js +11 -2
- package/dist/services/popup.test.d.ts +1 -0
- package/dist/services/popup.test.js +536 -0
- package/dist/services/select.test.d.ts +1 -0
- package/dist/services/select.test.js +69 -0
- package/dist/typedefs.d.ts +7 -0
- package/dist/typedefs.js +4 -0
- package/package.json +12 -11
|
@@ -107,7 +107,8 @@
|
|
|
107
107
|
margin: var(--sui-focus-ring-width);
|
|
108
108
|
min-width: var(--sui-textbox-singleline-min-width);
|
|
109
109
|
}
|
|
110
|
-
.password-input.flex {
|
|
110
|
+
.password-input.flex:not([hidden]) {
|
|
111
|
+
display: inline-flex;
|
|
111
112
|
width: -moz-available;
|
|
112
113
|
width: -webkit-fill-available;
|
|
113
114
|
width: stretch;
|
|
@@ -121,7 +121,8 @@
|
|
|
121
121
|
margin: var(--sui-focus-ring-width);
|
|
122
122
|
min-width: var(--sui-textbox-singleline-min-width);
|
|
123
123
|
}
|
|
124
|
-
.search-bar.flex {
|
|
124
|
+
.search-bar.flex:not([hidden]) {
|
|
125
|
+
display: inline-flex;
|
|
125
126
|
width: -moz-available;
|
|
126
127
|
width: -webkit-fill-available;
|
|
127
128
|
width: stretch;
|
|
@@ -101,7 +101,8 @@
|
|
|
101
101
|
margin: var(--sui-focus-ring-width);
|
|
102
102
|
min-width: var(--sui-textbox-singleline-min-width);
|
|
103
103
|
}
|
|
104
|
-
.secret-input.flex {
|
|
104
|
+
.secret-input.flex:not([hidden]) {
|
|
105
|
+
display: inline-flex;
|
|
105
106
|
width: -moz-available;
|
|
106
107
|
width: -webkit-fill-available;
|
|
107
108
|
width: stretch;
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
inputmode = 'text',
|
|
32
32
|
flex = false,
|
|
33
33
|
monospace = false,
|
|
34
|
+
debounce = false,
|
|
34
35
|
class: className,
|
|
35
36
|
hidden = false,
|
|
36
37
|
disabled = false,
|
|
@@ -39,11 +40,47 @@
|
|
|
39
40
|
invalid = false,
|
|
40
41
|
'aria-label': ariaLabel,
|
|
41
42
|
children,
|
|
43
|
+
oninput,
|
|
42
44
|
...restProps
|
|
43
45
|
/* eslint-enable prefer-const */
|
|
44
46
|
} = $props();
|
|
45
47
|
|
|
46
48
|
const id = $props.id();
|
|
49
|
+
const timeout = $derived(typeof debounce === 'number' ? debounce : 300);
|
|
50
|
+
|
|
51
|
+
let debounceTimer = 0;
|
|
52
|
+
|
|
53
|
+
$effect(() => () => {
|
|
54
|
+
clearTimeout(debounceTimer);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Update the `value` and call the `oninput` callback.
|
|
59
|
+
* @param {InputEvent} event The `input` event object.
|
|
60
|
+
*/
|
|
61
|
+
const fireInput = (event) => {
|
|
62
|
+
value = /** @type {HTMLInputElement} */ (event.target).value;
|
|
63
|
+
oninput?.(event);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Handle the `input` event. If `debounce` is `true`, the event will be debounced by 300ms.
|
|
68
|
+
* @param {InputEvent} event The `input` event object.
|
|
69
|
+
*/
|
|
70
|
+
const handleInput = (event) => {
|
|
71
|
+
if (debounce) {
|
|
72
|
+
clearTimeout(debounceTimer);
|
|
73
|
+
debounceTimer = /** @type {number} */ (
|
|
74
|
+
/** @type {unknown} */ (
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
fireInput(event);
|
|
77
|
+
}, timeout)
|
|
78
|
+
)
|
|
79
|
+
);
|
|
80
|
+
} else {
|
|
81
|
+
fireInput(event);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
47
84
|
</script>
|
|
48
85
|
|
|
49
86
|
<div
|
|
@@ -58,7 +95,7 @@
|
|
|
58
95
|
<input
|
|
59
96
|
bind:this={element}
|
|
60
97
|
{...restProps}
|
|
61
|
-
|
|
98
|
+
{value}
|
|
62
99
|
type="text"
|
|
63
100
|
{role}
|
|
64
101
|
dir="auto"
|
|
@@ -73,6 +110,7 @@
|
|
|
73
110
|
aria-readonly={readonly}
|
|
74
111
|
aria-required={required}
|
|
75
112
|
aria-invalid={invalid}
|
|
113
|
+
oninput={handleInput}
|
|
76
114
|
use:activateKeyShortcuts={keyShortcuts}
|
|
77
115
|
/>
|
|
78
116
|
{#if ariaLabel && showInlineLabel}
|
|
@@ -91,7 +129,8 @@
|
|
|
91
129
|
margin: var(--sui-focus-ring-width);
|
|
92
130
|
min-width: var(--sui-textbox-singleline-min-width);
|
|
93
131
|
}
|
|
94
|
-
.text-input.flex {
|
|
132
|
+
.text-input.flex:not([hidden]) {
|
|
133
|
+
display: inline-flex;
|
|
95
134
|
width: -moz-available;
|
|
96
135
|
width: -webkit-fill-available;
|
|
97
136
|
width: stretch;
|
|
@@ -112,9 +112,13 @@
|
|
|
112
112
|
});
|
|
113
113
|
|
|
114
114
|
if (show && duration) {
|
|
115
|
-
timerId =
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
timerId = /** @type {number} */ (
|
|
116
|
+
/** @type {unknown} */ (
|
|
117
|
+
globalThis.setTimeout(() => {
|
|
118
|
+
show = false;
|
|
119
|
+
}, duration)
|
|
120
|
+
)
|
|
121
|
+
);
|
|
118
122
|
}
|
|
119
123
|
});
|
|
120
124
|
</script>
|
|
@@ -2,13 +2,23 @@
|
|
|
2
2
|
* @import { ActionReturn } from 'svelte/action';
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
/** @type {boolean | undefined} */
|
|
6
|
+
let _isMac;
|
|
7
|
+
|
|
5
8
|
/**
|
|
6
9
|
* Check if the user agent is macOS.
|
|
7
10
|
* @returns {boolean} Result.
|
|
8
11
|
*/
|
|
9
|
-
export const isMac = () =>
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
export const isMac = () => {
|
|
13
|
+
_isMac ??=
|
|
14
|
+
/** @type {any} */ (navigator).userAgentData?.platform === 'macOS' ||
|
|
15
|
+
navigator.platform.startsWith('Mac');
|
|
16
|
+
|
|
17
|
+
return _isMac;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const MODIFIER_KEYS = ['Ctrl', 'Meta', 'Alt', 'Shift'];
|
|
21
|
+
const CODE_RE = /^(?:Digit|Key)(.)$/;
|
|
12
22
|
|
|
13
23
|
/**
|
|
14
24
|
* Whether the event matches the given keyboard shortcuts.
|
|
@@ -19,7 +29,13 @@ export const isMac = () =>
|
|
|
19
29
|
*/
|
|
20
30
|
export const matchShortcuts = (event, shortcuts) => {
|
|
21
31
|
const { ctrlKey, metaKey, altKey, shiftKey, code } = event;
|
|
22
|
-
|
|
32
|
+
|
|
33
|
+
// The `code` property can be `undefined` in some cases
|
|
34
|
+
if (!code) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const key = code.replace(CODE_RE, '$1');
|
|
23
39
|
|
|
24
40
|
return shortcuts.split(/\s+/).some((shortcut) => {
|
|
25
41
|
const keys = shortcut.split('+');
|
|
@@ -45,7 +61,7 @@ export const matchShortcuts = (event, shortcuts) => {
|
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
return keys
|
|
48
|
-
.filter((_key) => !
|
|
64
|
+
.filter((_key) => !MODIFIER_KEYS.includes(_key))
|
|
49
65
|
.every((_key) => _key.toUpperCase() === key.toUpperCase());
|
|
50
66
|
});
|
|
51
67
|
};
|
|
@@ -61,6 +77,12 @@ export const matchShortcuts = (event, shortcuts) => {
|
|
|
61
77
|
export const activateKeyShortcuts = (element, shortcuts = '') => {
|
|
62
78
|
/** @type {string | undefined} */
|
|
63
79
|
let platformKeyShortcuts;
|
|
80
|
+
/**
|
|
81
|
+
* Pre-parsed shortcuts for fast per-event matching without string allocations.
|
|
82
|
+
* @type {{ ctrl: boolean, meta: boolean, alt: boolean, shift: boolean, nonModifierKeys: string[]
|
|
83
|
+
* }[] | undefined}
|
|
84
|
+
*/
|
|
85
|
+
let parsedShortcuts;
|
|
64
86
|
|
|
65
87
|
/**
|
|
66
88
|
* Handle the event.
|
|
@@ -68,13 +90,33 @@ export const activateKeyShortcuts = (element, shortcuts = '') => {
|
|
|
68
90
|
*/
|
|
69
91
|
const handler = (event) => {
|
|
70
92
|
const { disabled, offsetParent } = element;
|
|
71
|
-
const { top, left } = element.getBoundingClientRect();
|
|
72
93
|
|
|
73
|
-
// Check
|
|
74
|
-
if (
|
|
94
|
+
// Check shortcut match and visibility first — no layout reflow until needed
|
|
95
|
+
if (
|
|
96
|
+
!offsetParent ||
|
|
97
|
+
!parsedShortcuts?.some(({ ctrl, meta, alt, shift, nonModifierKeys }) => {
|
|
98
|
+
const { ctrlKey, metaKey, altKey, shiftKey, code } = event;
|
|
99
|
+
|
|
100
|
+
if (!code) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const key = code.replace(CODE_RE, '$1').toUpperCase();
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
ctrl === ctrlKey &&
|
|
108
|
+
meta === metaKey &&
|
|
109
|
+
alt === altKey &&
|
|
110
|
+
shift === shiftKey &&
|
|
111
|
+
nonModifierKeys.every((k) => k === key)
|
|
112
|
+
);
|
|
113
|
+
})
|
|
114
|
+
) {
|
|
75
115
|
return;
|
|
76
116
|
}
|
|
77
117
|
|
|
118
|
+
const { top, left } = element.getBoundingClientRect();
|
|
119
|
+
|
|
78
120
|
if (disabled) {
|
|
79
121
|
// Make sure `elementsFromPoint()` works as expected
|
|
80
122
|
element.style.setProperty('pointer-events', 'auto');
|
|
@@ -113,8 +155,24 @@ export const activateKeyShortcuts = (element, shortcuts = '') => {
|
|
|
113
155
|
: undefined;
|
|
114
156
|
|
|
115
157
|
if (platformKeyShortcuts) {
|
|
158
|
+
parsedShortcuts = platformKeyShortcuts.split(/\s+/).map((shortcut) => {
|
|
159
|
+
const parts = shortcut.split('+');
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
ctrl: parts.includes('Ctrl'),
|
|
163
|
+
meta: parts.includes('Meta'),
|
|
164
|
+
alt: parts.includes('Alt'),
|
|
165
|
+
shift: parts.includes('Shift'),
|
|
166
|
+
nonModifierKeys: parts
|
|
167
|
+
.filter((k) => !MODIFIER_KEYS.includes(k))
|
|
168
|
+
.map((k) => k.toUpperCase()),
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
|
|
116
172
|
globalThis.addEventListener('keydown', handler, { capture: true });
|
|
117
173
|
element.setAttribute('aria-keyshortcuts', platformKeyShortcuts);
|
|
174
|
+
} else {
|
|
175
|
+
parsedShortcuts = undefined;
|
|
118
176
|
}
|
|
119
177
|
};
|
|
120
178
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { activateKeyShortcuts, isMac, matchShortcuts } from './events.svelte.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Helper to create a minimal KeyboardEvent-like object.
|
|
6
|
+
* @param {Partial<KeyboardEvent>} overrides Event property overrides.
|
|
7
|
+
* @returns {KeyboardEvent} A fake keyboard event.
|
|
8
|
+
*/
|
|
9
|
+
const makeEvent = (overrides = {}) =>
|
|
10
|
+
/** @type {KeyboardEvent} */ ({
|
|
11
|
+
ctrlKey: false,
|
|
12
|
+
metaKey: false,
|
|
13
|
+
altKey: false,
|
|
14
|
+
shiftKey: false,
|
|
15
|
+
code: 'KeyA',
|
|
16
|
+
...overrides,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('matchShortcuts', () => {
|
|
20
|
+
it('should match a plain key shortcut', () => {
|
|
21
|
+
expect(matchShortcuts(makeEvent({ code: 'KeyS' }), 'S')).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should not match when the key differs', () => {
|
|
25
|
+
expect(matchShortcuts(makeEvent({ code: 'KeyA' }), 'S')).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should match Ctrl+S', () => {
|
|
29
|
+
expect(matchShortcuts(makeEvent({ code: 'KeyS', ctrlKey: true }), 'Ctrl+S')).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should not match Ctrl+S when Ctrl is not pressed', () => {
|
|
33
|
+
expect(matchShortcuts(makeEvent({ code: 'KeyS' }), 'Ctrl+S')).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should not match Ctrl+S when an extra modifier is pressed', () => {
|
|
37
|
+
expect(
|
|
38
|
+
matchShortcuts(makeEvent({ code: 'KeyS', ctrlKey: true, shiftKey: true }), 'Ctrl+S'),
|
|
39
|
+
).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should match Shift+A', () => {
|
|
43
|
+
expect(matchShortcuts(makeEvent({ code: 'KeyA', shiftKey: true }), 'Shift+A')).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should match Alt+F4', () => {
|
|
47
|
+
expect(matchShortcuts(makeEvent({ code: 'KeyF', altKey: true }), 'Alt+F')).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should match any of multiple space-separated shortcuts', () => {
|
|
51
|
+
expect(matchShortcuts(makeEvent({ code: 'KeyZ', ctrlKey: true }), 'Ctrl+Z Ctrl+Y')).toBe(true);
|
|
52
|
+
expect(matchShortcuts(makeEvent({ code: 'KeyY', ctrlKey: true }), 'Ctrl+Z Ctrl+Y')).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should return false when code is empty', () => {
|
|
56
|
+
expect(matchShortcuts(makeEvent({ code: '' }), 'S')).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should match digit keys using Digit prefix', () => {
|
|
60
|
+
expect(matchShortcuts(makeEvent({ code: 'Digit1' }), '1')).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should be case-insensitive for the key', () => {
|
|
64
|
+
expect(matchShortcuts(makeEvent({ code: 'KeyS' }), 's')).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('isMac', () => {
|
|
69
|
+
it('should return a boolean', () => {
|
|
70
|
+
expect(typeof isMac()).toBe('boolean');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('activateKeyShortcuts', () => {
|
|
75
|
+
/** @type {HTMLButtonElement} */
|
|
76
|
+
let button;
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
button = /** @type {HTMLButtonElement} */ (document.createElement('button'));
|
|
80
|
+
document.body.appendChild(button);
|
|
81
|
+
|
|
82
|
+
// happy-dom doesn't expose document.elementsFromPoint as a configurable property;
|
|
83
|
+
// define a stub so vi.spyOn can wrap it in handler tests.
|
|
84
|
+
if (!document.elementsFromPoint) {
|
|
85
|
+
Object.defineProperty(document, 'elementsFromPoint', {
|
|
86
|
+
configurable: true,
|
|
87
|
+
writable: true,
|
|
88
|
+
// eslint-disable-next-line jsdoc/require-jsdoc
|
|
89
|
+
value: () => /** @type {Element[]} */ ([]),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
button.remove();
|
|
96
|
+
vi.restoreAllMocks();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should set aria-keyshortcuts when shortcuts are provided', () => {
|
|
100
|
+
const action = activateKeyShortcuts(button, 'Ctrl+S');
|
|
101
|
+
|
|
102
|
+
expect(button.getAttribute('aria-keyshortcuts')).toBe('Ctrl+S');
|
|
103
|
+
action.destroy?.();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should not set aria-keyshortcuts when no shortcuts are provided', () => {
|
|
107
|
+
const action = activateKeyShortcuts(button);
|
|
108
|
+
|
|
109
|
+
expect(button.getAttribute('aria-keyshortcuts')).toBeNull();
|
|
110
|
+
action.destroy?.();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should remove aria-keyshortcuts after destroy', () => {
|
|
114
|
+
const action = activateKeyShortcuts(button, 'Ctrl+S');
|
|
115
|
+
|
|
116
|
+
action.destroy?.();
|
|
117
|
+
expect(button.getAttribute('aria-keyshortcuts')).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should replace Accel with Meta or Ctrl depending on platform', () => {
|
|
121
|
+
const action = activateKeyShortcuts(button, 'Accel+S');
|
|
122
|
+
const attr = button.getAttribute('aria-keyshortcuts');
|
|
123
|
+
|
|
124
|
+
expect(attr === 'Meta+S' || attr === 'Ctrl+S').toBe(true);
|
|
125
|
+
action.destroy?.();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should re-register the original shortcuts when update() is called', () => {
|
|
129
|
+
// update() re-applies the same original shortcuts (param is intentionally ignored)
|
|
130
|
+
const action = activateKeyShortcuts(button, 'Ctrl+S');
|
|
131
|
+
|
|
132
|
+
/** @type {any} */ (action).update('Ctrl+Z');
|
|
133
|
+
expect(button.getAttribute('aria-keyshortcuts')).toBe('Ctrl+S');
|
|
134
|
+
action.destroy?.();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should keep no aria-keyshortcuts when update() is called on a no-shortcut action', () => {
|
|
138
|
+
const action = activateKeyShortcuts(button);
|
|
139
|
+
|
|
140
|
+
/** @type {any} */ (action).update('Ctrl+Z'); // original shortcuts was '' so still none
|
|
141
|
+
expect(button.getAttribute('aria-keyshortcuts')).toBeNull();
|
|
142
|
+
action.destroy?.();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should trigger click on element when matching shortcut key is pressed', () => {
|
|
146
|
+
// eslint-disable-next-line jsdoc/require-jsdoc
|
|
147
|
+
Object.defineProperty(button, 'offsetParent', { configurable: true, get: () => document.body });
|
|
148
|
+
vi.spyOn(document, 'elementsFromPoint').mockReturnValue(/** @type {any} */ ([button]));
|
|
149
|
+
|
|
150
|
+
const clickSpy = vi.fn();
|
|
151
|
+
|
|
152
|
+
button.addEventListener('click', clickSpy);
|
|
153
|
+
activateKeyShortcuts(button, 'Ctrl+S');
|
|
154
|
+
globalThis.dispatchEvent(
|
|
155
|
+
new KeyboardEvent('keydown', { code: 'KeyS', ctrlKey: true, bubbles: true }),
|
|
156
|
+
);
|
|
157
|
+
expect(clickSpy).toHaveBeenCalledOnce();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should not trigger click when a non-matching key is pressed', () => {
|
|
161
|
+
// Handler returns early before reaching elementsFromPoint when shortcut doesn't match
|
|
162
|
+
const clickSpy = vi.fn();
|
|
163
|
+
|
|
164
|
+
button.addEventListener('click', clickSpy);
|
|
165
|
+
activateKeyShortcuts(button, 'Ctrl+S');
|
|
166
|
+
globalThis.dispatchEvent(
|
|
167
|
+
new KeyboardEvent('keydown', { code: 'KeyZ', ctrlKey: true, bubbles: true }),
|
|
168
|
+
);
|
|
169
|
+
expect(clickSpy).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should not trigger click when element is not in elementsFromPoint result', () => {
|
|
173
|
+
// eslint-disable-next-line jsdoc/require-jsdoc
|
|
174
|
+
Object.defineProperty(button, 'offsetParent', { configurable: true, get: () => document.body });
|
|
175
|
+
vi.spyOn(document, 'elementsFromPoint').mockReturnValue(/** @type {any} */ ([]));
|
|
176
|
+
|
|
177
|
+
const clickSpy = vi.fn();
|
|
178
|
+
|
|
179
|
+
button.addEventListener('click', clickSpy);
|
|
180
|
+
activateKeyShortcuts(button, 'Ctrl+S');
|
|
181
|
+
globalThis.dispatchEvent(
|
|
182
|
+
new KeyboardEvent('keydown', { code: 'KeyS', ctrlKey: true, bubbles: true }),
|
|
183
|
+
);
|
|
184
|
+
expect(clickSpy).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should not trigger click when the event code is empty (covers inner return false branch)', () => {
|
|
188
|
+
// eslint-disable-next-line jsdoc/require-jsdoc
|
|
189
|
+
Object.defineProperty(button, 'offsetParent', { configurable: true, get: () => document.body });
|
|
190
|
+
activateKeyShortcuts(button, 'Ctrl+S');
|
|
191
|
+
|
|
192
|
+
const clickSpy = vi.fn();
|
|
193
|
+
|
|
194
|
+
button.addEventListener('click', clickSpy);
|
|
195
|
+
// Dispatch with empty code — the parsedShortcuts.some() callback returns false (line 101)
|
|
196
|
+
globalThis.dispatchEvent(
|
|
197
|
+
new KeyboardEvent('keydown', { code: '', ctrlKey: true, bubbles: true }),
|
|
198
|
+
);
|
|
199
|
+
expect(clickSpy).not.toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should manipulate pointer-events for disabled button but not trigger click', () => {
|
|
203
|
+
// eslint-disable-next-line jsdoc/require-jsdoc
|
|
204
|
+
Object.defineProperty(button, 'offsetParent', { configurable: true, get: () => document.body });
|
|
205
|
+
vi.spyOn(document, 'elementsFromPoint').mockReturnValue(/** @type {any} */ ([button]));
|
|
206
|
+
button.disabled = true;
|
|
207
|
+
|
|
208
|
+
const clickSpy = vi.fn();
|
|
209
|
+
const setPropertySpy = vi.spyOn(button.style, 'setProperty');
|
|
210
|
+
const removePropertySpy = vi.spyOn(button.style, 'removeProperty');
|
|
211
|
+
|
|
212
|
+
button.addEventListener('click', clickSpy);
|
|
213
|
+
activateKeyShortcuts(button, 'Ctrl+S');
|
|
214
|
+
globalThis.dispatchEvent(
|
|
215
|
+
new KeyboardEvent('keydown', { code: 'KeyS', ctrlKey: true, bubbles: true }),
|
|
216
|
+
);
|
|
217
|
+
expect(clickSpy).not.toHaveBeenCalled();
|
|
218
|
+
expect(setPropertySpy).toHaveBeenCalledWith('pointer-events', 'auto');
|
|
219
|
+
expect(removePropertySpy).toHaveBeenCalledWith('pointer-events');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -8,24 +8,28 @@ import { get } from 'svelte/store';
|
|
|
8
8
|
import { isRTL } from './i18n.js';
|
|
9
9
|
import { getSelectedItemDetail } from './select.svelte.js';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Diacritic characters regex for normalization. We use a regex instead of `Intl` APIs for better
|
|
13
|
+
* performance, since `transliterate` is slow and we only need basic normalization.
|
|
14
|
+
*/
|
|
15
|
+
const DIACRITIC_RE = /\p{Diacritic}/gu;
|
|
16
|
+
|
|
11
17
|
/**
|
|
12
18
|
* Normalize the given string for search value comparison. Since `transliterate` is slow, we only
|
|
13
19
|
* apply basic normalization.
|
|
20
|
+
* @internal
|
|
14
21
|
* @param {string} value Original value.
|
|
15
22
|
* @returns {string} Normalized value.
|
|
16
23
|
* @todo Move this to `@sveltia/utils`.
|
|
17
24
|
*/
|
|
18
|
-
const normalize = (value) => {
|
|
25
|
+
export const normalize = (value) => {
|
|
19
26
|
value = value.trim();
|
|
20
27
|
|
|
21
28
|
if (!value) {
|
|
22
29
|
return '';
|
|
23
30
|
}
|
|
24
31
|
|
|
25
|
-
return value
|
|
26
|
-
.normalize('NFD')
|
|
27
|
-
.replace(/\p{Diacritic}/gu, '')
|
|
28
|
-
.toLocaleLowerCase();
|
|
32
|
+
return value.normalize('NFD').replace(DIACRITIC_RE, '').toLocaleLowerCase();
|
|
29
33
|
};
|
|
30
34
|
|
|
31
35
|
/**
|
|
@@ -472,8 +476,8 @@ class Group {
|
|
|
472
476
|
newTarget = activeMembers[index - 1];
|
|
473
477
|
}
|
|
474
478
|
|
|
475
|
-
if (index
|
|
476
|
-
// Last member
|
|
479
|
+
if (index <= 0) {
|
|
480
|
+
// Last member (also handles the case when nothing is focused, index === -1)
|
|
477
481
|
newTarget = activeMembers[activeMembers.length - 1];
|
|
478
482
|
}
|
|
479
483
|
}
|
|
@@ -503,8 +507,9 @@ class Group {
|
|
|
503
507
|
onUpdate({ searchTerms }) {
|
|
504
508
|
const terms = normalize(searchTerms);
|
|
505
509
|
const _terms = terms ? terms.split(/\s+/) : [];
|
|
510
|
+
const { allMembers, parent } = this;
|
|
506
511
|
|
|
507
|
-
const matched =
|
|
512
|
+
const matched = allMembers
|
|
508
513
|
.map((member) => {
|
|
509
514
|
const searchValue = normalize(
|
|
510
515
|
member.dataset.searchValue ??
|
|
@@ -522,8 +527,8 @@ class Group {
|
|
|
522
527
|
})
|
|
523
528
|
.filter((hidden) => !hidden).length;
|
|
524
529
|
|
|
525
|
-
|
|
526
|
-
new CustomEvent('Filter', { detail: { matched, total:
|
|
530
|
+
parent.dispatchEvent(
|
|
531
|
+
new CustomEvent('Filter', { detail: { matched, total: allMembers.length } }),
|
|
527
532
|
);
|
|
528
533
|
}
|
|
529
534
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|