slim-select 2.10.0 → 2.12.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "slim-select",
3
3
  "description": "Slim advanced select dropdown",
4
- "version": "2.10.0",
4
+ "version": "2.12.0",
5
5
  "author": "Brian Voelker <brian@webiswhatido.com> (http://webiswhatido.com)",
6
6
  "homepage": "https://slimselectjs.com",
7
7
  "license": "MIT",
@@ -28,9 +28,9 @@
28
28
  "sass": "src/slim-select/slimselect.scss",
29
29
  "exports": {
30
30
  ".": {
31
+ "types": "./dist/index.d.ts",
31
32
  "import": "./dist/slimselect.js",
32
- "require": "./dist/slimselect.umd.js",
33
- "types": "./dist/index.d.ts"
33
+ "require": "./dist/slimselect.umd.js"
34
34
  },
35
35
  "./*": {
36
36
  "types": "./dist/*.d.ts"
@@ -51,45 +51,45 @@
51
51
  "test:coverage": "jest --coverage"
52
52
  },
53
53
  "devDependencies": {
54
- "@jest/globals": "^29.7.0",
54
+ "@jest/globals": "^30.0.2",
55
55
  "@rollup/plugin-babel": "^6.0.4",
56
- "@rollup/plugin-commonjs": "^28.0.1",
57
- "@rollup/plugin-node-resolve": "^15.3.0",
58
- "@rollup/plugin-replace": "^6.0.1",
56
+ "@rollup/plugin-commonjs": "^28.0.6",
57
+ "@rollup/plugin-node-resolve": "^16.0.1",
58
+ "@rollup/plugin-replace": "^6.0.2",
59
59
  "@rollup/plugin-terser": "^0.4.4",
60
- "@rollup/plugin-typescript": "^12.1.1",
60
+ "@rollup/plugin-typescript": "^12.1.3",
61
61
  "@testing-library/jest-dom": "^6.6.3",
62
- "@testing-library/user-event": "^14.5.2",
62
+ "@testing-library/user-event": "^14.6.1",
63
63
  "@types/downloadjs": "^1.4.6",
64
- "@types/jest": "^29.5.14",
65
- "@typescript-eslint/eslint-plugin": "^8.8.1",
66
- "@typescript-eslint/parser": "^8.8.1",
67
- "@vitejs/plugin-vue": "^5.2.0",
64
+ "@types/jest": "^30.0.0",
65
+ "@typescript-eslint/eslint-plugin": "^8.34.1",
66
+ "@typescript-eslint/parser": "^8.34.1",
67
+ "@vitejs/plugin-vue": "^5.2.4",
68
68
  "clipboard": "^2.0.11",
69
69
  "downloadjs": "^1.4.7",
70
- "eslint": "^9.12.0",
71
- "eslint-config-prettier": "^9.1.0",
72
- "eslint-plugin-prettier": "^5.2.1",
70
+ "eslint": "^9.29.0",
71
+ "eslint-config-prettier": "^10.1.5",
72
+ "eslint-plugin-prettier": "^5.5.0",
73
73
  "identity-obj-proxy": "^3.0.0",
74
- "jest": "^29.7.0",
74
+ "jest": "^30.0.2",
75
75
  "jest-canvas-mock": "^2.5.0",
76
- "jest-environment-jsdom": "^29.7.0",
77
- "prettier": "^3.3.3",
78
- "prismjs": "^1.29.0",
76
+ "jest-environment-jsdom": "^30.0.2",
77
+ "prettier": "^3.5.3",
78
+ "prismjs": "^1.30.0",
79
79
  "rimraf": "^6.0.1",
80
- "rollup": "^4.27.3",
80
+ "rollup": "^4.44.0",
81
81
  "rollup-plugin-peer-deps-external": "^2.2.4",
82
82
  "rollup-plugin-typescript2": "^0.36.0",
83
83
  "rollup-plugin-vue": "^6.0.0",
84
- "sass": "^1.81.0",
85
- "ts-jest": "^29.2.5",
84
+ "sass": "^1.89.2",
85
+ "ts-jest": "^29.4.0",
86
86
  "tslib": "^2.8.1",
87
- "typescript": "^5.6.3",
88
- "vite": "^5.4.11",
89
- "vite-plugin-dts": "^4.3.0",
90
- "vue": "^3.5.13",
91
- "vue-router": "^4.4.5",
92
- "vue-tsc": "^2.1.10",
87
+ "typescript": "^5.8.3",
88
+ "vite": "^6.3.5",
89
+ "vite-plugin-dts": "^4.5.4",
90
+ "vue": "^3.5.17",
91
+ "vue-router": "^4.5.1",
92
+ "vue-tsc": "^2.2.10",
93
93
  "vuex": "^4.0.2"
94
94
  }
95
95
  }
