slim-select 2.13.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +184 -43
  2. package/dist/index.d.ts +304 -52
  3. package/dist/react/index.d.ts +304 -0
  4. package/dist/react/index.js +1549 -0
  5. package/dist/react/index.umd.js +22 -0
  6. package/dist/slimselect.cjs.js +1 -2030
  7. package/dist/slimselect.css +1 -1
  8. package/dist/slimselect.css.map +1 -1
  9. package/dist/slimselect.es.js +1224 -2012
  10. package/dist/slimselect.umd.js +1 -2036
  11. package/dist/vue/index.d.ts +304 -0
  12. package/dist/vue/index.js +1341 -0
  13. package/dist/vue/index.umd.js +1 -0
  14. package/index.html +1 -0
  15. package/package.json +58 -36
  16. package/release.js +0 -7
  17. package/src/slim-select/accessibility.test.ts +782 -0
  18. package/src/slim-select/classes.test.ts +7 -6
  19. package/src/slim-select/classes.ts +8 -6
  20. package/src/slim-select/helpers.test.ts +10 -8
  21. package/src/slim-select/index.test.ts +509 -14
  22. package/src/slim-select/index.ts +58 -31
  23. package/src/slim-select/react/index.tsx +9 -0
  24. package/src/slim-select/react/react.tsx +112 -0
  25. package/src/slim-select/render.test.ts +479 -119
  26. package/src/slim-select/render.ts +213 -91
  27. package/src/slim-select/select.test.ts +28 -10
  28. package/src/slim-select/select.ts +35 -12
  29. package/src/slim-select/settings.test.ts +19 -8
  30. package/src/slim-select/settings.ts +2 -4
  31. package/src/slim-select/slimselect.scss +30 -15
  32. package/src/slim-select/store.test.ts +13 -16
  33. package/src/slim-select/store.ts +97 -86
  34. package/src/slim-select/vue/index.ts +9 -0
  35. package/src/slim-select/vue/vue.test.ts +832 -0
  36. package/src/slim-select/{vue.vue → vue/vue.vue} +45 -23
  37. package/tsconfig.json +6 -4
  38. package/{vite.config.mts → vite.config.docs.mts} +10 -0
  39. package/vite.config.lib.mts +42 -0
  40. package/vite.config.react.mts +40 -0
  41. package/vite.config.vue.mts +39 -0
  42. package/vitest.config.ts +29 -0
  43. package/dist/classes.d.ts +0 -43
  44. package/dist/helpers.d.ts +0 -5
  45. package/dist/render.d.ts +0 -81
  46. package/dist/select.d.ts +0 -30
  47. package/dist/settings.d.ts +0 -35
  48. package/dist/slimselect.global.js +0 -2033
  49. package/dist/slimselect.js +0 -2036
  50. package/dist/slimselect.min.js +0 -1
  51. package/dist/slimselect.umd.min.js +0 -1
  52. package/dist/store.d.ts +0 -84
  53. package/jest.config.js +0 -14
  54. package/src/slim-select/rollup.config.mjs +0 -127
  55. package/src/slim-select/tsconfig.json +0 -14
@@ -1,10 +1,6 @@
1
- /**
2
- * @jest-environment jsdom
3
- */
4
-
5
1
  'use strict'
6
2
 
7
- import { describe, expect, test } from '@jest/globals'
3
+ import { describe, expect, test, vi, beforeEach } from 'vitest'
8
4
  import Render, { Callbacks } from './render'
9
5
  import Settings from './settings'
10
6
  import Store, { Option } from './store'
@@ -12,14 +8,14 @@ import CssClasses from './classes'
12
8
 
