slim-select 2.13.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +185 -44
  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 +2 -2030
  7. package/dist/slimselect.cjs.js.map +1 -0
  8. package/dist/slimselect.css +1 -1
  9. package/dist/slimselect.css.map +1 -1
  10. package/dist/slimselect.es.js +1225 -2012
  11. package/dist/slimselect.es.js.map +1 -0
  12. package/dist/slimselect.iife.js +2 -0
  13. package/dist/slimselect.iife.js.map +1 -0
  14. package/dist/slimselect.js +2 -2036
  15. package/dist/slimselect.js.map +1 -0
  16. package/dist/vue/index.d.ts +304 -0
  17. package/dist/vue/index.js +1341 -0
  18. package/dist/vue/index.umd.js +1 -0
  19. package/index.html +1 -0
  20. package/package.json +63 -40
  21. package/release.js +0 -7
  22. package/src/slim-select/accessibility.test.ts +782 -0
  23. package/src/slim-select/classes.test.ts +7 -6
  24. package/src/slim-select/classes.ts +8 -6
  25. package/src/slim-select/helpers.test.ts +10 -8
  26. package/src/slim-select/index.test.ts +509 -14
  27. package/src/slim-select/index.ts +58 -31
  28. package/src/slim-select/react/index.tsx +9 -0
  29. package/src/slim-select/react/react.tsx +112 -0
  30. package/src/slim-select/render.test.ts +479 -119
  31. package/src/slim-select/render.ts +213 -91
  32. package/src/slim-select/select.test.ts +28 -10
  33. package/src/slim-select/select.ts +35 -12
  34. package/src/slim-select/settings.test.ts +19 -8
  35. package/src/slim-select/settings.ts +2 -4
  36. package/src/slim-select/slimselect.scss +30 -15
  37. package/src/slim-select/store.test.ts +13 -16
  38. package/src/slim-select/store.ts +97 -86
  39. package/src/slim-select/vue/index.ts +9 -0
  40. package/src/slim-select/vue/vue.test.ts +832 -0
  41. package/src/slim-select/{vue.vue → vue/vue.vue} +45 -23
  42. package/tsconfig.json +6 -4
  43. package/{vite.config.mts → vite.config.docs.mts} +10 -0
  44. package/vite.config.lib.mts +48 -0
  45. package/vite.config.react.mts +40 -0
  46. package/vite.config.vue.mts +39 -0
  47. package/vitest.config.ts +29 -0
  48. package/dist/classes.d.ts +0 -43
  49. package/dist/helpers.d.ts +0 -5
  50. package/dist/render.d.ts +0 -81
  51. package/dist/select.d.ts +0 -30
  52. package/dist/settings.d.ts +0 -35
  53. package/dist/slimselect.global.js +0 -2033
  54. package/dist/slimselect.min.js +0 -1
  55. package/dist/slimselect.umd.js +0 -2036
  56. package/dist/slimselect.umd.min.js +0 -1
  57. package/dist/store.d.ts +0 -84
  58. package/jest.config.js +0 -14
  59. package/src/slim-select/rollup.config.mjs +0 -127
  60. package/src/slim-select/tsconfig.json +0 -14
@@ -1,6 +1,6 @@
1
1
  import { debounce } from './helpers'
2
2
  import Settings from './settings'
3
- import Store, { DataArray, Optgroup, Option, OptionOptional } from './store'
3
+ import Store, { Optgroup, Option } from './store'
4
4
  import CssClasses from './classes'
5
5
 