@@ -200,7 +200,7 @@ describe('render module', () => {
200
200
 
201
201
  expect(render.main.main.role).toBe('combobox')
202
202
  expect(render.main.main.getAttribute('aria-haspopup')).toBe('listbox')
203
- expect(render.main.main.getAttribute('aria-controls')).toBe(render.content.main.id)
203
+ expect(render.main.main.getAttribute('aria-controls')).toBe(render.content.main.dataset.id ?? '')
204
204
  expect(render.main.main.getAttribute('aria-expanded')).toBe('false')
205
205
  expect(render.content.main.getAttribute('role')).toBe('listbox')
206
206
  })
@@ -1142,6 +1142,104 @@ describe('render module', () => {
1142
1142
  })
1143
1143
  })
1144
1144
 
1145
+ describe('range selection', () => {
1146
+ let render: Render
1147
+ let afterChangeMock: jest.Mock
1148
+
1149
+ beforeEach(() => {
1150
+ // create a new store with 3 options
1151
+ const store = new Store('multiple', [
1152
+ { text: 'test1', value: 'test1' },
1153
+ { text: 'test2', value: 'test2' },
1154
+ { text: 'test3', value: 'test3' }
1155
+ ])
1156
+
1157
+ const settings = new Settings()
1158
+ const classes = new CssClasses()
1159
+
1160
+ afterChangeMock = jest.fn(() => {})
1161
+
1162
+ const callbacks = {
1163
+ open: () => {},
1164
+ close: () => {},
1165
+ addSelected: () => {},
1166
+ setSelected: (value) => {
1167
+ store.setSelectedBy('id', typeof value === 'string' ? [value] : value)
1168
+ },
1169
+ addOption: () => {},
1170
+ search: () => {},
1171
+ afterChange: afterChangeMock
1172
+ } as Callbacks
1173
+
1174
+ render = new Render(settings, classes, store, callbacks)
1175
+ render.settings.isMultiple = true
1176
+ render.settings.closeOnSelect = false
1177
+ })
1178
+
1179
+ test('click holding shift key selects range from last clicked to current', () => {
1180
+ render.renderOptions(render.store.getDataOptions())
1181
+
1182
+ const opts = render.getOptions(false, false, true)
1183
+ expect(opts).toHaveLength(3)
1184
+
1185
+ // click on the first option
1186
+ opts[0].dispatchEvent(new MouseEvent('click'))
1187
+ expect(afterChangeMock).toHaveBeenCalledWith([expect.objectContaining({ value: 'test1' })])
1188
+
1189
+ // click on the last option holding the shift key
1190
+ opts[2].dispatchEvent(new MouseEvent('click', { shiftKey: true }))
1191
+
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
+ ])
1198
+ })
1199
+
1200
+ test('click holding shift key selects range from current to last clicked', () => {
1201
+ render.renderOptions(render.store.getDataOptions())
1202
+
1203
+ const opts = render.getOptions(false, false, true)
1204
+ expect(opts).toHaveLength(3)
1205
+
1206
+ // click on the last option
1207
+ opts[2].dispatchEvent(new MouseEvent('click'))
1208
+ expect(afterChangeMock).toHaveBeenCalledWith([expect.objectContaining({ value: 'test3' })])
1209
+
1210
+ // click on the first option holding the shift key
1211
+ opts[0].dispatchEvent(new MouseEvent('click', { shiftKey: true }))
1212
+
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
+ ])
1219
+ })
1220
+
1221
+ test('range selection is ignored if range length is greater than maxSelected', () => {
1222
+ render.settings.maxSelected = 2
1223
+ render.renderOptions(render.store.getDataOptions())
1224
+
1225
+ const opts = render.getOptions(false, false, true)
1226
+ expect(opts).toHaveLength(3)
1227
+
1228
+ // click on the first option
1229
+ opts[0].dispatchEvent(new MouseEvent('click'))
1230
+ expect(afterChangeMock).toHaveBeenCalledWith([expect.objectContaining({ value: 'test1' })])
1231
+
1232
+ // click on the last option holding the shift key
1233
+ opts[2].dispatchEvent(new MouseEvent('click', { shiftKey: true }))
1234
+
1235
+ // check that the middle value is NOT selected
1236
+ expect(afterChangeMock).toHaveBeenCalledWith([
1237
+ expect.objectContaining({ value: 'test1' }),
1238
+ expect.objectContaining({ value: 'test3' })
1239
+ ])
1240
+ })
1241
+ })
1242
+
1145
1243
  describe('destroy', () => {
1146
1244
  test('elements get removed', () => {
1147
1245
  expect(render.main.main).toBeInstanceOf(HTMLDivElement)
@@ -50,6 +50,8 @@ export default class Render {
50
50
  public settings: Settings
51
51
  public store: Store
52
52
  public callbacks: Callbacks
53
+ // Used to compute the range selection
54
+ private lastSelectedOption: Option | null
53
55
 
54
56
  // Elements
55
57
  public main: Main
@@ -63,6 +65,7 @@ export default class Render {
63
65
  this.settings = settings
64
66
  this.classes = classes
65
67
  this.callbacks = callbacks
68
+ this.lastSelectedOption = null
66
69
 
67
70
  this.main = this.mainDiv()
68
71
  this.content = this.contentDiv()
@@ -162,7 +165,7 @@ export default class Render {
162
165
  public updateAriaAttributes() {
163
166
  this.main.main.role = 'combobox'
164
167
  this.main.main.setAttribute('aria-haspopup', 'listbox')
165
- this.main.main.setAttribute('aria-controls', this.content.main.id)
168
+ this.main.main.setAttribute('aria-controls', this.content.main.dataset.id ?? '')
166
169
  this.main.main.setAttribute('aria-expanded', 'false')
167
170
  this.content.main.setAttribute('role', 'listbox')
168
171
  // do an aria-labelledby here maybe
@@ -517,6 +520,7 @@ export default class Render {
517
520
  // Create delete div element
518
521
  const deleteDiv = document.createElement('div')
519
522
  deleteDiv.classList.add(this.classes.valueDelete)
523
+ deleteDiv.setAttribute('tabindex', '0') // Make the div focusable for tab navigation
520
524
 
521
525
  // Add delete onclick event
522
526
  deleteDiv.onclick = (e: Event) => {
@@ -584,6 +588,13 @@ export default class Render {
584
588
  deleteDiv.appendChild(deleteSvg)
585
589
 
586
590
  value.appendChild(deleteDiv)
591
+
592
+ // Add keydown event listener for keyboard navigation (Enter key)
593
+ deleteDiv.onkeydown = (e) => {
594
+ if (e.key === 'Enter') {
595
+ deleteDiv.click() // Trigger the click event when Enter is pressed
596
+ }
597
+ }
587
598
  }
588
599
 
589
600
  return value
@@ -983,6 +994,7 @@ export default class Render {
983
994
  }
984
995
 
985
996
  // Append individual options to div container
997
+ const fragment = document.createDocumentFragment()
986
998
  for (const d of data) {
987
999
  // Create optgroup
988
1000
  if (d instanceof Optgroup) {
@@ -1146,17 +1158,18 @@ export default class Render {
1146
1158
  // Loop through options
1147
1159
  for (const o of d.options) {
1148
1160
  optgroupEl.appendChild(this.option(o))
1161
+ fragment.appendChild(optgroupEl)
1149
1162
  }
1150
-
1151
- // Add optgroup to list
1152
- this.content.list.appendChild(optgroupEl)
1153
1163
  }
1154
1164
 
1155
1165
  // Create option
1156
1166
  if (d instanceof Option) {
1157
- this.content.list.appendChild(this.option(d as Option))
1167
+ fragment.appendChild(this.option(d as Option))
1158
1168
  }
1159
1169
  }
1170
+
1171
+ // Append fragment to list
1172
+ this.content.list.appendChild(fragment)
1160
1173
  }
1161
1174
 
1162
1175
  // Create option div element
@@ -1264,6 +1277,27 @@ export default class Render {
1264
1277
  } else {
1265
1278
  // If not selected after would add
1266
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
1299
+ }
1300
+ }
1267
1301
  }
1268
1302
  }
1269
1303
 
@@ -196,6 +196,7 @@ export default class Select {
196
196
  value: option.value,
197
197
  text: option.text,
198
198
  html: option.dataset && option.dataset.html ? option.dataset.html : '',
199
+ defaultSelected: option.defaultSelected,
199
200
  selected: option.selected,
200
201
  display: option.style.display !== 'none',
201
202
  disabled: option.disabled,
@@ -378,9 +379,8 @@ export default class Select {
378
379
  if (info.html !== '') {
379
380
  optionEl.setAttribute('data-html', info.html)
380
381
  }
381
- if (info.selected) {
382
- optionEl.selected = info.selected
383
- }
382
+ optionEl.defaultSelected = info.defaultSelected
383
+ optionEl.selected = info.selected // Explicitly set selected because defaultSelected: true sets selected to true
384
384
  if (info.disabled) {
385
385
  optionEl.disabled = true
386
386
  }
@@ -49,6 +49,7 @@ export interface OptionOptional {
49
49
  text: string // Required
50
50
  html?: string
51
51
  selected?: boolean
52
+ defaultSelected?: boolean
52
53
  display?: boolean
53
54
  disabled?: boolean
54
55
  mandatory?: boolean
@@ -63,6 +64,7 @@ export class Option {
63
64
  value: string
64
65
  text: string
65
66
  html: string
67
+ defaultSelected: boolean
66
68
  selected: boolean
67
69
  display: boolean
68
70
  disabled: boolean
@@ -77,6 +79,7 @@ export class Option {
77
79
  this.value = option.value === undefined ? option.text : option.value
78
80
  this.text = option.text || ''
79
81
  this.html = option.html || ''
82
+ this.defaultSelected = option.defaultSelected !== undefined ? option.defaultSelected : false
80
83
  this.selected = option.selected !== undefined ? option.selected : false
81
84
  this.display = option.display !== undefined ? option.display : true
82
85
  this.disabled = option.disabled !== undefined ? option.disabled : false