13
9
  describe('render module', () => {
14
10
  let render: Render
15
- let openMock: jest.Mock
16
- let closeMock: jest.Mock
17
- let addSelectedMock: jest.Mock
18
- let setSelectedMock: jest.Mock
19
- let addOptionMock: jest.Mock
20
- let searchMock: jest.Mock
21
- let afterChangeMock: jest.Mock
22
- let beforeChangeMock: jest.Mock
11
+ let openMock: ReturnType<typeof vi.fn>
12
+ let closeMock: ReturnType<typeof vi.fn>
13
+ let addSelectedMock: ReturnType<typeof vi.fn>
14
+ let setSelectedMock: ReturnType<typeof vi.fn>
15
+ let addOptionMock: ReturnType<typeof vi.fn>
16
+ let searchMock: ReturnType<typeof vi.fn>
17
+ let afterChangeMock: ReturnType<typeof vi.fn>
18
+ let beforeChangeMock: ReturnType<typeof vi.fn>
23
19
 
24
20
  beforeEach(() => {
25
21
  const store = new Store('single', [
@@ -42,14 +38,14 @@ describe('render module', () => {
42
38
  const settings = new Settings()
43
39
  const classes = new CssClasses()
44
40
 
45
- openMock = jest.fn(() => {})
46
- closeMock = jest.fn(() => {})
47
- addSelectedMock = jest.fn(() => {})
48
- setSelectedMock = jest.fn(() => {})
49
- addOptionMock = jest.fn(() => {})
50
- searchMock = jest.fn(() => {})
51
- afterChangeMock = jest.fn(() => {})
52
- beforeChangeMock = jest.fn(() => true)
41
+ openMock = vi.fn(() => {})
42
+ closeMock = vi.fn(() => {})
43
+ addSelectedMock = vi.fn(() => {})
44
+ setSelectedMock = vi.fn(() => {})
45
+ addOptionMock = vi.fn(() => {})
46
+ searchMock = vi.fn(() => {})
47
+ afterChangeMock = vi.fn(() => {})
48
+ beforeChangeMock = vi.fn(() => true)
53
49
 
54
50
  // default callbacks
55
51
  const callbacks = {
@@ -123,24 +119,33 @@ describe('render module', () => {
123
119
  render.open()
124
120
 
125
121
  expect(render.main.arrow.path.getAttribute('d')).toBe(render.classes.arrowOpen)
126
- expect(render.main.main.classList.contains(render.classes.openBelow)).toBe(true)
127
- expect(render.main.main.classList.contains(render.classes.openAbove)).toBe(false)
128
122
  expect(render.main.main.getAttribute('aria-expanded')).toBe('true')
129
- expect(render.content.main.classList.contains(render.classes.openBelow)).toBe(true)
130
- expect(render.content.main.classList.contains(render.classes.openAbove)).toBe(false)
123
+ expect(render.content.main.classList.contains(render.classes.contentOpen)).toBe(true)
124
+ // Direction class should be set on both main and content (dirAbove or dirBelow)
125
+ const mainHasDirection =
126
+ render.main.main.classList.contains(render.classes.dirAbove) ||
127
+ render.main.main.classList.contains(render.classes.dirBelow)
128
+ const contentHasDirection =
129
+ render.content.main.classList.contains(render.classes.dirAbove) ||
130
+ render.content.main.classList.contains(render.classes.dirBelow)
131
+ expect(mainHasDirection).toBe(true)
132
+ expect(contentHasDirection).toBe(true)
131
133
  })
132
134
  })
133
135
 
134
136
  describe('close', () => {
135
137
  test('close sets the correct attributes and CSS classes', () => {
138
+ render.open()
136
139
  render.close()
137
140
 
138
141
  expect(render.main.arrow.path.getAttribute('d')).toBe(render.classes.arrowClose)
139
- expect(render.main.main.classList.contains(render.classes.openBelow)).toBe(false)
140
- expect(render.main.main.classList.contains(render.classes.openAbove)).toBe(false)
141
142
  expect(render.main.main.getAttribute('aria-expanded')).toBe('false')
142
- expect(render.content.main.classList.contains(render.classes.openBelow)).toBe(false)
143
- expect(render.content.main.classList.contains(render.classes.openAbove)).toBe(false)
143
+ expect(render.content.main.classList.contains(render.classes.contentOpen)).toBe(false)
144
+ // Direction class should persist after close
145
+ const hasDirection =
146
+ render.content.main.classList.contains(render.classes.dirAbove) ||
147
+ render.content.main.classList.contains(render.classes.dirBelow)
148
+ expect(hasDirection).toBe(true)
144
149
  })
145
150
  })
146
151
 
@@ -200,9 +205,10 @@ describe('render module', () => {
200
205
 
201
206
  expect(render.main.main.role).toBe('combobox')
202
207
  expect(render.main.main.getAttribute('aria-haspopup')).toBe('listbox')
203
- expect(render.main.main.getAttribute('aria-controls')).toBe(render.content.main.dataset.id ?? '')
208
+ expect(render.main.main.getAttribute('aria-controls')).toBe(render.content.list.id)
204
209
  expect(render.main.main.getAttribute('aria-expanded')).toBe('false')
205
- expect(render.content.main.getAttribute('role')).toBe('listbox')
210
+ expect(render.content.list.getAttribute('role')).toBe('listbox')
211
+ expect(render.content.list.getAttribute('aria-label')).toContain('listbox')
206
212
  })
207
213
  })
208
214
 
@@ -235,7 +241,7 @@ describe('render module', () => {
235
241
  })
236
242
 
237
243
  test('arrow key events on main element move highlight', () => {
238
- const highlightMock = jest.fn(() => {})
244
+ const highlightMock = vi.fn(() => {})
239
245
 
240
246
  render.highlight = highlightMock
241
247
 
@@ -316,7 +322,7 @@ describe('render module', () => {
316
322
  test('click on deselect on multiple select runs callbacks', () => {
317
323
  render.settings.isMultiple = true
318
324
 
319
- const deselectAllMock = jest.fn()
325
+ const deselectAllMock = vi.fn()
320
326
 
321
327
  render.updateDeselectAll = deselectAllMock
322
328
 
@@ -333,10 +339,10 @@ describe('render module', () => {
333
339
  })
334
340
 
335
341
  describe('mainFocus', () => {
336
- let focusMock: jest.Mock
342
+ let focusMock: ReturnType<typeof vi.fn>
337
343
 
338
344
  beforeEach(() => {
339
- focusMock = jest.fn(() => {})
345
+ focusMock = vi.fn(() => {})
340
346
  render.main.main.focus = focusMock
341
347
  })
342
348
 
@@ -485,17 +491,13 @@ describe('render module', () => {
485
491
  })
486
492
  })
487
493
 
488
- describe('multipleValue', () => {})
489
-
490
- describe('contentDiv', () => {})
491
-
492
494
  describe('moveContent', () => {
493
- let contentAboveMock: jest.Mock
494
- let contentBelowMock: jest.Mock
495
+ let contentAboveMock: ReturnType<typeof vi.fn>
496
+ let contentBelowMock: ReturnType<typeof vi.fn>
495
497
 
496
498
  beforeEach(() => {
497
- contentAboveMock = jest.fn()
498
- contentBelowMock = jest.fn()
499
+ contentAboveMock = vi.fn()
500
+ contentBelowMock = vi.fn()
499
501
 
500
502
  render.moveContentAbove = contentAboveMock
501
503
  render.moveContentBelow = contentBelowMock
@@ -526,7 +528,7 @@ describe('render module', () => {
526
528
  })
527
529
 
528
530
  test('content is moved above when putContent is up', () => {
529
- render.putContent = jest.fn(() => 'up')
531
+ render.putContent = vi.fn(() => 'up') as any
530
532
 
531
533
  render.moveContent()
532
534
  expect(contentAboveMock).toHaveBeenCalled()
@@ -534,7 +536,7 @@ describe('render module', () => {
534
536
  })
535
537
 
536
538
  test('content is moved below when putContent is down', () => {
537
- render.putContent = jest.fn(() => 'down')
539
+ render.putContent = vi.fn(() => 'down') as any
538
540
 
539
541
  render.moveContent()
540
542
  expect(contentAboveMock).not.toHaveBeenCalled()
@@ -564,7 +566,7 @@ describe('render module', () => {
564
566
 
565
567
  test('arrow keys move highlight', () => {
566
568
  const search = render.searchDiv()
567
- const highlightMock = jest.fn(() => {})
569
+ const highlightMock = vi.fn(() => {})
568
570
 
569
571
  render.highlight = highlightMock
570
572
 
@@ -596,7 +598,7 @@ describe('render module', () => {
596
598
 
597
599
  test("enter and space don't call addable witout ctrl key", () => {
598
600
  const search = render.searchDiv()
599
- const addableMock = jest.fn((s: string) => ({
601
+ const addableMock = vi.fn((s: string) => ({
600
602
  text: s,
601
603
  value: s.toLowerCase()
602
604
  }))
@@ -611,7 +613,7 @@ describe('render module', () => {
611
613
  })
612
614
 
613
615
  test('enter and space call event and does nothing without input value', () => {
614
- const addableMock = jest.fn((s: string) => ({
616
+ const addableMock = vi.fn((s: string) => ({
615
617
  text: s,
616
618
  value: s.toLowerCase()
617
619
  }))
@@ -626,7 +628,7 @@ describe('render module', () => {
626
628
  })
627
629
 
628
630
  test('enter and space call addable when defined', () => {
629
- const addableMock = jest.fn((s: string) => ({
631
+ const addableMock = vi.fn((s: string) => ({
630
632
  text: s,
631
633
  value: s.toLowerCase()
632
634
  }))
@@ -642,6 +644,64 @@ describe('render module', () => {
642
644
  expect(addableMock).toHaveBeenCalledTimes(1)
643
645
  expect(addableMock.mock.calls[0]).toStrictEqual(['Search'])
644
646
  })
647
+
648
+ test('enter selects highlighted option before calling addable', () => {
649
+ const addableMock = vi.fn((s: string) => ({
650
+ text: s,
651
+ value: s.toLowerCase()
652
+ }))
653
+
654
+ render.callbacks.addable = addableMock
655
+
656
+ // recreate search because we have added the addable callback
657
+ render.content.search = render.searchDiv()
658
+
659
+ // Render options
660
+ render.renderOptions(render.store.getDataOptions())
661
+
662
+ // Set search value
663
+ render.content.search.input.value = '1'
664
+
665
+ // Highlight first option (simulating arrow down)
666
+ render.highlight('down')
667
+
668
+ // Verify an option is highlighted
669
+ const highlighted = render.content.list.querySelector('.' + render.classes.highlighted)
670
+ expect(highlighted).toBeTruthy()
671
+
672
+ // Press Enter - should select highlighted option, NOT call addable
673
+ render.content.search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
674
+
675
+ // Addable should NOT have been called because an option was highlighted
676
+ expect(addableMock).not.toHaveBeenCalled()
677
+ })
678
+
679
+ test('enter calls addable when no option is highlighted', () => {
680
+ const addableMock = vi.fn((s: string) => ({
681
+ text: s,
682
+ value: s.toLowerCase()
683
+ }))
684
+
685
+ render.callbacks.addable = addableMock
686
+
687
+ // recreate search because we have added the addable callback
688
+ render.content.search = render.searchDiv()
689
+
690
+ // Render options
691
+ render.renderOptions(render.store.getDataOptions())
692
+
693
+ // Set search value
694
+ render.content.search.input.value = 'NewItem'
695
+
696
+ // Do NOT highlight any option (user just types and presses Enter)
697
+
698
+ // Press Enter - should call addable since no option is highlighted
699
+ render.content.search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
700
+
701
+ // Addable SHOULD have been called
702
+ expect(addableMock).toHaveBeenCalledTimes(1)
703
+ expect(addableMock.mock.calls[0]).toStrictEqual(['NewItem'])
704
+ })
645
705
  })
646
706
 
647
707
  describe('searchFocus', () => {
@@ -1001,8 +1061,6 @@ describe('render module', () => {
1001
1061
  })
1002
1062
  })
1003
1063
 
1004
- describe('renderOptions', () => {})
1005
-
1006
1064
  describe('option', () => {
1007
1065
  test('add inline styles correctly', () => {
1008
1066
  const option = render.option(
@@ -1080,6 +1138,62 @@ describe('render module', () => {
1080
1138
  expect(option.querySelector('mark')?.textContent).toBe('opt')
1081
1139
  })
1082
1140
 
1141
+ test('text highlighting with special regex characters does not break HTML', () => {
1142
+ render.settings.searchHighlight = true
1143
+ render.content.search.input.value = '<'
1144
+
1145
+ const option = render.option(
1146
+ new Option({
1147
+ text: 'option <test>'
1148
+ })
1149
+ )
1150
+
1151
+ // Should not break the HTML structure
1152
+ expect(option.querySelector('.ss-option')).toBeFalsy() // No nested ss-option divs
1153
+
1154
+ // The < character should be highlighted if found in text
1155
+ const mark = option.querySelector('mark')
1156
+ if (mark) {
1157
+ expect(mark.textContent).toBe('<')
1158
+ }
1159
+ })
1160
+
1161
+ test('text highlighting escapes special regex characters', () => {
1162
+ render.settings.searchHighlight = true
1163
+
1164
+ // Test various special regex characters
1165
+ const specialChars = ['<', '>', '.', '*', '+', '?', '^', '$', '{', '}', '(', ')', '|', '[', ']', '\\']
1166
+
1167
+ specialChars.forEach((char) => {
1168
+ render.content.search.input.value = char
1169
+ const option = render.option(
1170
+ new Option({
1171
+ text: `option ${char} test`
1172
+ })
1173
+ )
1174
+
1175
+ // Should not throw an error and should contain the character
1176
+ expect(option.textContent).toContain(char)
1177
+ })
1178
+ })
1179
+
1180
+ test('text highlighting with HTML content highlights only text nodes', () => {
1181
+ render.settings.searchHighlight = true
1182
+ render.content.search.input.value = 'test'
1183
+
1184
+ const option = render.option(
1185
+ new Option({
1186
+ text: 'test option',
1187
+ html: '<div class="wrapper">test option</div>'
1188
+ })
1189
+ )
1190
+
1191
+ // Should not break HTML structure - the key fix for issue #570
1192
+ expect(option.querySelector('.wrapper')).toBeTruthy() // HTML structure preserved
1193
+ expect(option.querySelector('mark')).toBeTruthy() // Text is highlighted
1194
+ expect(option.querySelector('mark')?.textContent).toBe('test')
1195
+ })
1196
+
1083
1197
  test('click does nothing when option is disabled', () => {
1084
1198
  const option = render.option(
1085
1199
  new Option({
@@ -1142,26 +1256,30 @@ describe('render module', () => {
1142
1256
  })
1143
1257
  })
1144
1258
 
1145
- describe('range selection', () => {
1259
+ describe('native multi-select behavior', () => {
1146
1260
  let render: Render
1147
- let afterChangeMock: jest.Mock
1261
+ let afterChangeMock: ReturnType<typeof vi.fn>
1262
+ let closeMock: ReturnType<typeof vi.fn>
1148
1263
 
1149
1264
  beforeEach(() => {
1150
- // create a new store with 3 options
1265
+ // create a new store with 5 options for comprehensive testing
1151
1266
  const store = new Store('multiple', [
1152
1267
  { text: 'test1', value: 'test1' },
1153
1268
  { text: 'test2', value: 'test2' },
1154
- { text: 'test3', value: 'test3' }
1269
+ { text: 'test3', value: 'test3' },
1270
+ { text: 'test4', value: 'test4' },
1271
+ { text: 'test5', value: 'test5' }
1155
1272
  ])
1156
1273
 
1157
1274
  const settings = new Settings()
1158
1275
  const classes = new CssClasses()
1159
1276
 
1160
- afterChangeMock = jest.fn(() => {})
1277
+ afterChangeMock = vi.fn(() => {})
1278
+ closeMock = vi.fn(() => {})
1161
1279
 
1162
1280
  const callbacks = {
1163
1281
  open: () => {},
1164
- close: () => {},
1282
+ close: closeMock,
1165
1283
  addSelected: () => {},
1166
1284
  setSelected: (value) => {
1167
1285
  store.setSelectedBy('id', typeof value === 'string' ? [value] : value)
@@ -1173,70 +1291,320 @@ describe('render module', () => {
1173
1291
 
1174
1292
  render = new Render(settings, classes, store, callbacks)
1175
1293
  render.settings.isMultiple = true
1176
- render.settings.closeOnSelect = false
1294
+ render.settings.closeOnSelect = true
1177
1295
  })
1178
1296
 
1179
- test('click holding shift key selects range from last clicked to current', () => {
1180
- render.renderOptions(render.store.getDataOptions())
1297
+ describe('Regular Click (no modifiers)', () => {
1298
+ test('toggles option (add/remove) without affecting others', () => {
1299
+ render.renderOptions(render.store.getDataOptions())
1300
+ const opts = render.getOptions(false, false, true)
1181
1301
 
1182
- const opts = render.getOptions(false, false, true)
1183
- expect(opts).toHaveLength(3)
1302
+ // Click first option - adds it
1303
+ opts[0].dispatchEvent(new MouseEvent('click'))
1304
+ expect(afterChangeMock).toHaveBeenCalledWith([expect.objectContaining({ value: 'test1' })])
1184
1305
 
1185
- // click on the first option
1186
- opts[0].dispatchEvent(new MouseEvent('click'))
1187
- expect(afterChangeMock).toHaveBeenCalledWith([expect.objectContaining({ value: 'test1' })])
1306
+ // Click third option - adds it (first option still selected)
1307
+ opts[2].dispatchEvent(new MouseEvent('click'))
1308
+ expect(afterChangeMock).toHaveBeenCalledWith([
1309
+ expect.objectContaining({ value: 'test1' }),
1310
+ expect.objectContaining({ value: 'test3' })
1311
+ ])
1188
1312
 
1189
- // click on the last option holding the shift key
1190
- opts[2].dispatchEvent(new MouseEvent('click', { shiftKey: true }))
1313
+ // Click first option again - removes it
1314
+ opts[0].dispatchEvent(new MouseEvent('click'))
1315
+ expect(afterChangeMock).toHaveBeenCalledWith([expect.objectContaining({ value: 'test3' })])
1316
+ })
1191
1317
 
1192
- // check that also the middle value is selected
1193
- expect(afterChangeMock).toHaveBeenCalledWith([
1194
- expect.objectContaining({ value: 'test1' }),
1195
- expect.objectContaining({ value: 'test2' }),
1196
- expect.objectContaining({ value: 'test3' })
1197
- ])
1318
+ test('closes dropdown on regular click when closeOnSelect is true', () => {
1319
+ render.renderOptions(render.store.getDataOptions())
1320
+ const opts = render.getOptions(false, false, true)
1321
+
1322
+ opts[0].dispatchEvent(new MouseEvent('click'))
1323
+ expect(closeMock).toHaveBeenCalled()
1324
+ })
1325
+
1326
+ test('regular click on selected option deselects it and closes dropdown', () => {
1327
+ render.renderOptions(render.store.getDataOptions())
1328
+ const opts = render.getOptions(false, false, true)
1329
+
1330
+ // Select two options
1331
+ opts[0].dispatchEvent(new MouseEvent('click'))
1332
+ opts[1].dispatchEvent(new MouseEvent('click'))
1333
+
1334
+ expect(afterChangeMock).toHaveBeenLastCalledWith([
1335
+ expect.objectContaining({ value: 'test1' }),
1336
+ expect.objectContaining({ value: 'test2' })
1337
+ ])
1338
+ closeMock.mockClear()
1339
+
1340
+ // Click on already selected option - should deselect it and close
1341
+ opts[0].dispatchEvent(new MouseEvent('click'))
1342
+
1343
+ expect(afterChangeMock).toHaveBeenLastCalledWith([expect.objectContaining({ value: 'test2' })])
1344
+ expect(closeMock).toHaveBeenCalledTimes(1)
1345
+ })
1346
+
1347
+ test('regression: regular click adds/removes and closes, Cmd/Ctrl keeps open', () => {
1348
+ render.renderOptions(render.store.getDataOptions())
1349
+ const opts = render.getOptions(false, false, true)
1350
+
1351
+ // Regular click - should close
1352
+ opts[0].dispatchEvent(new MouseEvent('click'))
1353
+ expect(closeMock).toHaveBeenCalledTimes(1)
1354
+ closeMock.mockClear()
1355
+
1356
+ // Cmd+Click - should NOT close
1357
+ opts[1].dispatchEvent(new MouseEvent('click', { metaKey: true }))
1358
+ expect(closeMock).not.toHaveBeenCalled()
1359
+
1360
+ // Ctrl+Click - should NOT close
1361
+ opts[2].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
1362
+ expect(closeMock).not.toHaveBeenCalled()
1363
+
1364
+ // Regular click again - should close
1365
+ opts[3].dispatchEvent(new MouseEvent('click'))
1366
+ expect(closeMock).toHaveBeenCalledTimes(1)
1367
+ })
1198
1368
  })
1199
1369
 
1200
- test('click holding shift key selects range from current to last clicked', () => {
1201
- render.renderOptions(render.store.getDataOptions())
1370
+ describe('Cmd/Ctrl+Click (toggle selection)', () => {
1371
+ test('Cmd+Click adds option without deselecting others (Mac)', () => {
1372
+ render.renderOptions(render.store.getDataOptions())
1373
+ const opts = render.getOptions(false, false, true)
1202
1374
 
1203
- const opts = render.getOptions(false, false, true)
1204
- expect(opts).toHaveLength(3)
1375
+ // Cmd+Click first option
1376
+ opts[0].dispatchEvent(new MouseEvent('click', { metaKey: true }))
1377
+ expect(afterChangeMock).toHaveBeenCalledWith([expect.objectContaining({ value: 'test1' })])
1205
1378
 
1206
- // click on the last option
1207
- opts[2].dispatchEvent(new MouseEvent('click'))
1208
- expect(afterChangeMock).toHaveBeenCalledWith([expect.objectContaining({ value: 'test3' })])
1379
+ // Cmd+Click third option - both should be selected
1380
+ opts[2].dispatchEvent(new MouseEvent('click', { metaKey: true }))
1381
+ expect(afterChangeMock).toHaveBeenCalledWith([
1382
+ expect.objectContaining({ value: 'test1' }),
1383
+ expect.objectContaining({ value: 'test3' })
1384
+ ])
1385
+ })
1209
1386
 
1210
- // click on the first option holding the shift key
1211
- opts[0].dispatchEvent(new MouseEvent('click', { shiftKey: true }))
1387
+ test('Ctrl+Click adds option without deselecting others (Windows/Linux)', () => {
1388
+ render.renderOptions(render.store.getDataOptions())
1389
+ const opts = render.getOptions(false, false, true)
1212
1390
 
1213
- // check that also the middle value is selected
1214
- expect(afterChangeMock).toHaveBeenCalledWith([
1215
- expect.objectContaining({ value: 'test3' }),
1216
- expect.objectContaining({ value: 'test1' }),
1217
- expect.objectContaining({ value: 'test2' })
1218
- ])
1391
+ // Ctrl+Click first option
1392
+ opts[0].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
1393
+ expect(afterChangeMock).toHaveBeenCalledWith([expect.objectContaining({ value: 'test1' })])
1394
+
1395
+ // Ctrl+Click third option - both should be selected
1396
+ opts[2].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
1397
+ expect(afterChangeMock).toHaveBeenCalledWith([
1398
+ expect.objectContaining({ value: 'test1' }),
1399
+ expect.objectContaining({ value: 'test3' })
1400
+ ])
1401
+ })
1402
+
1403
+ test('Cmd+Click on selected option deselects it (Mac)', () => {
1404
+ render.renderOptions(render.store.getDataOptions())
1405
+ const opts = render.getOptions(false, false, true)
1406
+
1407
+ // Select multiple options with Cmd
1408
+ opts[0].dispatchEvent(new MouseEvent('click', { metaKey: true }))
1409
+ opts[1].dispatchEvent(new MouseEvent('click', { metaKey: true }))
1410
+ opts[2].dispatchEvent(new MouseEvent('click', { metaKey: true }))
1411
+
1412
+ // Cmd+Click on middle option to deselect it
1413
+ opts[1].dispatchEvent(new MouseEvent('click', { metaKey: true }))
1414
+
1415
+ expect(afterChangeMock).toHaveBeenCalledWith([
1416
+ expect.objectContaining({ value: 'test1' }),
1417
+ expect.objectContaining({ value: 'test3' })
1418
+ ])
1419
+ })
1420
+
1421
+ test('Ctrl+Click on selected option deselects it (Windows/Linux)', () => {
1422
+ render.renderOptions(render.store.getDataOptions())
1423
+ const opts = render.getOptions(false, false, true)
1424
+
1425
+ // Select multiple options with Ctrl
1426
+ opts[0].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
1427
+ opts[1].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
1428
+ opts[2].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
1429
+
1430
+ // Ctrl+Click on middle option to deselect it
1431
+ opts[1].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
1432
+
1433
+ expect(afterChangeMock).toHaveBeenCalledWith([
1434
+ expect.objectContaining({ value: 'test1' }),
1435
+ expect.objectContaining({ value: 'test3' })
1436
+ ])
1437
+ })
1438
+
1439
+ test('Cmd+Click toggles selection on and off', () => {
1440
+ render.renderOptions(render.store.getDataOptions())
1441
+ const opts = render.getOptions(false, false, true)
1442
+
1443
+ // Click to select
1444
+ opts[0].dispatchEvent(new MouseEvent('click', { metaKey: true }))
1445
+ expect(afterChangeMock).toHaveBeenLastCalledWith([expect.objectContaining({ value: 'test1' })])
1446
+
1447
+ // Click again to deselect
1448
+ opts[0].dispatchEvent(new MouseEvent('click', { metaKey: true }))
1449
+ expect(afterChangeMock).toHaveBeenLastCalledWith([])
1450
+
1451
+ // Click once more to select again
1452
+ opts[0].dispatchEvent(new MouseEvent('click', { metaKey: true }))
1453
+ expect(afterChangeMock).toHaveBeenLastCalledWith([expect.objectContaining({ value: 'test1' })])
1454
+ })
1455
+
1456
+ test('Cmd/Ctrl+Click works even when allowDeselect is false', () => {
1457
+ render.settings.allowDeselect = false
1458
+ render.renderOptions(render.store.getDataOptions())
1459
+ const opts = render.getOptions(false, false, true)
1460
+
1461
+ // Select with Cmd/Ctrl
1462
+ opts[0].dispatchEvent(new MouseEvent('click', { metaKey: true }))
1463
+ opts[1].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
1464
+
1465
+ // Should be able to deselect with Cmd/Ctrl even though allowDeselect is false
1466
+ opts[0].dispatchEvent(new MouseEvent('click', { metaKey: true }))
1467
+
1468
+ expect(afterChangeMock).toHaveBeenLastCalledWith([expect.objectContaining({ value: 'test2' })])
1469
+ })
1470
+
1471
+ test('does NOT close dropdown on Cmd+Click', () => {
1472
+ render.renderOptions(render.store.getDataOptions())
1473
+ const opts = render.getOptions(false, false, true)
1474
+
1475
+ opts[0].dispatchEvent(new MouseEvent('click', { metaKey: true }))
1476
+ expect(closeMock).not.toHaveBeenCalled()
1477
+ })
1478
+
1479
+ test('does NOT close dropdown on Ctrl+Click', () => {
1480
+ render.renderOptions(render.store.getDataOptions())
1481
+ const opts = render.getOptions(false, false, true)
1482
+
1483
+ opts[0].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
1484
+ expect(closeMock).not.toHaveBeenCalled()
1485
+ })
1219
1486
  })
1220
1487
 
1221
- test('range selection is ignored if range length is greater than maxSelected', () => {
1222
- render.settings.maxSelected = 2
1223
- render.renderOptions(render.store.getDataOptions())
1488
+ describe('Shift+Click (range selection)', () => {
1489
+ test('selects range from last clicked to current', () => {
1490
+ render.renderOptions(render.store.getDataOptions())
1491
+ const opts = render.getOptions(false, false, true)
1224
1492
 
1225
- const opts = render.getOptions(false, false, true)
1226
- expect(opts).toHaveLength(3)
1493
+ // Click first option
1494
+ opts[0].dispatchEvent(new MouseEvent('click'))
1495
+ expect(afterChangeMock).toHaveBeenCalledWith([expect.objectContaining({ value: 'test1' })])
1227
1496
 
1228
- // click on the first option
1229
- opts[0].dispatchEvent(new MouseEvent('click'))
1230
- expect(afterChangeMock).toHaveBeenCalledWith([expect.objectContaining({ value: 'test1' })])
1497
+ // Shift+Click third option - should select test1, test2, test3
1498
+ opts[2].dispatchEvent(new MouseEvent('click', { shiftKey: true }))
1499
+ expect(afterChangeMock).toHaveBeenCalledWith([
1500
+ expect.objectContaining({ value: 'test1' }),
1501
+ expect.objectContaining({ value: 'test2' }),
1502
+ expect.objectContaining({ value: 'test3' })
1503
+ ])
1504
+ })
1231
1505
 
1232
- // click on the last option holding the shift key
1233
- opts[2].dispatchEvent(new MouseEvent('click', { shiftKey: true }))
1506
+ test('selects range in reverse direction', () => {
1507
+ render.renderOptions(render.store.getDataOptions())
1508
+ const opts = render.getOptions(false, false, true)
1234
1509
 
1235
- // check that the middle value is NOT selected
1236
- expect(afterChangeMock).toHaveBeenCalledWith([
1237
- expect.objectContaining({ value: 'test1' }),
1238
- expect.objectContaining({ value: 'test3' })
1239
- ])
1510
+ // Click fourth option
1511
+ opts[3].dispatchEvent(new MouseEvent('click'))
1512
+
1513
+ // Shift+Click second option - should select test2, test3, test4
1514
+ opts[1].dispatchEvent(new MouseEvent('click', { shiftKey: true }))
1515
+ expect(afterChangeMock).toHaveBeenCalledWith([
1516
+ expect.objectContaining({ value: 'test4' }),
1517
+ expect.objectContaining({ value: 'test2' }),
1518
+ expect.objectContaining({ value: 'test3' })
1519
+ ])
1520
+ })
1521
+
1522
+ test('respects maxSelected limit', () => {
1523
+ render.settings.maxSelected = 2
1524
+ render.renderOptions(render.store.getDataOptions())
1525
+ const opts = render.getOptions(false, false, true)
1526
+
1527
+ // Click first option
1528
+ opts[0].dispatchEvent(new MouseEvent('click'))
1529
+
1530
+ // Shift+Click fourth option - would select 4 items, exceeds limit
1531
+ opts[3].dispatchEvent(new MouseEvent('click', { shiftKey: true }))
1532
+
1533
+ // Should keep only the original selection
1534
+ expect(afterChangeMock).toHaveBeenLastCalledWith([expect.objectContaining({ value: 'test1' })])
1535
+ })
1536
+
1537
+ test('does NOT close dropdown on Shift+Click', () => {
1538
+ render.renderOptions(render.store.getDataOptions())
1539
+ const opts = render.getOptions(false, false, true)
1540
+
1541
+ opts[0].dispatchEvent(new MouseEvent('click'))
1542
+ closeMock.mockClear()
1543
+
1544
+ opts[2].dispatchEvent(new MouseEvent('click', { shiftKey: true }))
1545
+ expect(closeMock).not.toHaveBeenCalled()
1546
+ })
1547
+ })
1548
+
1549
+ describe('Combined modifier keys', () => {
1550
+ test('Cmd/Ctrl+Click then Shift+Click selects range from last click', () => {
1551
+ render.renderOptions(render.store.getDataOptions())
1552
+ const opts = render.getOptions(false, false, true)
1553
+
1554
+ // Cmd+Click first and Ctrl+Click third options (testing both)
1555
+ opts[0].dispatchEvent(new MouseEvent('click', { metaKey: true }))
1556
+ opts[2].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
1557
+
1558
+ // Shift+Click fifth option - should add test3, test4, test5 to selection
1559
+ opts[4].dispatchEvent(new MouseEvent('click', { shiftKey: true }))
1560
+
1561
+ expect(afterChangeMock).toHaveBeenLastCalledWith([
1562
+ expect.objectContaining({ value: 'test1' }),
1563
+ expect.objectContaining({ value: 'test3' }),
1564
+ expect.objectContaining({ value: 'test4' }),
1565
+ expect.objectContaining({ value: 'test5' })
1566
+ ])
1567
+ })
1568
+
1569
+ test('can build complex selections like native multi-select (Mac)', () => {
1570
+ render.renderOptions(render.store.getDataOptions())
1571
+ const opts = render.getOptions(false, false, true)
1572
+
1573
+ // Select test1
1574
+ opts[0].dispatchEvent(new MouseEvent('click'))
1575
+ // Cmd+Add test3
1576
+ opts[2].dispatchEvent(new MouseEvent('click', { metaKey: true }))
1577
+ // Cmd+Add test5
1578
+ opts[4].dispatchEvent(new MouseEvent('click', { metaKey: true }))
1579
+ // Cmd+Remove test3
1580
+ opts[2].dispatchEvent(new MouseEvent('click', { metaKey: true }))
1581
+
1582
+ // Final selection should be test1 and test5
1583
+ expect(afterChangeMock).toHaveBeenLastCalledWith([
1584
+ expect.objectContaining({ value: 'test1' }),
1585
+ expect.objectContaining({ value: 'test5' })
1586
+ ])
1587
+ })
1588
+
1589
+ test('can build complex selections like native multi-select (Windows/Linux)', () => {
1590
+ render.renderOptions(render.store.getDataOptions())
1591
+ const opts = render.getOptions(false, false, true)
1592
+
1593
+ // Select test1
1594
+ opts[0].dispatchEvent(new MouseEvent('click'))
1595
+ // Ctrl+Add test3
1596
+ opts[2].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
1597
+ // Ctrl+Add test5
1598
+ opts[4].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
1599
+ // Ctrl+Remove test3
1600
+ opts[2].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
1601
+
1602
+ // Final selection should be test1 and test5
1603
+ expect(afterChangeMock).toHaveBeenLastCalledWith([
1604
+ expect.objectContaining({ value: 'test1' }),
1605
+ expect.objectContaining({ value: 'test5' })
1606
+ ])
1607
+ })
1240
1608
  })
1241
1609
  })
1242
1610
 
@@ -1260,11 +1628,10 @@ describe('render module', () => {
1260
1628
  test('correct classes are set', () => {
1261
1629
  render.moveContentAbove()
1262
1630
 
1263
- expect(render.main.main.classList.contains(render.classes.openAbove)).toBe(true)
1264
- expect(render.main.main.classList.contains(render.classes.openBelow)).toBe(false)
1265
-
1266
- expect(render.content.main.classList.contains(render.classes.openAbove)).toBe(true)
1267
- expect(render.content.main.classList.contains(render.classes.openBelow)).toBe(false)
1631
+ expect(render.main.main.classList.contains(render.classes.dirAbove)).toBe(true)
1632
+ expect(render.main.main.classList.contains(render.classes.dirBelow)).toBe(false)
1633
+ expect(render.content.main.classList.contains(render.classes.dirAbove)).toBe(true)
1634
+ expect(render.content.main.classList.contains(render.classes.dirBelow)).toBe(false)
1268
1635
  })
1269
1636
  })
1270
1637
 
@@ -1272,17 +1639,10 @@ describe('render module', () => {
1272
1639
  test('correct classes are set', () => {
1273
1640
  render.moveContentBelow()
1274
1641
 
1275
- expect(render.main.main.classList.contains(render.classes.openAbove)).toBe(false)
1276
- expect(render.main.main.classList.contains(render.classes.openBelow)).toBe(true)
1277
-
1278
- expect(render.content.main.classList.contains(render.classes.openAbove)).toBe(false)
1279
- expect(render.content.main.classList.contains(render.classes.openBelow)).toBe(true)
1642
+ expect(render.main.main.classList.contains(render.classes.dirAbove)).toBe(false)
1643
+ expect(render.main.main.classList.contains(render.classes.dirBelow)).toBe(true)
1644
+ expect(render.content.main.classList.contains(render.classes.dirAbove)).toBe(false)
1645
+ expect(render.content.main.classList.contains(render.classes.dirBelow)).toBe(true)
1280
1646
  })
1281
1647
  })
1282
-
1283
- describe('ensureElementInView', () => {})
1284
-
1285
- describe('putContent', () => {})
1286
-
1287
- describe('updateDeselectAll', () => {})
1288
1648
  })