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.
- package/README.md +185 -44
- package/dist/index.d.ts +304 -52
- package/dist/react/index.d.ts +304 -0
- package/dist/react/index.js +1549 -0
- package/dist/react/index.umd.js +22 -0
- package/dist/slimselect.cjs.js +2 -2030
- package/dist/slimselect.cjs.js.map +1 -0
- package/dist/slimselect.css +1 -1
- package/dist/slimselect.css.map +1 -1
- package/dist/slimselect.es.js +1225 -2012
- package/dist/slimselect.es.js.map +1 -0
- package/dist/slimselect.iife.js +2 -0
- package/dist/slimselect.iife.js.map +1 -0
- package/dist/slimselect.js +2 -2036
- package/dist/slimselect.js.map +1 -0
- package/dist/vue/index.d.ts +304 -0
- package/dist/vue/index.js +1341 -0
- package/dist/vue/index.umd.js +1 -0
- package/index.html +1 -0
- package/package.json +63 -40
- package/release.js +0 -7
- package/src/slim-select/accessibility.test.ts +782 -0
- package/src/slim-select/classes.test.ts +7 -6
- package/src/slim-select/classes.ts +8 -6
- package/src/slim-select/helpers.test.ts +10 -8
- package/src/slim-select/index.test.ts +509 -14
- package/src/slim-select/index.ts +58 -31
- package/src/slim-select/react/index.tsx +9 -0
- package/src/slim-select/react/react.tsx +112 -0
- package/src/slim-select/render.test.ts +479 -119
- package/src/slim-select/render.ts +213 -91
- package/src/slim-select/select.test.ts +28 -10
- package/src/slim-select/select.ts +35 -12
- package/src/slim-select/settings.test.ts +19 -8
- package/src/slim-select/settings.ts +2 -4
- package/src/slim-select/slimselect.scss +30 -15
- package/src/slim-select/store.test.ts +13 -16
- package/src/slim-select/store.ts +97 -86
- package/src/slim-select/vue/index.ts +9 -0
- package/src/slim-select/vue/vue.test.ts +832 -0
- package/src/slim-select/{vue.vue → vue/vue.vue} +45 -23
- package/tsconfig.json +6 -4
- package/{vite.config.mts → vite.config.docs.mts} +10 -0
- package/vite.config.lib.mts +48 -0
- package/vite.config.react.mts +40 -0
- package/vite.config.vue.mts +39 -0
- package/vitest.config.ts +29 -0
- package/dist/classes.d.ts +0 -43
- package/dist/helpers.d.ts +0 -5
- package/dist/render.d.ts +0 -81
- package/dist/select.d.ts +0 -30
- package/dist/settings.d.ts +0 -35
- package/dist/slimselect.global.js +0 -2033
- package/dist/slimselect.min.js +0 -1
- package/dist/slimselect.umd.js +0 -2036
- package/dist/slimselect.umd.min.js +0 -1
- package/dist/store.d.ts +0 -84
- package/jest.config.js +0 -14
- package/src/slim-select/rollup.config.mjs +0 -127
- 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, {
|
|
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<
|
|
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',
|
|
188
|
+
this.main.main.setAttribute('aria-controls', listboxId)
|
|
169
189
|
this.main.main.setAttribute('aria-expanded', 'false')
|
|
170
|
-
|
|
171
|
-
|
|
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')
|
|
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
|
-
|
|
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()
|
|
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
|
|
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:
|
|
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.
|
|
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]
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
1198
|
+
optgroupEl.classList.add(this.classes.mainOpen)
|
|
1143
1199
|
optgroupClosableArrow.setAttribute('d', this.classes.arrowOpen)
|
|
1144
1200
|
} else {
|
|
1145
|
-
optgroupEl.classList.remove(this.classes.
|
|
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
|
|
1160
|
-
optgroupEl.appendChild(this.option(
|
|
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
|
-
|
|
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
|
-
//
|
|
1254
|
-
|
|
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
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
-
//
|
|
1343
|
-
|
|
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
|
-
|
|
1367
|
-
|
|
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
|
-
//
|
|
1376
|
-
const
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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.
|
|
1390
|
-
this.main.main.classList.add(this.classes.
|
|
1391
|
-
this.content.main.classList.remove(this.classes.
|
|
1392
|
-
this.content.main.classList.add(this.classes.
|
|
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.
|
|
1407
|
-
this.main.main.classList.add(this.classes.
|
|
1408
|
-
this.content.main.classList.remove(this.classes.
|
|
1409
|
-
this.content.main.classList.add(this.classes.
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
430
|
+
const onOptionsMock = vi.fn()
|
|
413
431
|
select.onOptionsChange = onOptionsMock
|
|
414
432
|
|
|
415
433
|
let selectOption = document.getElementById('test_option') as HTMLOptionElement
|