6
6
  export interface Callbacks {
@@ -8,7 +8,7 @@ export interface Callbacks {
8
8
  close: () => void
9
9
  addable?: (
10
10
  value: string
11
- ) => Promise<OptionOptional | string> | OptionOptional | string | false | undefined | null | Error
11
+ ) => Promise<Partial<Option> | string> | Partial<Option> | string | false | undefined | null | Error
12
12
  setSelected: (value: string | string[], runAfterChange: boolean) => void
13
13
  addOption: (option: Option) => void
14
14
  search: (search: string) => void
@@ -84,6 +84,7 @@ export default class Render {
84
84
  public enable(): void {
85
85
  // Remove disabled class
86
86
  this.main.main.classList.remove(this.classes.disabled)
87
+ this.main.main.setAttribute('aria-disabled', 'false')
87
88
 
88
89
  // Set search input to "enabled"
89
90
  this.content.search.input.disabled = false
@@ -93,6 +94,7 @@ export default class Render {
93
94
  public disable(): void {
94
95
  // Add disabled class
95
96
  this.main.main.classList.add(this.classes.disabled)
97
+ this.main.main.setAttribute('aria-disabled', 'true')
96
98
 
97
99
  // Set search input to disabled
98
100
  this.content.search.input.disabled = true
@@ -100,11 +102,20 @@ export default class Render {
100
102
 
101
103
  public open(): void {
102
104
  this.main.arrow.path.setAttribute('d', this.classes.arrowOpen)
103
-
104
- // Add class to main container
105
- this.main.main.classList.add(this.settings.openPosition === 'up' ? this.classes.openAbove : this.classes.openBelow)
106
105
  this.main.main.setAttribute('aria-expanded', 'true')
107
106
 
107
+ // Set direction class on both main and content (persists, never removed)
108
+ const isAbove = this.settings.openPosition === 'up'
109
+ const dirClass = isAbove ? this.classes.dirAbove : this.classes.dirBelow
110
+ this.main.main.classList.add(dirClass)
111
+ this.content.main.classList.add(dirClass)
112
+
113
+ // Add open class to content to trigger open animation
114
+ this.content.main.classList.add(this.classes.contentOpen)
115
+
116
+ // Make search visible to screen readers when opened
117
+ this.content.search.input.removeAttribute('aria-hidden')
118
+
108
119
  // move the content in to the right location
109
120
  this.moveContent()
110
121
 
@@ -120,12 +131,18 @@ export default class Render {
120
131
  }
121
132
 
122
133
  public close(): void {
123
- this.main.main.classList.remove(this.classes.openAbove)
124
- this.main.main.classList.remove(this.classes.openBelow)
125
134
  this.main.main.setAttribute('aria-expanded', 'false')
126
- this.content.main.classList.remove(this.classes.openAbove)
127
- this.content.main.classList.remove(this.classes.openBelow)
128
135
  this.main.arrow.path.setAttribute('d', this.classes.arrowClose)
136
+
137
+ // Remove open class from content to trigger close animation
138
+ // Direction class (dirAbove/dirBelow) persists to maintain correct transform-origin
139
+ this.content.main.classList.remove(this.classes.contentOpen)
140
+
141
+ // Hide search from screen readers when closed
142
+ this.content.search.input.setAttribute('aria-hidden', 'true')
143
+
144
+ // Clear active descendant when closed
145
+ this.main.main.removeAttribute('aria-activedescendant')
129
146
  }
130
147
 
131
148
  public updateClassStyles(): void {
@@ -163,12 +180,24 @@ export default class Render {
163
180
  }
164
181
 
165
182
  public updateAriaAttributes() {
183
+ const listboxId = this.content.list.id
184
+
185
+ // Main combobox
166
186
  this.main.main.role = 'combobox'
167
187
  this.main.main.setAttribute('aria-haspopup', 'listbox')
168
- this.main.main.setAttribute('aria-controls', this.content.main.dataset.id ?? '')
188
+ this.main.main.setAttribute('aria-controls', listboxId)
169
189
  this.main.main.setAttribute('aria-expanded', 'false')
170
- this.content.main.setAttribute('role', 'listbox')
171
- // do an aria-labelledby here maybe
190
+
191
+ this.content.list.setAttribute('role', 'listbox')
192
+ this.content.list.setAttribute('aria-label', this.settings.ariaLabel + ' listbox')
193
+
194
+ // Add aria-multiselectable for multiple selects
195
+ if (this.settings.isMultiple) {
196
+ this.content.list.setAttribute('aria-multiselectable', 'true')
197
+ }
198
+
199
+ // Search input should also control the listbox
200
+ this.content.search.input.setAttribute('aria-controls', listboxId)
172
201
  }
173
202
 
174
203
  public mainDiv(): Main {
@@ -520,7 +549,7 @@ export default class Render {
520
549
  // Create delete div element
521
550
  const deleteDiv = document.createElement('div')
522
551
  deleteDiv.classList.add(this.classes.valueDelete)
523
- deleteDiv.setAttribute('tabindex', '0') // Make the div focusable for tab navigation
552
+ deleteDiv.setAttribute('tabindex', '0') // Make the div focusable for tab navigation
524
553
 
525
554
  // Add delete onclick event
526
555
  deleteDiv.onclick = (e: Event) => {
@@ -555,7 +584,9 @@ export default class Render {
555
584
  for (const o of after) {
556
585
  if (o instanceof Optgroup) {
557
586
  for (const c of o.options) {
558
- selectedIds.push(c.id)
587
+ if (c.id) {
588
+ selectedIds.push(c.id)
589
+ }
559
590
  }
560
591
  }
561
592
 
@@ -592,7 +623,7 @@ export default class Render {
592
623
  // Add keydown event listener for keyboard navigation (Enter key)
593
624
  deleteDiv.onkeydown = (e) => {
594
625
  if (e.key === 'Enter') {
595
- deleteDiv.click() // Trigger the click event when Enter is pressed
626
+ deleteDiv.click() // Trigger the click event when Enter is pressed
596
627
  }
597
628
  }
598
629
  }
@@ -668,9 +699,12 @@ export default class Render {
668
699
  input.placeholder = this.settings.searchPlaceholder
669
700
  input.tabIndex = -1
670
701
  input.setAttribute('aria-label', this.settings.searchPlaceholder)
702
+ input.setAttribute('aria-autocomplete', 'list')
671
703
  input.setAttribute('autocapitalize', 'off')
672
704
  input.setAttribute('autocomplete', 'off')
673
705
  input.setAttribute('autocorrect', 'off')
706
+ // Hide from screen readers by default (shown when opened)
707
+ input.setAttribute('aria-hidden', 'true')
674
708
 
675
709
  input.oninput = debounce((e: Event) => {
676
710
  this.callbacks.search((e.target as HTMLInputElement).value)
@@ -701,15 +735,16 @@ export default class Render {
701
735
  }
702
736
  return true
703
737
  case 'Enter':
704
- if (this.callbacks.addable) {
738
+ // Check if there's a highlighted option first
739
+ const highlightedEnter = this.content.list.querySelector('.' + this.classes.highlighted) as HTMLDivElement
740
+ if (highlightedEnter) {
741
+ // If an option is highlighted, select it (even if addable is enabled)
742
+ highlightedEnter.click()
743
+ return false
744
+ } else if (this.callbacks.addable) {
745
+ // If no option is highlighted and addable is enabled, add new item
705
746
  addable.click()
706
747
  return false
707
- } else {
708
- const highlighted = this.content.list.querySelector('.' + this.classes.highlighted) as HTMLDivElement
709
- if (highlighted) {
710
- highlighted.click()
711
- return false
712
- }
713
748
  }
714
749
  return true
715
750
  }
@@ -752,7 +787,7 @@ export default class Render {
752
787
  // Run finish will be ran at the end of the addable function.
753
788
  // Reason its in a function is so we can run it after the
754
789
  // addable function is done for promise based addables
755
- const runFinish = (oo: OptionOptional) => {
790
+ const runFinish = (oo: Partial<Option>) => {
756
791
  let newOption = new Option(oo)
757
792
 
758
793
  // Call addOption to add the new option
@@ -831,6 +866,10 @@ export default class Render {
831
866
  this.content.search.input.focus()
832
867
  }
833
868
 
869
+ public clearSearch(): void {
870
+ this.content.search.input.value = ''
871
+ }
872
+
834
873
  public getOptions(notPlaceholder = false, notDisabled = false, notHidden = false): HTMLDivElement[] {
835
874
  // Put together query string
836
875
  let query = '.' + this.classes.option
@@ -894,7 +933,7 @@ export default class Render {
894
933
 
895
934
  // If previous option has parent classes ss-optgroup with ss-open then click it
896
935
  const prevParent = prevOption.parentElement
897
- if (prevParent && prevParent.classList.contains(this.classes.open)) {
936
+ if (prevParent && prevParent.classList.contains(this.classes.mainOpen)) {
898
937
  const optgroupLabel = prevParent.querySelector('.' + this.classes.optgroupLabel) as HTMLDivElement
899
938
  if (optgroupLabel) {
900
939
  optgroupLabel.click()
@@ -907,6 +946,11 @@ export default class Render {
907
946
  selectOption.classList.add(this.classes.highlighted)
908
947
  this.ensureElementInView(this.content.list, selectOption)
909
948
 
949
+ // Update aria-activedescendant for screen readers
950
+ if (selectOption.id) {
951
+ this.main.main.setAttribute('aria-activedescendant', selectOption.id)
952
+ }
953
+
910
954
  // If selected option has parent classes ss-optgroup with ss-close then click it
911
955
  const selectParent = selectOption.parentElement
912
956
  if (selectParent && selectParent.classList.contains(this.classes.close)) {
@@ -922,10 +966,16 @@ export default class Render {
922
966
 
923
967
  // If we get here, there is no highlighted option
924
968
  // So we will highlight the first or last based upon direction
925
- options[dir === 'down' ? 0 : options.length - 1].classList.add(this.classes.highlighted)
969
+ const firstHighlight = options[dir === 'down' ? 0 : options.length - 1]
970
+ firstHighlight.classList.add(this.classes.highlighted)
971
+
972
+ // Update aria-activedescendant for screen readers
973
+ if (firstHighlight.id) {
974
+ this.main.main.setAttribute('aria-activedescendant', firstHighlight.id)
975
+ }
926
976
 
927
977
  // Scroll to highlighted one
928
- this.ensureElementInView(this.content.list, options[dir === 'down' ? 0 : options.length - 1])
978
+ this.ensureElementInView(this.content.list, firstHighlight)
929
979
  }
930
980
 
931
981
  // Create main container that options will reside
@@ -933,6 +983,11 @@ export default class Render {
933
983
  const options = document.createElement('div')
934
984
  options.classList.add(this.classes.list)
935
985
 
986
+ // Add id for ARIA controls reference
987
+ const listId = this.settings.id + '-list'
988
+ options.id = listId
989
+ options.dataset.id = listId
990
+
936
991
  return options
937
992
  }
938
993
 
@@ -957,7 +1012,7 @@ export default class Render {
957
1012
  }
958
1013
 
959
1014
  // Take in data and add options to
960
- public renderOptions(data: DataArray): void {
1015
+ public renderOptions(data: (Option | Optgroup)[]): void {
961
1016
  // Clear out innerHtml
962
1017
  this.content.list.innerHTML = ''
963
1018
 
@@ -1084,13 +1139,14 @@ export default class Render {
1084
1139
  return
1085
1140
  } else {
1086
1141
  // Put together new list with all options in this optgroup
1087
- const newSelected = currentSelected.concat(d.options.map((o) => o.id))
1142
+ let optionIds = d.options.map((o) => o.id).filter((id) => id !== undefined)
1143
+ const newSelected = currentSelected.concat(optionIds)
1088
1144
 
1089
1145
  // Loop through options and if they don't exist in the store
1090
1146
  // run addOption callback
1091
1147
  for (const o of d.options) {
1092
- if (!this.store.getOptionByID(o.id)) {
1093
- this.callbacks.addOption(o)
1148
+ if (o.id && !this.store.getOptionByID(o.id)) {
1149
+ this.callbacks.addOption(new Option(o))
1094
1150
  }
1095
1151
  }
1096
1152
 
@@ -1121,10 +1177,10 @@ export default class Render {
1121
1177
 
1122
1178
  // If any options are selected or someone is searching, set optgroup to open
1123
1179
  if (d.options.some((o) => o.selected) || this.content.search.input.value.trim() !== '') {
1124
- optgroupClosable.classList.add(this.classes.open)
1180
+ optgroupClosable.classList.add(this.classes.mainOpen)
1125
1181
  optgroupClosableArrow.setAttribute('d', this.classes.arrowOpen)
1126
1182
  } else if (d.closable === 'open') {
1127
- optgroupEl.classList.add(this.classes.open)
1183
+ optgroupEl.classList.add(this.classes.mainOpen)
1128
1184
  optgroupClosableArrow.setAttribute('d', this.classes.arrowOpen)
1129
1185
  } else if (d.closable === 'close') {
1130
1186
  optgroupEl.classList.add(this.classes.close)
@@ -1139,10 +1195,10 @@ export default class Render {
1139
1195
  // If optgroup is closed, open it
1140
1196
  if (optgroupEl.classList.contains(this.classes.close)) {
1141
1197
  optgroupEl.classList.remove(this.classes.close)
1142
- optgroupEl.classList.add(this.classes.open)
1198
+ optgroupEl.classList.add(this.classes.mainOpen)
1143
1199
  optgroupClosableArrow.setAttribute('d', this.classes.arrowOpen)
1144
1200
  } else {
1145
- optgroupEl.classList.remove(this.classes.open)
1201
+ optgroupEl.classList.remove(this.classes.mainOpen)
1146
1202
  optgroupEl.classList.add(this.classes.close)
1147
1203
  optgroupClosableArrow.setAttribute('d', this.classes.arrowClose)
1148
1204
  }
@@ -1156,8 +1212,8 @@ export default class Render {
1156
1212
  optgroupEl.appendChild(optgroupLabel)
1157
1213
 
1158
1214
  // Loop through options
1159
- for (const o of d.options) {
1160
- optgroupEl.appendChild(this.option(o))
1215
+ for (const option of d.options) {
1216
+ optgroupEl.appendChild(this.option(new Option(option)))
1161
1217
  fragment.appendChild(optgroupEl)
1162
1218
  }
1163
1219
  }
@@ -1185,7 +1241,7 @@ export default class Render {
1185
1241
  // Create option
1186
1242
  const optionEl = document.createElement('div')
1187
1243
  optionEl.dataset.id = option.id // Dataset id for identifying an option
1188
- // optionEl.id = option.id // Remove for now as it is not needed and add duplicate id errors
1244
+ optionEl.id = this.settings.id + '-' + option.id // Unique ID for ARIA references
1189
1245
  optionEl.classList.add(this.classes.option)
1190
1246
  optionEl.setAttribute('role', 'option') // WCAG attribute
1191
1247
  if (option.class) {
@@ -1249,17 +1305,24 @@ export default class Render {
1249
1305
  const selectedOptions = this.store.getSelected()
1250
1306
  const element = e.currentTarget as HTMLDivElement
1251
1307
  const elementID = String(element.dataset.id)
1308
+ const isCmd = e.ctrlKey || e.metaKey // Cmd (Mac) or Ctrl (Windows/Linux)
1309
+
1310
+ // If the option is disabled, do nothing
1311
+ if (option.disabled) {
1312
+ return
1313
+ }
1252
1314
 
1253
- // If the option is disabled or selected and the user isnt allowed to deselect
1254
- if (option.disabled || (option.selected && !this.settings.allowDeselect)) {
1315
+ // allowDeselect only applies to single-select mode
1316
+ // In multi-select, you can always toggle options on/off
1317
+ if (!this.settings.isMultiple && option.selected && !this.settings.allowDeselect) {
1255
1318
  return
1256
1319
  }
1257
1320
 
1258
1321
  // Check limit and do nothing if limit is reached and the option is not selected
1259
- // Also check reverse for min limit and is selected
1322
+ // Also check reverse for min limit and is selected (allow Cmd to bypass minSelected)
1260
1323
  if (
1261
1324
  (this.settings.isMultiple && this.settings.maxSelected <= selectedOptions.length && !option.selected) ||
1262
- (this.settings.isMultiple && this.settings.minSelected >= selectedOptions.length && option.selected)
1325
+ (this.settings.isMultiple && this.settings.minSelected >= selectedOptions.length && option.selected && !isCmd)
1263
1326
  ) {
1264
1327
  return
1265
1328
  }
@@ -1269,35 +1332,56 @@ export default class Render {
1269
1332
  const before = this.store.getSelectedOptions()
1270
1333
  let after = [] as Option[]
1271
1334
 
1272
- // If multiple
1335
+ // If multiple - mimic native browser multi-select behavior
1273
1336
  if (this.settings.isMultiple) {
1274
- if (option.selected) {
1275
- // If selected after would remove
1276
- after = before.filter((o: Option) => o.id !== elementID)
1277
- } else {
1278
- // If not selected after would add
1279
- after = before.concat(option)
1280
-
1281
- // Handles range selection
1282
- if (!this.settings.closeOnSelect) {
1283
- if (e.shiftKey && this.lastSelectedOption) {
1284
- const options = this.store.getDataOptions()
1285
- let lastClickedOptionIndex = options.findIndex((o: Option) => o.id === this.lastSelectedOption!.id)
1286
- let currentOptionIndex = options.findIndex((o: Option) => o.id === option.id)
1287
- if (lastClickedOptionIndex >= 0 && currentOptionIndex >= 0) {
1288
- // Select the range from the last clicked option to the current one, or vice versa.
1289
- const startIndex = Math.min(lastClickedOptionIndex, currentOptionIndex)
1290
- const endIndex = Math.max(lastClickedOptionIndex, currentOptionIndex)
1291
- const afterRange = options.slice(startIndex, endIndex + 1)
1292
- if (afterRange.length > 0 && afterRange.length < this.settings.maxSelected) {
1293
- // Set the union between the current range and the previously selected values, removing duplicates
1294
- after = before.concat(afterRange.filter((a) => !before.find((b) => b.id === a.id)))
1295
- }
1296
- }
1297
- } else if (!option.selected) {
1298
- this.lastSelectedOption = option
1337
+ const isCurrentlySelected = before.some((o: Option) => o.id === elementID)
1338
+ const isShift = e.shiftKey
1339
+
1340
+ // Shift+Click: Select range from last clicked to current
1341
+ if (isShift && this.lastSelectedOption) {
1342
+ const options = this.store.getDataOptions()
1343
+ const lastIndex = options.findIndex((o: Option) => o.id === this.lastSelectedOption!.id)
1344
+ const currentIndex = options.findIndex((o: Option) => o.id === option.id)
1345
+
1346
+ if (lastIndex >= 0 && currentIndex >= 0) {
1347
+ const startIndex = Math.min(lastIndex, currentIndex)
1348
+ const endIndex = Math.max(lastIndex, currentIndex)
1349
+ const rangeOptions = options.slice(startIndex, endIndex + 1)
1350
+
1351
+ // Check if range would exceed maxSelected
1352
+ const newSelections = rangeOptions.filter((opt) => !before.find((b) => b.id === opt.id))
1353
+ if (before.length + newSelections.length <= this.settings.maxSelected) {
1354
+ // Add range to existing selections
1355
+ after = before.concat(newSelections)
1356
+ } else {
1357
+ // Range too large, keep existing selections
1358
+ after = before
1299
1359
  }
1360
+ } else {
1361
+ after = before
1362
+ }
1363
+ }
1364
+ // Cmd/Ctrl+Click: Toggle selection without affecting others (keeps dropdown open)
1365
+ else if (isCmd) {
1366
+ if (isCurrentlySelected) {
1367
+ // Deselect this option
1368
+ after = before.filter((o: Option) => o.id !== elementID)
1369
+ } else {
1370
+ // Add this option to selection
1371
+ after = before.concat(option)
1300
1372
  }
1373
+ this.lastSelectedOption = option
1374
+ }
1375
+ // Regular Click: Toggle this option (add/remove), will close dropdown
1376
+ else {
1377
+ if (isCurrentlySelected) {
1378
+ // Deselect this option
1379
+ after = before.filter((o: Option) => o.id !== elementID)
1380
+ } else {
1381
+ // Add this option to selection
1382
+ after = before.concat(option)
1383
+ }
1384
+ this.lastSelectedOption = option
1301
1385
  }
1302
1386
  }
1303
1387
 
@@ -1339,8 +1423,12 @@ export default class Render {
1339
1423
  false
1340
1424
  )
1341
1425
 
1342
- // If closeOnSelect is true
1343
- if (this.settings.closeOnSelect) {
1426
+ // Close dropdown unless using modifier keys in multi-select
1427
+ // (mimics native multi-select behavior where you can keep selecting)
1428
+ const isModifierKey = e.ctrlKey || e.metaKey || e.shiftKey // Cmd/Ctrl or Shift
1429
+ const shouldClose = this.settings.closeOnSelect && !(this.settings.isMultiple && isModifierKey)
1430
+
1431
+ if (shouldClose) {
1344
1432
  this.callbacks.close()
1345
1433
  }
1346
1434
 
@@ -1363,21 +1451,55 @@ export default class Render {
1363
1451
  }
1364
1452
 
1365
1453
  private highlightText(str: string, search: any, className: string) {
1366
- // the completed string will be itself if already set, otherwise, the string that was passed in
1367
- let completedString: any = str
1368
- const regex = new RegExp('(?![^<]*>)(' + search.trim() + ')(?![^<]*>[^<>]*</)', 'i')
1369
-
1370
- // If the regex doesn't match the string just exit
1371
- if (!str.match(regex)) {
1454
+ const searchTerm = search.trim()
1455
+ if (searchTerm === '') {
1372
1456
  return str
1373
1457
  }
1374
1458
 
1375
- // Otherwise, get to highlighting
1376
- const matchStartPosition = (str.match(regex) as any).index
1377
- const matchEndPosition = matchStartPosition + (str.match(regex) as any)[0].toString().length
1378
- const originalTextFoundByRegex = str.substring(matchStartPosition, matchEndPosition)
1379
- completedString = completedString.replace(regex, `<mark class="${className}">${originalTextFoundByRegex}</mark>`)
1380
- return completedString
1459
+ // Escape special regex characters in the search term to prevent regex injection
1460
+ const escapedSearch = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1461
+
1462
+ // Create a temporary div to parse HTML and work with text nodes only
1463
+ const tempDiv = document.createElement('div')
1464
+ tempDiv.innerHTML = str
1465
+
1466
+ // Function to recursively process text nodes
1467
+ const highlightTextNodes = (node: Node) => {
1468
+ if (node.nodeType === Node.TEXT_NODE) {
1469
+ const text = node.textContent || ''
1470
+ const regex = new RegExp('(' + escapedSearch + ')', 'i')
1471
+
1472
+ if (regex.test(text)) {
1473
+ // Create a temporary container for the highlighted content
1474
+ const wrapper = document.createElement('span')
1475
+ const parts = text.split(regex)
1476
+
1477
+ parts.forEach((part, index) => {
1478
+ if (part && regex.test(part)) {
1479
+ // This is the matched part - wrap it in mark
1480
+ const mark = document.createElement('mark')
1481
+ mark.className = className
1482
+ mark.textContent = part
1483
+ wrapper.appendChild(mark)
1484
+ } else if (part) {
1485
+ // This is not the matched part - keep as text
1486
+ wrapper.appendChild(document.createTextNode(part))
1487
+ }
1488
+ })
1489
+
1490
+ // Replace the text node with the wrapper
1491
+ node.parentNode?.replaceChild(wrapper, node)
1492
+ }
1493
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
1494
+ // Recursively process child nodes
1495
+ Array.from(node.childNodes).forEach((child) => highlightTextNodes(child))
1496
+ }
1497
+ }
1498
+
1499
+ // Process all text nodes in the temporary div
1500
+ Array.from(tempDiv.childNodes).forEach((node) => highlightTextNodes(node))
1501
+
1502
+ return tempDiv.innerHTML
1381
1503
  }
1382
1504
 
1383
1505
  public moveContentAbove(): void {
@@ -1385,11 +1507,11 @@ export default class Render {
1385
1507
  const mainHeight = this.main.main.offsetHeight
1386
1508
  const contentHeight = this.content.main.offsetHeight
1387
1509
 
1388
- // Set classes
1389
- this.main.main.classList.remove(this.classes.openBelow)
1390
- this.main.main.classList.add(this.classes.openAbove)
1391
- this.content.main.classList.remove(this.classes.openBelow)
1392
- this.content.main.classList.add(this.classes.openAbove)
1510
+ // Set direction classes on both main and content
1511
+ this.main.main.classList.remove(this.classes.dirBelow)
1512
+ this.main.main.classList.add(this.classes.dirAbove)
1513
+ this.content.main.classList.remove(this.classes.dirBelow)
1514
+ this.content.main.classList.add(this.classes.dirAbove)
1393
1515
 
1394
1516
  // Set the content position
1395
1517
  const containerRect = this.main.main.getBoundingClientRect()
@@ -1402,11 +1524,11 @@ export default class Render {
1402
1524
  }
1403
1525
 
1404
1526
  public moveContentBelow(): void {
1405
- // Set classes
1406
- this.main.main.classList.remove(this.classes.openAbove)
1407
- this.main.main.classList.add(this.classes.openBelow)
1408
- this.content.main.classList.remove(this.classes.openAbove)
1409
- this.content.main.classList.add(this.classes.openBelow)
1527
+ // Set direction classes on both main and content
1528
+ this.main.main.classList.remove(this.classes.dirAbove)
1529
+ this.main.main.classList.add(this.classes.dirBelow)
1530
+ this.content.main.classList.remove(this.classes.dirAbove)
1531
+ this.content.main.classList.add(this.classes.dirBelow)
1410
1532
 
1411
1533
  // Set the content position
1412
1534
  const containerRect = this.main.main.getBoundingClientRect()
@@ -4,7 +4,7 @@
4
4
 
5
5
  'use strict'
6
6
 
7
- import { describe, expect, jest, test } from '@jest/globals'
7
+ import { describe, expect, vi, test, beforeEach } from 'vitest'
8
8
  import Select from './select'
9
9
  import Store, { Optgroup, Option } from './store'
10
10
 
@@ -64,7 +64,15 @@ describe('select module', () => {
64
64
  select.hideUI()
65
65
 
66
66
  expect(select.select.tabIndex).toBe(-1)
67
- expect(select.select.style.display).toBe('none')
67
+ // Visually hidden but still focusable for form validation
68
+ expect(select.select.style.position).toBe('absolute')
69
+ expect(select.select.style.width).toBe('1px')
70
+ expect(select.select.style.height).toBe('1px')
71
+ expect(select.select.style.opacity).toBe('0')
72
+ expect(select.select.style.pointerEvents).toBe('none')
73
+ expect(select.select.style.margin).toBe('0px')
74
+ expect(select.select.style.padding).toBe('0px')
75
+ expect(select.select.style.clip).toContain('rect')
68
76
  expect(select.select.getAttribute('aria-hidden')).toBe('true')
69
77
  })
70
78
  })
@@ -72,13 +80,23 @@ describe('select module', () => {
72
80
  describe('showUI', () => {
73
81
  test('HTML attributes get reset', () => {
74
82
  select.select.tabIndex = -1
75
- select.select.style.display = 'none'
83
+ select.select.style.position = 'absolute'
84
+ select.select.style.width = '0'
85
+ select.select.style.opacity = '0'
86
+ select.select.style.margin = '0'
87
+ select.select.style.padding = '0'
88
+ select.select.style.borderWidth = '0'
76
89
  select.select.setAttribute('aria-hidden', 'true')
77
90
 
78
91
  select.showUI()
79
92
 
80
93
  expect(select.select.tabIndex).toBeFalsy()
81
- expect(select.select.style.display).toBeFalsy()
94
+ expect(select.select.style.position).toBeFalsy()
95
+ expect(select.select.style.width).toBeFalsy()
96
+ expect(select.select.style.opacity).toBeFalsy()
97
+ expect(select.select.style.margin).toBeFalsy()
98
+ expect(select.select.style.padding).toBeFalsy()
99
+ // borderWidth gets reset
82
100
  expect(select.select.getAttribute('aria-hidden')).toBeNull()
83
101
  })
84
102
  })
@@ -263,7 +281,7 @@ describe('select module', () => {
263
281
  let data = select.getData() as Option[]
264
282
  expect(data[0].selected).toBe(true)
265
283
 
266
- const onValueMock = jest.fn()
284
+ const onValueMock = vi.fn()
267
285
  select.onValueChange = onValueMock
268
286
 
269
287
  // Change the value
@@ -284,7 +302,7 @@ describe('select module', () => {
284
302
  })
285
303
 
286
304
  test('listener is triggered when inner HTML is replaced with new options', async () => {
287
- const onValueMock = jest.fn()
305
+ const onValueMock = vi.fn()
288
306
  select.onValueChange = onValueMock
289
307
 
290
308
  selectElement.innerHTML = `<option value="4">Four</option>
@@ -315,7 +333,7 @@ describe('select module', () => {
315
333
  let data = select.getData() as Option[]
316
334
  expect(data).toHaveLength(3)
317
335
 
318
- const onOptionsMock = jest.fn()
336
+ const onOptionsMock = vi.fn()
319
337
  select.onOptionsChange = onOptionsMock
320
338
 
321
339
  selectElement.innerHTML = '<option value="1">One</option><option value="2">Two</option>'
@@ -346,7 +364,7 @@ describe('select module', () => {
346
364
  const optGroups = select.getData() as Optgroup[]
347
365
  expect(optGroups).toHaveLength(2)
348
366
 
349
- const onOptionsMock = jest.fn()
367
+ const onOptionsMock = vi.fn()
350
368
  select.onOptionsChange = onOptionsMock
351
369
 
352
370
  const selectOptgroup = document.getElementById('test_optgroup') as HTMLOptGroupElement
@@ -376,7 +394,7 @@ describe('select module', () => {
376
394
  let data = select.getData() as Option[]
377
395
  expect(data[0].text).toBe('One')
378
396
 
379
- const onOptionsMock = jest.fn()
397
+ const onOptionsMock = vi.fn()
380
398
  select.onOptionsChange = onOptionsMock
381
399
 
382
400
  let option = selectElement.options[0]
@@ -409,7 +427,7 @@ describe('select module', () => {
409
427
 
410
428
  expect(dataOptgroup[0].options[0].text).toBe('One')
411
429
 
412
- const onOptionsMock = jest.fn()
430
+ const onOptionsMock = vi.fn()
413
431
  select.onOptionsChange = onOptionsMock
414
432
 
415
433
  let selectOption = document.getElementById('test_option') as HTMLOptionElement