@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.
@@ -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 '../../services/events.svelte.js';
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" | "left" | "right" | "bottom" | undefined;
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" | "left" | "right" | "bottom" | undefined;
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(async () => {
303
+ editor.registerUpdateListener(() => {
305
304
  if (editor?.isComposing()) {
306
305
  return;
307
306
  }
308
307
 
309
- await sleep(100);
308
+ (async () => {
309
+ await sleep(100);
310
310
 
311
- editor.update(() => {
312
- // Prevent CodeNode from being removed
313
- if (isCodeEditor) {
314
- const root = $getRoot();
315
- const children = root.getChildren();
311
+ editor.update(() => {
312
+ // Prevent CodeNode from being removed
313
+ if (isCodeEditor) {
314
+ const root = $getRoot();
315
+ const children = root.getChildren();
316
316
 
317
- if (children.length === 1 && !$isCodeNode(children[0])) {
318
- children[0].remove();
319
- }
317
+ if (children.length === 1 && !$isCodeNode(children[0])) {
318
+ children[0].remove();
319
+ }
320
320
 
321
- if (children.length === 0) {
322
- const node = $createCodeNode();
321
+ if (children.length === 0) {
322
+ const node = $createCodeNode();
323
323
 
324
- node.setLanguage(defaultLanguage);
325
- root.append(node);
324
+ node.setLanguage(defaultLanguage);
325
+ root.append(node);
326
+ }
326
327
  }
327
- }
328
328
 
329
- onEditorUpdate(editor);
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: 40px;
55
+ height: auto;
56
+ min-height: 40px;
55
57
  background-color: var(--sui-tertiary-background-color);
56
58
  }
57
59
  @media (width < 768px) {
@@ -144,5 +144,10 @@ textarea[aria-invalid=true] {
144
144
  .clone {
145
145
  overflow: hidden;
146
146
  visibility: hidden;
147
+ }
148
+
149
+ textarea,
150
+ .clone {
147
151
  white-space: pre-wrap;
152
+ word-break: break-all;
148
153
  }</style>
@@ -5,7 +5,7 @@
5
5
  @see https://w3c.github.io/aria/#textbox
6
6
  -->
7
7
  <script>
8
- import { activateKeyShortcuts } from '../../services/events.svelte.js';
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
- document.documentElement.setAttribute('dir', 'rtl');
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
- document.documentElement.removeAttribute('dir');
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)', () => {
@@ -10,7 +10,7 @@ export type ButtonProps = {
10
10
  /**
11
11
  * The `type` attribute on the `<button>` element.
12
12
  */
13
- type?: "reset" | "submit" | "button" | undefined;
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.2",
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.41.0",
12
- "@lexical/dragon": "^0.41.0",
13
- "@lexical/extension": "^0.41.0",
14
- "@lexical/history": "^0.41.0",
15
- "@lexical/link": "^0.41.0",
16
- "@lexical/list": "^0.41.0",
17
- "@lexical/markdown": "^0.41.0",
18
- "@lexical/rich-text": "^0.41.0",
19
- "@lexical/selection": "^0.41.0",
20
- "@lexical/table": "^0.41.0",
21
- "@lexical/utils": "^0.41.0",
22
- "@sveltia/utils": "^0.8.6",
23
- "lexical": "^0.41.0",
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.4.0",
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.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
- });