@sveltia/ui 0.35.2 → 0.35.4
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 +1 -1
- package/dist/components/drawer/drawer.svelte.d.ts +2 -2
- package/dist/components/text-editor/core.js +20 -19
- package/dist/components/text-editor/store.svelte.test.js +33 -0
- package/dist/components/text-editor/toolbar/insert-link-button.svelte +1 -1
- package/dist/components/text-editor/toolbar/toolbar-wrapper.svelte +3 -1
- package/dist/components/text-field/text-area.svelte +5 -0
- package/dist/components/text-field/text-input.svelte +1 -1
- package/dist/services/group.svelte.js +1 -2
- package/dist/services/group.test.js +381 -0
- package/dist/services/popup.test.js +111 -2
- package/dist/typedefs.d.ts +1 -1
- package/package.json +18 -17
- package/dist/services/events.svelte.d.ts +0 -4
- package/dist/services/events.svelte.js +0 -193
- package/dist/services/events.test.d.ts +0 -1
- package/dist/services/events.test.js +0 -221
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
@see https://www.w3.org/WAI/ARIA/apg/patterns/button/
|
|
6
6
|
-->
|
|
7
7
|
<script>
|
|
8
|
-
import { activateKeyShortcuts } from '
|
|
8
|
+
import { activateKeyShortcuts } from '@sveltia/utils/events';
|
|
9
9
|
import TruncatedText from '../typography/truncated-text.svelte';
|
|
10
10
|
import Popup from '../util/popup.svelte';
|
|
11
11
|
|
|
@@ -24,7 +24,7 @@ declare const Drawer: import("svelte").Component<ModalProps & {
|
|
|
24
24
|
/**
|
|
25
25
|
* Position of the drawer.
|
|
26
26
|
*/
|
|
27
|
-
position?: "top" | "
|
|
27
|
+
position?: "top" | "right" | "bottom" | "left" | undefined;
|
|
28
28
|
/**
|
|
29
29
|
* Width or height of the
|
|
30
30
|
* drawer.
|
|
@@ -75,7 +75,7 @@ type Props = {
|
|
|
75
75
|
/**
|
|
76
76
|
* Position of the drawer.
|
|
77
77
|
*/
|
|
78
|
-
position?: "top" | "
|
|
78
|
+
position?: "top" | "right" | "bottom" | "left" | undefined;
|
|
79
79
|
/**
|
|
80
80
|
* Width or height of the
|
|
81
81
|
* drawer.
|
|
@@ -4,9 +4,8 @@ import {
|
|
|
4
4
|
$isCodeNode,
|
|
5
5
|
CodeHighlightNode,
|
|
6
6
|
CodeNode,
|
|
7
|
-
PrismTokenizer,
|
|
8
|
-
registerCodeHighlighting,
|
|
9
7
|
} from '@lexical/code';
|
|
8
|
+
import { PrismTokenizer, registerCodeHighlighting } from '@lexical/code-prism';
|
|
10
9
|
import { registerDragonSupport } from '@lexical/dragon';
|
|
11
10
|
import { HorizontalRuleNode } from '@lexical/extension';
|
|
12
11
|
import { createEmptyHistoryState, registerHistory } from '@lexical/history';
|
|
@@ -301,33 +300,35 @@ export const initEditor = ({
|
|
|
301
300
|
COMMAND_PRIORITY_NORMAL,
|
|
302
301
|
);
|
|
303
302
|
|
|
304
|
-
editor.registerUpdateListener(
|
|
303
|
+
editor.registerUpdateListener(() => {
|
|
305
304
|
if (editor?.isComposing()) {
|
|
306
305
|
return;
|
|
307
306
|
}
|
|
308
307
|
|
|
309
|
-
|
|
308
|
+
(async () => {
|
|
309
|
+
await sleep(100);
|
|
310
310
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
311
|
+
editor.update(() => {
|
|
312
|
+
// Prevent CodeNode from being removed
|
|
313
|
+
if (isCodeEditor) {
|
|
314
|
+
const root = $getRoot();
|
|
315
|
+
const children = root.getChildren();
|
|
316
316
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
317
|
+
if (children.length === 1 && !$isCodeNode(children[0])) {
|
|
318
|
+
children[0].remove();
|
|
319
|
+
}
|
|
320
320
|
|
|
321
|
-
|
|
322
|
-
|
|
321
|
+
if (children.length === 0) {
|
|
322
|
+
const node = $createCodeNode();
|
|
323
323
|
|
|
324
|
-
|
|
325
|
-
|
|
324
|
+
node.setLanguage(defaultLanguage);
|
|
325
|
+
root.append(node);
|
|
326
|
+
}
|
|
326
327
|
}
|
|
327
|
-
}
|
|
328
328
|
|
|
329
|
-
|
|
330
|
-
|
|
329
|
+
onEditorUpdate(editor);
|
|
330
|
+
});
|
|
331
|
+
})();
|
|
331
332
|
});
|
|
332
333
|
|
|
333
334
|
// `editor.registerCommand(KEY_TAB_COMMAND, listener, priority)` doesn’t work for some reason, so
|
|
@@ -193,4 +193,37 @@ describe('createEditorStore', () => {
|
|
|
193
193
|
expect(store.hasConverterError).toBe(true);
|
|
194
194
|
consoleSpy.mockRestore();
|
|
195
195
|
});
|
|
196
|
+
|
|
197
|
+
it('should pass empty string fallback to convertMarkdownToLexical when inputValue is empty (branch 2)', async () => {
|
|
198
|
+
const store = createEditorStore();
|
|
199
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
200
|
+
const mockEditor = /** @type {any} */ ({ getEditorState: () => ({ isEmpty: () => false }) });
|
|
201
|
+
|
|
202
|
+
store.editor = mockEditor;
|
|
203
|
+
store.initialized = true;
|
|
204
|
+
store.inputValue = 'hello'; // non-empty: inputValue || '' uses inputValue (count[0])
|
|
205
|
+
store.inputValue = ''; // empty: inputValue || '' uses '' fallback (count[1])
|
|
206
|
+
await new Promise((r) => {
|
|
207
|
+
setTimeout(r, 0);
|
|
208
|
+
});
|
|
209
|
+
expect(store.inputValue).toBe('');
|
|
210
|
+
consoleSpy.mockRestore();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should trigger convertMarkdown when isEmpty() is true even though value is unchanged (branch 6)', async () => {
|
|
214
|
+
const store = createEditorStore();
|
|
215
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
216
|
+
// isEmpty() returns true → triggers convertMarkdown even when hasChange = false
|
|
217
|
+
const mockEditor = /** @type {any} */ ({ getEditorState: () => ({ isEmpty: () => true }) });
|
|
218
|
+
|
|
219
|
+
store.editor = mockEditor;
|
|
220
|
+
store.initialized = true;
|
|
221
|
+
store.inputValue = 'hello'; // hasChange=true → count[1] of binary-expr at line 88
|
|
222
|
+
store.inputValue = 'hello'; // hasChange=false, isEmpty()=true → count[2] is hit
|
|
223
|
+
await new Promise((r) => {
|
|
224
|
+
setTimeout(r, 0);
|
|
225
|
+
});
|
|
226
|
+
expect(store.inputValue).toBe('hello');
|
|
227
|
+
consoleSpy.mockRestore();
|
|
228
|
+
});
|
|
196
229
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import { LinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
|
|
3
3
|
import { $getNearestNodeOfType as getNearestNodeOfType } from '@lexical/utils';
|
|
4
|
+
import { isMac, matchShortcuts } from '@sveltia/utils/events';
|
|
4
5
|
import {
|
|
5
6
|
COMMAND_PRIORITY_NORMAL,
|
|
6
7
|
KEY_DOWN_COMMAND,
|
|
@@ -15,7 +16,6 @@
|
|
|
15
16
|
} from 'lexical';
|
|
16
17
|
import { getContext } from 'svelte';
|
|
17
18
|
import { _ } from 'svelte-i18n';
|
|
18
|
-
import { isMac, matchShortcuts } from '../../../services/events.svelte.js';
|
|
19
19
|
import Button from '../../button/button.svelte';
|
|
20
20
|
import Dialog from '../../dialog/dialog.svelte';
|
|
21
21
|
import Icon from '../../icon/icon.svelte';
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
top: 0;
|
|
43
43
|
z-index: 100;
|
|
44
44
|
display: flex;
|
|
45
|
+
flex-wrap: wrap;
|
|
45
46
|
gap: 4px;
|
|
46
47
|
border-width: 1px 1px 0;
|
|
47
48
|
border-style: solid;
|
|
@@ -51,7 +52,8 @@
|
|
|
51
52
|
border-end-start-radius: 0;
|
|
52
53
|
border-end-end-radius: 0;
|
|
53
54
|
padding: 0 4px;
|
|
54
|
-
height:
|
|
55
|
+
height: auto;
|
|
56
|
+
min-height: 40px;
|
|
55
57
|
background-color: var(--sui-tertiary-background-color);
|
|
56
58
|
}
|
|
57
59
|
@media (width < 768px) {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
@see https://w3c.github.io/aria/#textbox
|
|
6
6
|
-->
|
|
7
7
|
<script>
|
|
8
|
-
import { activateKeyShortcuts } from '
|
|
8
|
+
import { activateKeyShortcuts } from '@sveltia/utils/events';
|
|
9
9
|
import TruncatedText from '../typography/truncated-text.svelte';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -515,8 +515,7 @@ class Group {
|
|
|
515
515
|
member.dataset.searchValue ??
|
|
516
516
|
member.dataset.label ??
|
|
517
517
|
member.querySelector('.label')?.textContent ??
|
|
518
|
-
member.textContent
|
|
519
|
-
'',
|
|
518
|
+
/** @type {string} */ (member.textContent),
|
|
520
519
|
);
|
|
521
520
|
|
|
522
521
|
const hidden = !_terms.every((term) => searchValue.includes(term));
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/* eslint-disable jsdoc/require-jsdoc */
|
|
2
|
+
/* eslint-disable jsdoc/require-description */
|
|
2
3
|
|
|
3
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
5
|
import { activateGroup, normalize } from './group.svelte.js';
|
|
6
|
+
import { isRTL } from './i18n.js';
|
|
5
7
|
|
|
6
8
|
describe('normalize', () => {
|
|
7
9
|
it('should trim whitespace', () => {
|
|
@@ -125,6 +127,22 @@ describe('Group - tablist', () => {
|
|
|
125
127
|
expect(tabs[1].tabIndex).toBe(0);
|
|
126
128
|
expect(tabs[0].tabIndex).toBe(-1);
|
|
127
129
|
});
|
|
130
|
+
|
|
131
|
+
it('should navigate backward when pressing ArrowRight in RTL (branch 63 prevKey=ArrowRight)', () => {
|
|
132
|
+
isRTL.set(true);
|
|
133
|
+
// In RTL prevKey='ArrowRight'; press from tabs[2] → backward to tabs[1]
|
|
134
|
+
tabs[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
|
135
|
+
expect(tabs[1].getAttribute('aria-selected')).toBe('true');
|
|
136
|
+
isRTL.set(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should navigate forward when pressing ArrowLeft in RTL (branch 65 nextKey=ArrowLeft)', () => {
|
|
140
|
+
isRTL.set(true);
|
|
141
|
+
// In RTL nextKey='ArrowLeft'; press from tabs[0] → forward to tabs[1]
|
|
142
|
+
tabs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
|
143
|
+
expect(tabs[1].getAttribute('aria-selected')).toBe('true');
|
|
144
|
+
isRTL.set(false);
|
|
145
|
+
});
|
|
128
146
|
});
|
|
129
147
|
|
|
130
148
|
describe('Group - listbox', () => {
|
|
@@ -167,6 +185,26 @@ describe('Group - listbox', () => {
|
|
|
167
185
|
expect(opt.tabIndex).toBe(-1);
|
|
168
186
|
});
|
|
169
187
|
});
|
|
188
|
+
|
|
189
|
+
it('should select option when clicking on a child element inside it (target.closest branch 38)', () => {
|
|
190
|
+
const span = document.createElement('span');
|
|
191
|
+
|
|
192
|
+
span.textContent = 'Inner';
|
|
193
|
+
options[0].appendChild(span);
|
|
194
|
+
span.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true }));
|
|
195
|
+
expect(options[0].getAttribute('aria-selected')).toBe('true');
|
|
196
|
+
span.remove();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should not select option when right-clicked (button !== 0, branch 39 early return)', () => {
|
|
200
|
+
options[0].dispatchEvent(new MouseEvent('click', { button: 2, bubbles: true }));
|
|
201
|
+
expect(options[0].getAttribute('aria-selected')).toBe('false');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should not select when clicking the listbox container itself (!newTarget, branch 39)', () => {
|
|
205
|
+
listbox.dispatchEvent(new MouseEvent('click', { button: 0 }));
|
|
206
|
+
options.forEach((opt) => expect(opt.getAttribute('aria-selected')).toBe('false'));
|
|
207
|
+
});
|
|
170
208
|
});
|
|
171
209
|
|
|
172
210
|
describe('Group - onUpdate (search filter)', () => {
|
|
@@ -457,6 +495,22 @@ describe('Group - multiselect listbox', () => {
|
|
|
457
495
|
expect(options[1].getAttribute('aria-selected')).toBe('false');
|
|
458
496
|
expect(options[2].getAttribute('aria-selected')).toBe('true');
|
|
459
497
|
});
|
|
498
|
+
|
|
499
|
+
it('should do nothing when Space pressed with no focused element (branch 47 false path)', () => {
|
|
500
|
+
// No navigation has occurred → no .focused element → currentTarget = undefined
|
|
501
|
+
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
|
|
502
|
+
options.forEach((opt) => expect(opt.getAttribute('aria-selected')).toBe('false'));
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('should select multiselect option via Space keydown (selectByKeydown, branch 22 count[3])', () => {
|
|
506
|
+
// ArrowDown gives options[0] the .focused class without selecting (multiselect ignores arrow)
|
|
507
|
+
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
508
|
+
expect(options[0].classList.contains('focused')).toBe(true);
|
|
509
|
+
expect(options[0].getAttribute('aria-selected')).toBe('false');
|
|
510
|
+
// Space selects via multiSelect && isTarget && selectByKeydown path
|
|
511
|
+
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
|
|
512
|
+
expect(options[0].getAttribute('aria-selected')).toBe('true');
|
|
513
|
+
});
|
|
460
514
|
});
|
|
461
515
|
|
|
462
516
|
describe('Group - listbox keyboard navigation', () => {
|
|
@@ -760,4 +814,331 @@ describe('Group - grid listbox navigation', () => {
|
|
|
760
814
|
expect(options[0].getAttribute('aria-selected')).toBe('true');
|
|
761
815
|
expect(options[3].getAttribute('aria-selected')).not.toBe('true');
|
|
762
816
|
});
|
|
817
|
+
|
|
818
|
+
it('should navigate grid forward (index+1) on ArrowLeft in RTL (branch 56)', () => {
|
|
819
|
+
// Navigate to options[1] first
|
|
820
|
+
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
|
821
|
+
expect(options[1].getAttribute('aria-selected')).toBe('true');
|
|
822
|
+
// In RTL, ArrowLeft moves forward: index 1 + 1 = options[2]
|
|
823
|
+
isRTL.set(true);
|
|
824
|
+
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
|
825
|
+
expect(options[2].getAttribute('aria-selected')).toBe('true');
|
|
826
|
+
isRTL.set(false);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it('should navigate grid backward (index-1) on ArrowRight in RTL (branch 59)', () => {
|
|
830
|
+
// Navigate to options[1]
|
|
831
|
+
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
|
832
|
+
expect(options[1].getAttribute('aria-selected')).toBe('true');
|
|
833
|
+
// In RTL, ArrowRight moves backward: index 1 - 1 = options[0]
|
|
834
|
+
isRTL.set(true);
|
|
835
|
+
listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
|
836
|
+
expect(options[0].getAttribute('aria-selected')).toBe('true');
|
|
837
|
+
isRTL.set(false);
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
describe('Group - scrollIntoView catch fallback in activate()', () => {
|
|
842
|
+
/** @type {HTMLElement} */
|
|
843
|
+
let tablist;
|
|
844
|
+
/** @type {HTMLElement[]} */
|
|
845
|
+
let panels;
|
|
846
|
+
|
|
847
|
+
beforeEach(async () => {
|
|
848
|
+
vi.useFakeTimers();
|
|
849
|
+
panels = ['panel-catch-a', 'panel-catch-b'].map((id) => {
|
|
850
|
+
const panel = document.createElement('div');
|
|
851
|
+
|
|
852
|
+
panel.id = id;
|
|
853
|
+
// Make scrollIntoView(options) throw but scrollIntoView(boolean) succeed
|
|
854
|
+
|
|
855
|
+
panel.scrollIntoView = (arg) => {
|
|
856
|
+
if (arg && typeof arg === 'object') {
|
|
857
|
+
throw new Error('not supported');
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
document.body.appendChild(panel);
|
|
862
|
+
|
|
863
|
+
return panel;
|
|
864
|
+
});
|
|
865
|
+
tablist = document.createElement('div');
|
|
866
|
+
tablist.setAttribute('role', 'tablist');
|
|
867
|
+
panels.forEach((panel, i) => {
|
|
868
|
+
const tab = document.createElement('div');
|
|
869
|
+
|
|
870
|
+
tab.setAttribute('role', 'tab');
|
|
871
|
+
tab.setAttribute('aria-controls', panel.id);
|
|
872
|
+
tab.textContent = `Tab ${i + 1}`;
|
|
873
|
+
tablist.appendChild(tab);
|
|
874
|
+
});
|
|
875
|
+
document.body.appendChild(tablist);
|
|
876
|
+
activateGroup(tablist);
|
|
877
|
+
// flush sleep(100) + setTimeout(300) in activate()
|
|
878
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
afterEach(() => {
|
|
882
|
+
tablist.remove();
|
|
883
|
+
panels.forEach((p) => p.remove());
|
|
884
|
+
vi.useRealTimers();
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it('should fall back to scrollIntoView(true) when options form throws during activate()', () => {
|
|
888
|
+
// If the catch branch executed without throwing, the panel is set up correctly
|
|
889
|
+
expect(panels[0].getAttribute('aria-hidden')).toBe('false');
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
describe('Group - scrollIntoView catch fallback in selectTarget()', () => {
|
|
894
|
+
/** @type {HTMLElement} */
|
|
895
|
+
let tablist;
|
|
896
|
+
/** @type {HTMLElement[]} */
|
|
897
|
+
let tabs;
|
|
898
|
+
/** @type {HTMLElement[]} */
|
|
899
|
+
let panels;
|
|
900
|
+
|
|
901
|
+
beforeEach(async () => {
|
|
902
|
+
vi.useFakeTimers();
|
|
903
|
+
panels = ['panel-sel-a', 'panel-sel-b'].map((id) => {
|
|
904
|
+
const panel = document.createElement('div');
|
|
905
|
+
|
|
906
|
+
panel.id = id;
|
|
907
|
+
|
|
908
|
+
panel.scrollIntoView = (arg) => {
|
|
909
|
+
if (arg && typeof arg === 'object') {
|
|
910
|
+
throw new Error('not supported');
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
document.body.appendChild(panel);
|
|
915
|
+
|
|
916
|
+
return panel;
|
|
917
|
+
});
|
|
918
|
+
tablist = document.createElement('div');
|
|
919
|
+
tablist.setAttribute('role', 'tablist');
|
|
920
|
+
tabs = panels.map((panel, i) => {
|
|
921
|
+
const tab = document.createElement('div');
|
|
922
|
+
|
|
923
|
+
tab.setAttribute('role', 'tab');
|
|
924
|
+
tab.setAttribute('aria-controls', panel.id);
|
|
925
|
+
tab.textContent = `Tab ${i + 1}`;
|
|
926
|
+
// Also override the tab element's scrollIntoView to throw on options form
|
|
927
|
+
|
|
928
|
+
tab.scrollIntoView = (arg) => {
|
|
929
|
+
if (arg && typeof arg === 'object') {
|
|
930
|
+
throw new Error('not supported');
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
tablist.appendChild(tab);
|
|
935
|
+
|
|
936
|
+
return tab;
|
|
937
|
+
});
|
|
938
|
+
document.body.appendChild(tablist);
|
|
939
|
+
activateGroup(tablist);
|
|
940
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
afterEach(() => {
|
|
944
|
+
tablist.remove();
|
|
945
|
+
panels.forEach((p) => p.remove());
|
|
946
|
+
vi.useRealTimers();
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it('should fall back to scrollIntoView(true) on controlTarget and element when options form throws', async () => {
|
|
950
|
+
// Switch to the second tab to trigger selectTarget() catch branches
|
|
951
|
+
tabs[1].click();
|
|
952
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
953
|
+
// Verify the panel switched correctly despite the scrollIntoView error
|
|
954
|
+
expect(panels[1].getAttribute('aria-hidden')).toBe('false');
|
|
955
|
+
expect(panels[0].getAttribute('aria-hidden')).toBe('true');
|
|
956
|
+
});
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
describe('Group - tablist with pre-selected second tab (branch 7 defaultSelected)', () => {
|
|
960
|
+
it('should preserve pre-selected tab and use defaultSelected ternary path', async () => {
|
|
961
|
+
vi.useFakeTimers();
|
|
962
|
+
|
|
963
|
+
const tl = document.createElement('div');
|
|
964
|
+
|
|
965
|
+
tl.setAttribute('role', 'tablist');
|
|
966
|
+
|
|
967
|
+
const tbs = ['Tab 1', 'Tab 2', 'Tab 3'].map((label) => {
|
|
968
|
+
const tab = document.createElement('div');
|
|
969
|
+
|
|
970
|
+
tab.setAttribute('role', 'tab');
|
|
971
|
+
tab.textContent = label;
|
|
972
|
+
tl.appendChild(tab);
|
|
973
|
+
|
|
974
|
+
return tab;
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
// Pre-select the second tab before activation
|
|
978
|
+
tbs[1].setAttribute('aria-selected', 'true');
|
|
979
|
+
document.body.appendChild(tl);
|
|
980
|
+
activateGroup(tl);
|
|
981
|
+
await vi.advanceTimersByTimeAsync(150);
|
|
982
|
+
|
|
983
|
+
// defaultSelected = tbs[1]; the ternary `defaultSelected ? element === defaultSelected : ...`
|
|
984
|
+
// evaluates to true for tbs[1] → branch 7 count[0] is hit
|
|
985
|
+
expect(tbs[1].getAttribute('aria-selected')).toBe('true');
|
|
986
|
+
expect(tbs[0].getAttribute('aria-selected')).toBe('false');
|
|
987
|
+
tl.remove();
|
|
988
|
+
vi.useRealTimers();
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
describe('Group - menu with nested radio groups (branch 16 cross-group filter)', () => {
|
|
993
|
+
it('should not affect radio items in a different group when selecting one', async () => {
|
|
994
|
+
vi.useFakeTimers();
|
|
995
|
+
|
|
996
|
+
const menu = document.createElement('div');
|
|
997
|
+
|
|
998
|
+
menu.setAttribute('role', 'menu');
|
|
999
|
+
|
|
1000
|
+
const group1 = document.createElement('div');
|
|
1001
|
+
|
|
1002
|
+
group1.setAttribute('role', 'group');
|
|
1003
|
+
|
|
1004
|
+
const radioA = document.createElement('div');
|
|
1005
|
+
|
|
1006
|
+
radioA.setAttribute('role', 'menuitemradio');
|
|
1007
|
+
radioA.textContent = 'A';
|
|
1008
|
+
group1.appendChild(radioA);
|
|
1009
|
+
|
|
1010
|
+
const group2 = document.createElement('div');
|
|
1011
|
+
|
|
1012
|
+
group2.setAttribute('role', 'group');
|
|
1013
|
+
|
|
1014
|
+
const radioB = document.createElement('div');
|
|
1015
|
+
|
|
1016
|
+
radioB.setAttribute('role', 'menuitemradio');
|
|
1017
|
+
radioB.textContent = 'B';
|
|
1018
|
+
group2.appendChild(radioB);
|
|
1019
|
+
|
|
1020
|
+
menu.appendChild(group1);
|
|
1021
|
+
menu.appendChild(group2);
|
|
1022
|
+
document.body.appendChild(menu);
|
|
1023
|
+
activateGroup(menu);
|
|
1024
|
+
await vi.advanceTimersByTimeAsync(150);
|
|
1025
|
+
|
|
1026
|
+
// Click radioA — radioB is in a different group, so line 267 early return filters it out
|
|
1027
|
+
radioA.click();
|
|
1028
|
+
expect(radioA.getAttribute('aria-checked')).toBe('true');
|
|
1029
|
+
// radioB was skipped (early return at line 267) — remains unaffected
|
|
1030
|
+
expect(radioB.getAttribute('aria-checked')).toBe('false');
|
|
1031
|
+
menu.remove();
|
|
1032
|
+
vi.useRealTimers();
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
describe('Group - onClick with clickToSelect disabled (branch 39 !clickToSelect)', () => {
|
|
1037
|
+
it('should not select option when clickToSelect is false', async () => {
|
|
1038
|
+
vi.useFakeTimers();
|
|
1039
|
+
|
|
1040
|
+
const lb = document.createElement('div');
|
|
1041
|
+
|
|
1042
|
+
lb.setAttribute('role', 'listbox');
|
|
1043
|
+
|
|
1044
|
+
const opt = document.createElement('div');
|
|
1045
|
+
|
|
1046
|
+
opt.setAttribute('role', 'option');
|
|
1047
|
+
opt.textContent = 'Option';
|
|
1048
|
+
lb.appendChild(opt);
|
|
1049
|
+
document.body.appendChild(lb);
|
|
1050
|
+
activateGroup(lb, { clickToSelect: false });
|
|
1051
|
+
await vi.advanceTimersByTimeAsync(150);
|
|
1052
|
+
opt.click();
|
|
1053
|
+
// !clickToSelect → early return in onClick → aria-selected stays false
|
|
1054
|
+
expect(opt.getAttribute('aria-selected')).toBe('false');
|
|
1055
|
+
lb.remove();
|
|
1056
|
+
vi.useRealTimers();
|
|
1057
|
+
});
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
describe('Group - grid listbox with no initial focus (branch 49 currentTarget?...: -1)', () => {
|
|
1061
|
+
it('should use index -1 as fallback when no item is focused in grid', async () => {
|
|
1062
|
+
vi.useFakeTimers();
|
|
1063
|
+
|
|
1064
|
+
const gridListbox = document.createElement('div');
|
|
1065
|
+
|
|
1066
|
+
gridListbox.setAttribute('role', 'listbox');
|
|
1067
|
+
gridListbox.classList.add('grid');
|
|
1068
|
+
|
|
1069
|
+
const gridOptions = Array.from({ length: 6 }, (_, i) => {
|
|
1070
|
+
const opt = document.createElement('div');
|
|
1071
|
+
|
|
1072
|
+
opt.setAttribute('role', 'option');
|
|
1073
|
+
opt.textContent = `Item ${i + 1}`;
|
|
1074
|
+
gridListbox.appendChild(opt);
|
|
1075
|
+
|
|
1076
|
+
return opt;
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
document.body.appendChild(gridListbox);
|
|
1080
|
+
Object.defineProperty(gridListbox, 'clientWidth', { configurable: true, get: () => 300 });
|
|
1081
|
+
gridOptions.forEach((opt) => {
|
|
1082
|
+
Object.defineProperty(opt, 'clientWidth', { configurable: true, get: () => 100 });
|
|
1083
|
+
});
|
|
1084
|
+
activateGroup(gridListbox);
|
|
1085
|
+
await vi.advanceTimersByTimeAsync(150);
|
|
1086
|
+
|
|
1087
|
+
// Press ArrowDown with no focused element → currentTarget=undefined → index=-1
|
|
1088
|
+
// -1 + colCount(3) = 2 → gridOptions[2]
|
|
1089
|
+
gridListbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
1090
|
+
expect(gridOptions[2].getAttribute('aria-selected')).toBe('true');
|
|
1091
|
+
gridListbox.remove();
|
|
1092
|
+
vi.useRealTimers();
|
|
1093
|
+
});
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
describe('Group - onUpdate with querySelector(.label) and textContent fallbacks (branch 75)', () => {
|
|
1097
|
+
it('should use .label child textContent and plain textContent as search sources', async () => {
|
|
1098
|
+
vi.useFakeTimers();
|
|
1099
|
+
|
|
1100
|
+
const listbox = document.createElement('div');
|
|
1101
|
+
|
|
1102
|
+
listbox.setAttribute('role', 'listbox');
|
|
1103
|
+
|
|
1104
|
+
// opt1: has .label span child, no dataset attrs → querySelector('.label').textContent path
|
|
1105
|
+
const opt1 = document.createElement('div');
|
|
1106
|
+
|
|
1107
|
+
opt1.setAttribute('role', 'option');
|
|
1108
|
+
|
|
1109
|
+
const labelSpan = document.createElement('span');
|
|
1110
|
+
|
|
1111
|
+
labelSpan.className = 'label';
|
|
1112
|
+
labelSpan.textContent = 'Apple';
|
|
1113
|
+
opt1.appendChild(labelSpan);
|
|
1114
|
+
|
|
1115
|
+
// opt2: plain textContent only, no dataset, no .label child → member.textContent path
|
|
1116
|
+
const opt2 = document.createElement('div');
|
|
1117
|
+
|
|
1118
|
+
opt2.setAttribute('role', 'option');
|
|
1119
|
+
opt2.textContent = 'Banana';
|
|
1120
|
+
|
|
1121
|
+
listbox.appendChild(opt1);
|
|
1122
|
+
listbox.appendChild(opt2);
|
|
1123
|
+
document.body.appendChild(listbox);
|
|
1124
|
+
|
|
1125
|
+
const action = /** @type {any} */ (activateGroup(listbox, { searchTerms: '' }));
|
|
1126
|
+
|
|
1127
|
+
await vi.advanceTimersByTimeAsync(150);
|
|
1128
|
+
|
|
1129
|
+
/** @type {any} */
|
|
1130
|
+
let filterDetail = null;
|
|
1131
|
+
|
|
1132
|
+
listbox.addEventListener('Filter', (e) => {
|
|
1133
|
+
filterDetail = /** @type {any} */ (e).detail;
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
// Searching 'apple' matches opt1 (via .label span) but not opt2 (via textContent 'Banana')
|
|
1137
|
+
action.update({ searchTerms: 'apple' });
|
|
1138
|
+
expect(filterDetail.matched).toBe(1);
|
|
1139
|
+
expect(filterDetail.total).toBe(2);
|
|
1140
|
+
|
|
1141
|
+
listbox.remove();
|
|
1142
|
+
vi.useRealTimers();
|
|
1143
|
+
});
|
|
763
1144
|
});
|
|
@@ -123,6 +123,29 @@ describe('Popup', () => {
|
|
|
123
123
|
expect(get(instance.open)).toBe(false);
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
it('should not close popup when clicking a non-menuitem element inside it (branch 35 false)', () => {
|
|
127
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
128
|
+
const div = document.createElement('div');
|
|
129
|
+
|
|
130
|
+
popup.appendChild(div);
|
|
131
|
+
anchor.click(); // open
|
|
132
|
+
div.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
133
|
+
// div has no role → neither menuitem nor popup backdrop → popup stays open
|
|
134
|
+
expect(get(instance.open)).toBe(true);
|
|
135
|
+
div.remove();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should not close popup on Escape with modifier key held (branch 38 false)', () => {
|
|
139
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
140
|
+
|
|
141
|
+
anchor.click(); // open
|
|
142
|
+
popup.dispatchEvent(
|
|
143
|
+
new KeyboardEvent('keydown', { key: 'Escape', shiftKey: true, bubbles: false }),
|
|
144
|
+
);
|
|
145
|
+
// hasModifier=true → condition false → popup stays open
|
|
146
|
+
expect(get(instance.open)).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
126
149
|
it('should toggle open to true on Enter keydown on anchor', () => {
|
|
127
150
|
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
128
151
|
|
|
@@ -450,7 +473,7 @@ describe('Popup - IntersectionObserver position callback (lines 35-132)', () =>
|
|
|
450
473
|
});
|
|
451
474
|
|
|
452
475
|
it('should normalize RTL position when document.dir is rtl', () => {
|
|
453
|
-
|
|
476
|
+
Object.defineProperty(document, 'dir', { get: () => 'rtl', configurable: true });
|
|
454
477
|
|
|
455
478
|
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
456
479
|
|
|
@@ -459,7 +482,93 @@ describe('Popup - IntersectionObserver position callback (lines 35-132)', () =>
|
|
|
459
482
|
const style = get(instance.style);
|
|
460
483
|
|
|
461
484
|
expect(style.inset).not.toBeUndefined();
|
|
462
|
-
|
|
485
|
+
Reflect.deleteProperty(document, 'dir');
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('should normalize bottom-right to bottom-left in RTL (endsWith -right branch)', () => {
|
|
489
|
+
Object.defineProperty(document, 'dir', { get: () => 'rtl', configurable: true });
|
|
490
|
+
|
|
491
|
+
const instance = activatePopup(anchor, popup, 'bottom-right');
|
|
492
|
+
|
|
493
|
+
ioCallbacks[0]([makeEntry()]);
|
|
494
|
+
|
|
495
|
+
const style = get(instance.style);
|
|
496
|
+
|
|
497
|
+
// After RTL normalization bottom-right → bottom-left; inset should be computed
|
|
498
|
+
expect(style.inset).not.toBeUndefined();
|
|
499
|
+
Reflect.deleteProperty(document, 'dir');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('should normalize left-top to right-top in RTL (startsWith left- branch)', () => {
|
|
503
|
+
Object.defineProperty(document, 'dir', { get: () => 'rtl', configurable: true });
|
|
504
|
+
|
|
505
|
+
const instance = activatePopup(anchor, popup, 'left-top');
|
|
506
|
+
|
|
507
|
+
ioCallbacks[0]([makeEntry()]);
|
|
508
|
+
|
|
509
|
+
const style = get(instance.style);
|
|
510
|
+
|
|
511
|
+
// After RTL normalization left-top → right-top; inset should be computed
|
|
512
|
+
expect(style.inset).not.toBeUndefined();
|
|
513
|
+
Reflect.deleteProperty(document, 'dir');
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('should normalize right-top to left-top in RTL (startsWith right- branch)', () => {
|
|
517
|
+
Object.defineProperty(document, 'dir', { get: () => 'rtl', configurable: true });
|
|
518
|
+
|
|
519
|
+
const instance = activatePopup(anchor, popup, 'right-top');
|
|
520
|
+
|
|
521
|
+
ioCallbacks[0]([makeEntry()]);
|
|
522
|
+
|
|
523
|
+
const style = get(instance.style);
|
|
524
|
+
|
|
525
|
+
// After RTL normalization right-top → left-top; inset should be computed
|
|
526
|
+
expect(style.inset).not.toBeUndefined();
|
|
527
|
+
Reflect.deleteProperty(document, 'dir');
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('should set height to bottomMargin when content overflows bottom but top is not better', () => {
|
|
531
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
532
|
+
|
|
533
|
+
// bottomMargin = 500 - 400 - 8 = 92; topMargin = 50 - 8 = 42; topMargin < bottomMargin
|
|
534
|
+
// so the else branch runs: height = bottomMargin (92px)
|
|
535
|
+
Object.defineProperty(content, 'scrollHeight', { configurable: true, get: () => 200 });
|
|
536
|
+
ioCallbacks[0]([makeEntry({ top: 50, bottom: 400, vw: 800, vh: 500 })]);
|
|
537
|
+
|
|
538
|
+
const style = get(instance.style);
|
|
539
|
+
|
|
540
|
+
expect(style.height).toBe('92px');
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('should compute bottom from rootBounds.height - intersectionRect.bottom for -bottom position (branch 19)', () => {
|
|
544
|
+
// 'right-bottom' ends with '-bottom' → bottom = Math.round(vh - intersectionRect.bottom)
|
|
545
|
+
const instance = activatePopup(anchor, popup, 'right-bottom');
|
|
546
|
+
|
|
547
|
+
// default: top=100, bottom=150, left=50, right=300, vh=600
|
|
548
|
+
// bottom = Math.round(600 - 150) = 450
|
|
549
|
+
ioCallbacks[0]([makeEntry()]);
|
|
550
|
+
|
|
551
|
+
const style = get(instance.style);
|
|
552
|
+
|
|
553
|
+
expect(style.inset).toContain('450px');
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('should not update style when intersection callback fires with identical geometry (branch 25)', () => {
|
|
557
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
558
|
+
const entry = makeEntry();
|
|
559
|
+
|
|
560
|
+
// First call — style is updated (inset differs from initial empty object)
|
|
561
|
+
ioCallbacks[0]([entry]);
|
|
562
|
+
|
|
563
|
+
const styleBefore = get(instance.style);
|
|
564
|
+
|
|
565
|
+
// Second call with same entry — all comparisons are equal → style.set not called again
|
|
566
|
+
ioCallbacks[0]([entry]);
|
|
567
|
+
|
|
568
|
+
const styleAfter = get(instance.style);
|
|
569
|
+
|
|
570
|
+
expect(styleAfter.inset).toBe(styleBefore.inset);
|
|
571
|
+
expect(styleAfter.zIndex).toBe(styleBefore.zIndex);
|
|
463
572
|
});
|
|
464
573
|
});
|
|
465
574
|
describe('Popup - ResizeObserver callback (lines 223-224)', () => {
|
package/dist/typedefs.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export type ButtonProps = {
|
|
|
10
10
|
/**
|
|
11
11
|
* The `type` attribute on the `<button>` element.
|
|
12
12
|
*/
|
|
13
|
-
type?: "
|
|
13
|
+
type?: "button" | "submit" | "reset" | undefined;
|
|
14
14
|
/**
|
|
15
15
|
* The `role` attribute on the `<button>` element.
|
|
16
16
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sveltia/ui",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -8,19 +8,20 @@
|
|
|
8
8
|
"url": "github:sveltia/sveltia-ui"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@lexical/code": "^0.
|
|
12
|
-
"@lexical/
|
|
13
|
-
"@lexical/
|
|
14
|
-
"@lexical/
|
|
15
|
-
"@lexical/
|
|
16
|
-
"@lexical/
|
|
17
|
-
"@lexical/
|
|
18
|
-
"@lexical/
|
|
19
|
-
"@lexical/
|
|
20
|
-
"@lexical/
|
|
21
|
-
"@lexical/
|
|
22
|
-
"@
|
|
23
|
-
"
|
|
11
|
+
"@lexical/code": "^0.42.0",
|
|
12
|
+
"@lexical/code-prism": "^0.42.0",
|
|
13
|
+
"@lexical/dragon": "^0.42.0",
|
|
14
|
+
"@lexical/extension": "^0.42.0",
|
|
15
|
+
"@lexical/history": "^0.42.0",
|
|
16
|
+
"@lexical/link": "^0.42.0",
|
|
17
|
+
"@lexical/list": "^0.42.0",
|
|
18
|
+
"@lexical/markdown": "^0.42.0",
|
|
19
|
+
"@lexical/rich-text": "^0.42.0",
|
|
20
|
+
"@lexical/selection": "^0.42.0",
|
|
21
|
+
"@lexical/table": "^0.42.0",
|
|
22
|
+
"@lexical/utils": "^0.42.0",
|
|
23
|
+
"@sveltia/utils": "^0.9.1",
|
|
24
|
+
"lexical": "^0.42.0",
|
|
24
25
|
"prismjs": "^1.30.0",
|
|
25
26
|
"svelte-i18n": "^4.0.1"
|
|
26
27
|
},
|
|
@@ -47,14 +48,14 @@
|
|
|
47
48
|
"prettier": "^3.8.1",
|
|
48
49
|
"prettier-plugin-svelte": "^3.5.1",
|
|
49
50
|
"sass": "^1.98.0",
|
|
50
|
-
"stylelint": "^17.
|
|
51
|
+
"stylelint": "^17.5.0",
|
|
51
52
|
"stylelint-config-recommended-scss": "^17.0.0",
|
|
52
53
|
"stylelint-scss": "^7.0.0",
|
|
53
54
|
"svelte": "^5.54.0",
|
|
54
55
|
"svelte-check": "^4.4.5",
|
|
55
56
|
"svelte-preprocess": "^6.0.3",
|
|
56
57
|
"tslib": "^2.8.1",
|
|
57
|
-
"vite": "^8.0.
|
|
58
|
+
"vite": "^8.0.1",
|
|
58
59
|
"vitest": "^4.1.0"
|
|
59
60
|
},
|
|
60
61
|
"exports": {
|
|
@@ -94,7 +95,7 @@
|
|
|
94
95
|
"check:oxlint": "oxlint .",
|
|
95
96
|
"check:stylelint": "stylelint '**/*.{css,scss,svelte}'",
|
|
96
97
|
"test": "vitest run",
|
|
97
|
-
"test:coverage": "vitest --coverage",
|
|
98
|
+
"test:coverage": "vitest run --coverage",
|
|
98
99
|
"test:watch": "vitest"
|
|
99
100
|
}
|
|
100
101
|
}
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
export function isMac(): boolean;
|
|
2
|
-
export function matchShortcuts(event: KeyboardEvent, shortcuts: string): boolean;
|
|
3
|
-
export function activateKeyShortcuts(element: (HTMLInputElement | HTMLButtonElement), shortcuts?: string | undefined): ActionReturn;
|
|
4
|
-
import type { ActionReturn } from 'svelte/action';
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @import { ActionReturn } from 'svelte/action';
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/** @type {boolean | undefined} */
|
|
6
|
-
let _isMac;
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Check if the user agent is macOS.
|
|
10
|
-
* @returns {boolean} Result.
|
|
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)(.)$/;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Whether the event matches the given keyboard shortcuts.
|
|
25
|
-
* @param {KeyboardEvent} event `keydown` or `keypress` event.
|
|
26
|
-
* @param {string} shortcuts Keyboard shortcuts like `A` or `Ctrl+S`.
|
|
27
|
-
* @returns {boolean} Result.
|
|
28
|
-
* @see https://w3c.github.io/aria/#aria-keyshortcuts
|
|
29
|
-
*/
|
|
30
|
-
export const matchShortcuts = (event, shortcuts) => {
|
|
31
|
-
const { ctrlKey, metaKey, altKey, shiftKey, code } = event;
|
|
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');
|
|
39
|
-
|
|
40
|
-
return shortcuts.split(/\s+/).some((shortcut) => {
|
|
41
|
-
const keys = shortcut.split('+');
|
|
42
|
-
|
|
43
|
-
// Check if required modifier keys are pressed
|
|
44
|
-
if (
|
|
45
|
-
(keys.includes('Ctrl') && !ctrlKey) ||
|
|
46
|
-
(keys.includes('Meta') && !metaKey) ||
|
|
47
|
-
(keys.includes('Alt') && !altKey) ||
|
|
48
|
-
(keys.includes('Shift') && !shiftKey)
|
|
49
|
-
) {
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Check if unnecessary modifier keys are not pressed
|
|
54
|
-
if (
|
|
55
|
-
(!keys.includes('Ctrl') && ctrlKey) ||
|
|
56
|
-
(!keys.includes('Meta') && metaKey) ||
|
|
57
|
-
(!keys.includes('Alt') && altKey) ||
|
|
58
|
-
(!keys.includes('Shift') && shiftKey)
|
|
59
|
-
) {
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return keys
|
|
64
|
-
.filter((_key) => !MODIFIER_KEYS.includes(_key))
|
|
65
|
-
.every((_key) => _key.toUpperCase() === key.toUpperCase());
|
|
66
|
-
});
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Activate keyboard shortcuts.
|
|
71
|
-
* @param {(HTMLInputElement | HTMLButtonElement)} element Element.
|
|
72
|
-
* @param {string} [shortcuts] Keyboard shortcuts like `A` or `Accel+S` to focus and click the text
|
|
73
|
-
* field or button. Multiple shortcuts can be defined space-separated. The `Accel` modifier will be
|
|
74
|
-
* replaced with `Ctrl` on Windows/Linux and `Command` on macOS.
|
|
75
|
-
* @returns {ActionReturn} Actions.
|
|
76
|
-
*/
|
|
77
|
-
export const activateKeyShortcuts = (element, shortcuts = '') => {
|
|
78
|
-
/** @type {string | undefined} */
|
|
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;
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Handle the event.
|
|
89
|
-
* @param {KeyboardEvent} event `keydown` event.
|
|
90
|
-
*/
|
|
91
|
-
const handler = (event) => {
|
|
92
|
-
const { disabled, offsetParent } = element;
|
|
93
|
-
|
|
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
|
-
) {
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const { top, left } = element.getBoundingClientRect();
|
|
119
|
-
|
|
120
|
-
if (disabled) {
|
|
121
|
-
// Make sure `elementsFromPoint()` works as expected
|
|
122
|
-
element.style.setProperty('pointer-events', 'auto');
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Check if the element is clickable (not behind a modal dialog)
|
|
126
|
-
if (document.elementsFromPoint(left + 4, top + 4).includes(element)) {
|
|
127
|
-
event.preventDefault();
|
|
128
|
-
|
|
129
|
-
// Trigger click only when the element is enabled
|
|
130
|
-
if (!disabled) {
|
|
131
|
-
element.focus();
|
|
132
|
-
element.click();
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
if (disabled) {
|
|
137
|
-
element.style.removeProperty('pointer-events');
|
|
138
|
-
}
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Remove the event listener.
|
|
143
|
-
*/
|
|
144
|
-
const removeListener = () => {
|
|
145
|
-
globalThis.removeEventListener('keydown', handler, { capture: true });
|
|
146
|
-
element.removeAttribute('aria-keyshortcuts');
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Add the event listener.
|
|
151
|
-
*/
|
|
152
|
-
const addListener = () => {
|
|
153
|
-
platformKeyShortcuts = shortcuts
|
|
154
|
-
? shortcuts.replace(/\bAccel\b/g, isMac() ? 'Meta' : 'Ctrl')
|
|
155
|
-
: undefined;
|
|
156
|
-
|
|
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
|
-
|
|
172
|
-
globalThis.addEventListener('keydown', handler, { capture: true });
|
|
173
|
-
element.setAttribute('aria-keyshortcuts', platformKeyShortcuts);
|
|
174
|
-
} else {
|
|
175
|
-
parsedShortcuts = undefined;
|
|
176
|
-
}
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
addListener();
|
|
180
|
-
|
|
181
|
-
return {
|
|
182
|
-
// eslint-disable-next-line jsdoc/require-jsdoc, no-shadow, no-unused-vars
|
|
183
|
-
update(shortcuts) {
|
|
184
|
-
removeListener();
|
|
185
|
-
addListener();
|
|
186
|
-
},
|
|
187
|
-
|
|
188
|
-
// eslint-disable-next-line jsdoc/require-jsdoc
|
|
189
|
-
destroy() {
|
|
190
|
-
removeListener();
|
|
191
|
-
},
|
|
192
|
-
};
|
|
193
|
-
};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,221 +0,0 @@
|
|
|
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
|
-
});
|