filtered-select-multiple-widget 0.0.3 → 0.0.6

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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Filtered Select Multiple Widget
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/filtered-select-multiple-widget)](https://www.npmjs.com/package/filtered-select-multiple-widget)
4
+ [![license](https://img.shields.io/npm/l/filtered-select-multiple-widget)](https://github.com/tombreit/filtered-select-multiple-widget/blob/main/LICENSE)
5
+ [![Publish](https://github.com/tombreit/filtered-select-multiple-widget/actions/workflows/publish.yml/badge.svg)](https://github.com/tombreit/filtered-select-multiple-widget/actions/workflows/publish.yml)
6
+ [![Deploy](https://github.com/tombreit/filtered-select-multiple-widget/actions/workflows/deploy.yml/badge.svg)](https://github.com/tombreit/filtered-select-multiple-widget/actions/workflows/deploy.yml)
7
+
3
8
  A dependency-free, JavaScript implementation of Django's dual-pane “FilteredSelectMultiple” widget. It transforms a native `<select multiple>` element into a picker with move buttons and client-side filtering.
4
9
 
5
10
  ![Screenshot Filtered Select Multiple Widget](./docs/filtered-select-multiple-widget-screenshot.png)
@@ -27,10 +32,14 @@ The Django widget is a thousand times better than mine. But I hope to gradually
27
32
 
28
33
  - Zero dependencies and ES module friendly.
29
34
  - Works with any native `<select multiple>` element.
30
- - Optional client-side filtering of the available choices.
31
- - Keyboard friendly and form-ready (synced back to the original select element).
35
+ - Optional client-side filtering of the available choices (with debounce).
36
+ - Keyboard friendly: select items and press Enter to transfer. Form-ready (synced back to the original select element).
37
+ - Ships default CSS — no extra stylesheet needed for out-of-the-box styling.
32
38
  - Integrates with Bootstrap 5, DaisyUI, and Tailwind CSS (see live demo with theme switcher).
33
39
  - Automatically detects pane labels from associated `<label>` elements.
40
+ - Items count indicator on each pane.
41
+ - Accessible: `aria-labelledby`, `aria-label`, `aria-live` regions.
42
+ - Disabled `<option>` elements are respected and excluded from bulk transfers.
34
43
 
35
44
  ## Demo
36
45
 
@@ -44,7 +53,7 @@ The demo includes a theme switcher to showcase integration with Bootstrap 5, Dai
44
53
  ## Installation
45
54
 
46
55
  ```bash
47
- npm install git+https://github.com/tombreit/filtered-select-multiple-widget.git
56
+ npm install filtered-select-multiple-widget
48
57
  ```
49
58
 
50
59
  ## Usage
@@ -68,12 +77,17 @@ const widget = new FilteredSelectMultiple(select);
68
77
 
69
78
  | Option | Type | Default | Description |
70
79
  | ------ | ---- | ------- | ----------- |
71
- | `showFilter` | `boolean` | `true` | Toggle the search box above the available list. |
72
- | `filterMatchMode` | `'contains' \| 'startsWith'` | `'contains'` | How filtering behaves. |
73
- | `size` | `number` | `select.size \|\| clamp(optionCount)` | Number of visible rows for each list. |
80
+ | `showFilter` | `boolean` | `true` | Toggle the search box above each list. |
81
+ | `filterMatchMode` | `'contains' \| 'startsWith'` | `'contains'` | How filtering behaves. `'contains'` supports multiple space-separated tokens. |
82
+ | `size` | `number` | `select.size \|\| clamp(optionCount, 4, 12)` | Number of visible rows for each list. |
74
83
  | `preserveSelectionOrder` | `boolean` | `false` | Keep the order items were added in the chosen list rather than the original option order. |
75
84
  | `text` | `object` | see defaults | Override UI copy (`availableLabel`, `chosenLabel`, `filterPlaceholder`, `availableFilterPlaceholder`, `chosenFilterPlaceholder`, `addAll`, `addSelected`, `removeSelected`, `removeAll`). If `availableLabel` and `chosenLabel` are not provided, the widget will attempt to auto-detect them from an associated `<label>` element. |
76
- | `theme` | `Theme` | `defaultTheme` | CSS theme configuration for styling framework integration. |
85
+ | `theme` | `object` | `defaultTheme` | CSS theme configuration for styling framework integration. |
86
+
87
+ ### Keyboard Support
88
+
89
+ - **Enter** on either select list: transfers the currently selected items.
90
+ - **Double-click** on an option: transfers that single item.
77
91
 
78
92
  ### Theming
79
93
 
@@ -99,32 +113,40 @@ new FilteredSelectMultiple(select, { theme: tailwindTheme });
99
113
 
100
114
  #### Custom Themes
101
115
 
102
- ```js
103
- import { Theme } from "filtered-select-multiple-widget";
116
+ A theme is a plain object mapping element roles to CSS class strings. Any keys you omit are filled in from `defaultTheme`:
104
117
 
105
- const customTheme = new Theme({
118
+ ```js
119
+ const customTheme = {
106
120
  container: "my-widget-container",
107
121
  button: "my-button-class",
108
- buttonAddSelected: "my-button-class my-primary-button",
122
+ buttonAddSelected: "my-primary-button",
109
123
  filter: "my-input-class",
110
124
  select: "my-select-class",
111
- // ... customize any element
112
- });
125
+ // ... only override what you need
126
+ };
113
127
 
114
128
  new FilteredSelectMultiple(select, { theme: customTheme });
115
129
  ```
116
130
 
117
- #### Available Theme Elements
118
-
119
- - `container` - Main widget wrapper
120
- - `column`, `availableColumn`, `chosenColumn` - Pane containers
121
- - `label` - Pane labels
122
- - `filter` - Search input fields
123
- - `select` - Multi-select elements
124
- - `controls` - Button container
125
- - `button`, `buttonAddAll`, `buttonAddSelected`, `buttonRemoveSelected`, `buttonRemoveAll` - Control buttons
126
- - `buttonDisabled` - Disabled button modifier
127
- - `button_svg` - Button SVG icon
131
+ #### Available Theme Keys
132
+
133
+ | Key | Element |
134
+ | --- | ------- |
135
+ | `container` | Main widget wrapper |
136
+ | `column` | Pane column |
137
+ | `availableColumn` | Available pane column modifier |
138
+ | `chosenColumn` | Chosen pane column modifier |
139
+ | `label` | Pane labels |
140
+ | `counter` | Item count indicator inside labels |
141
+ | `filter` | Search input fields |
142
+ | `select` | Multi-select elements |
143
+ | `controls` | Button container |
144
+ | `button` | Base button class (applied to all buttons) |
145
+ | `buttonAddAll` | "Add all" button |
146
+ | `buttonAddSelected` | "Add selected" button |
147
+ | `buttonRemoveSelected` | "Remove selected" button |
148
+ | `buttonRemoveAll` | "Remove all" button |
149
+ | `buttonDisabled` | Disabled button modifier |
128
150
 
129
151
  ### Destroying the widget
130
152
 
@@ -132,7 +154,11 @@ new FilteredSelectMultiple(select, { theme: customTheme });
132
154
  widget.destroy();
133
155
  ```
134
156
 
135
- The original `<select>` is restored and remains in sync with user selections.
157
+ The original `<select>` is restored and all event listeners are cleaned up.
158
+
159
+ ## Note on `<optgroup>`
160
+
161
+ The widget currently flattens `<optgroup>` structures — all options are displayed in a single flat list regardless of their original grouping. Grouped display is not yet supported.
136
162
 
137
163
  ## Third-Party Assets
138
164
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filtered-select-multiple-widget",
3
- "version": "0.0.3",
3
+ "version": "0.0.6",
4
4
  "description": "ES module implementation of Django's FilteredSelectMultiple widget.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -21,14 +21,20 @@
21
21
  "homepage": "https://tombreit.github.io/filtered-select-multiple-widget/",
22
22
  "repository": {
23
23
  "type": "git",
24
- "url": "https://github.com/tombreit/filtered-select-multiple-widget.git"
24
+ "url": "git+https://github.com/tombreit/filtered-select-multiple-widget.git"
25
25
  },
26
26
  "bugs": {
27
27
  "url": "https://github.com/tombreit/filtered-select-multiple-widget/issues"
28
28
  },
29
29
  "keywords": [
30
30
  "widget",
31
- "multiselect"
31
+ "multiselect",
32
+ "select",
33
+ "multiple",
34
+ "dual-list",
35
+ "filter",
36
+ "django",
37
+ "transfer-list"
32
38
  ],
33
39
  "exports": {
34
40
  ".": {
@@ -40,10 +46,10 @@
40
46
  "http-server": "^14.1.1"
41
47
  },
42
48
  "scripts": {
43
- "test": "node --check src/index.js && node --check src/FilteredSelectMultiple.js && node --check src/themes.js",
44
- "pre-demo": "ln -sf ../src docs/src",
49
+ "test": "node --check src/index.js && node --check src/FilteredSelectMultiple.js && node --check src/themes.js && node --check src/icons.js",
50
+ "predemo": "ln -sfn ../src docs/src",
45
51
  "demo": "http-server docs/ -p 8000 -o",
46
- "pre-ghpages": "rm -rf docs/src",
52
+ "preghpages": "rm -rf docs/src",
47
53
  "ghpages": "cp -r src docs/src"
48
54
  }
49
55
  }
@@ -1,31 +1,142 @@
1
1
  import { defaultTheme } from "./themes.js";
2
+ import { icons } from "./icons.js";
3
+
4
+ /**
5
+ * Default CSS injected once when the default theme is used.
6
+ * Avoids the need for users to ship a separate .css file.
7
+ */
8
+ const DEFAULT_CSS = `
9
+ .filtered-select-multiple {
10
+ display: flex;
11
+ gap: 1rem;
12
+ align-items: stretch;
13
+ flex-wrap: wrap;
14
+ }
15
+ .fsm-column {
16
+ display: flex;
17
+ flex-direction: column;
18
+ gap: 0.5rem;
19
+ min-width: 220px;
20
+ flex: 1 1 220px;
21
+ }
22
+ .fsm-label {
23
+ font-weight: 600;
24
+ }
25
+ .fsm-counter {
26
+ font-size: 0.85em;
27
+ opacity: 0.7;
28
+ font-weight: normal;
29
+ }
30
+ .fsm-filter {
31
+ padding: 0.5rem;
32
+ border-radius: 6px;
33
+ border: 1px solid #d0d6e0;
34
+ }
35
+ .fsm-select {
36
+ min-width: 220px;
37
+ padding: 0.25rem;
38
+ border-radius: 6px;
39
+ border: 1px solid #d0d6e0;
40
+ background: #fff;
41
+ flex: 1 1 auto;
42
+ }
43
+ .fsm-controls {
44
+ display: flex;
45
+ flex-direction: column;
46
+ gap: 0.5rem;
47
+ justify-content: center;
48
+ }
49
+ .fsm-button {
50
+ display: inline-flex;
51
+ align-items: center;
52
+ gap: 0.4em;
53
+ padding: 0.5rem 0.75rem;
54
+ border-radius: 6px;
55
+ border: none;
56
+ background: #0052cc;
57
+ color: #fff;
58
+ cursor: pointer;
59
+ transition: background 0.2s ease-in-out;
60
+ white-space: nowrap;
61
+ }
62
+ .fsm-button svg {
63
+ height: 1.2em;
64
+ width: auto;
65
+ flex-shrink: 0;
66
+ }
67
+ .fsm-button:disabled,
68
+ .fsm-button-disabled {
69
+ cursor: not-allowed;
70
+ background: #cbd5f5;
71
+ color: #49526f;
72
+ }
73
+ .fsm-button:not(:disabled):hover {
74
+ background: #0a66e6;
75
+ }
76
+ .fsm-add-all,
77
+ .fsm-add-selected {
78
+ justify-content: flex-end;
79
+ }
80
+ .fsm-remove-selected,
81
+ .fsm-remove-all {
82
+ justify-content: flex-start;
83
+ }
84
+ @media (max-width: 640px) {
85
+ .filtered-select-multiple {
86
+ flex-direction: column;
87
+ align-items: stretch;
88
+ }
89
+ .fsm-controls {
90
+ flex-direction: row;
91
+ justify-content: center;
92
+ }
93
+ }
94
+ `;
95
+
96
+ let defaultCssInjected = false;
97
+
98
+ function injectDefaultCSS(doc) {
99
+ if (defaultCssInjected) return;
100
+ const style = doc.createElement("style");
101
+ style.setAttribute("data-fsm-default-theme", "");
102
+ style.textContent = DEFAULT_CSS;
103
+ doc.head.appendChild(style);
104
+ defaultCssInjected = true;
105
+ }
2
106
 
3
107
  /**
4
108
  * Transform a native <select multiple> element into a dual-list transfer widget.
5
- *
109
+ *
6
110
  * Usage:
7
111
  * import { FilteredSelectMultiple } from "filtered-select-multiple-widget";
8
112
  * const widget = new FilteredSelectMultiple(selectElement, options);
9
113
  */
10
114
  export class FilteredSelectMultiple {
115
+ /** @type {AbortController} – used to clean up all event listeners on destroy */
116
+ #abortController = new AbortController();
117
+
11
118
  constructor(selectElement, options = {}) {
12
- // Validate
13
119
  if (!(selectElement instanceof HTMLSelectElement) || !selectElement.multiple) {
14
120
  throw new Error("FilteredSelectMultiple requires a <select multiple> element");
15
121
  }
16
122
 
17
123
  this.selectElement = selectElement;
18
124
  this.doc = selectElement.ownerDocument;
19
-
20
- // Parse options
125
+
126
+ // Merge the provided theme on top of defaults so missing keys are filled.
127
+ this.theme = { ...defaultTheme, ...(options.theme ?? {}) };
128
+
129
+ // Always inject default CSS — its .fsm-* selectors won't collide with
130
+ // framework themes that use their own class names.
131
+ injectDefaultCSS(this.doc);
132
+
21
133
  const optionCount = selectElement.options.length;
22
134
  this.showFilter = options.showFilter ?? true;
23
135
  this.filterMatchMode = options.filterMatchMode ?? "contains";
24
136
  this.size = options.size ?? (selectElement.size || Math.min(Math.max(optionCount, 4), 12));
25
137
  this.preserveSelectionOrder = options.preserveSelectionOrder ?? false;
26
- this.theme = { ...defaultTheme, ...options.theme };
27
-
28
- // Parse text options
138
+
139
+ // Text / labels
29
140
  const textDefaults = {
30
141
  availableLabel: "Available",
31
142
  chosenLabel: "Chosen",
@@ -36,10 +147,12 @@ export class FilteredSelectMultiple {
36
147
  removeAll: "Remove all",
37
148
  };
38
149
  this.text = { ...textDefaults, ...(options.text || {}) };
39
-
40
- // Auto-detect labels from <label> element if defaults are used
41
- if (this.text.availableLabel === textDefaults.availableLabel &&
42
- this.text.chosenLabel === textDefaults.chosenLabel) {
150
+
151
+ // Auto-detect labels from an associated <label> element
152
+ if (
153
+ this.text.availableLabel === textDefaults.availableLabel &&
154
+ this.text.chosenLabel === textDefaults.chosenLabel
155
+ ) {
43
156
  const label = this.doc.querySelector(`label[for="${selectElement.id}"]`);
44
157
  if (label) {
45
158
  const baseLabel = label.textContent.trim();
@@ -47,42 +160,43 @@ export class FilteredSelectMultiple {
47
160
  this.text.chosenLabel = `Chosen ${baseLabel}`;
48
161
  }
49
162
  }
50
-
51
- // Set filter placeholder defaults
163
+
52
164
  const fallbackFilter = options.text?.filterPlaceholder ?? textDefaults.filterPlaceholder;
53
165
  this.text.availableFilterPlaceholder = this.text.availableFilterPlaceholder ?? fallbackFilter;
54
166
  this.text.chosenFilterPlaceholder = this.text.chosenFilterPlaceholder ?? fallbackFilter;
55
-
56
- // State
57
- this.options = new Map(); // key -> {index, value, label, disabled, dataset, title, original}
167
+
168
+ // Internal state
169
+ this.options = new Map();
58
170
  this.available = [];
59
171
  this.chosen = [];
60
-
61
- // UI elements
172
+
173
+ // UI element references
62
174
  this.container = null;
63
175
  this.availableSelect = null;
64
176
  this.chosenSelect = null;
65
177
  this.availableFilter = null;
66
178
  this.chosenFilter = null;
179
+ this.availableCounter = null;
180
+ this.chosenCounter = null;
67
181
  this.buttons = {};
68
-
182
+
183
+ // Filter debounce timer ids
184
+ this._filterTimers = { available: 0, chosen: 0 };
185
+
69
186
  this._init();
70
187
  }
71
188
 
189
+ // ---------------------------------------------------------------------------
190
+ // Initialisation
191
+ // ---------------------------------------------------------------------------
192
+
72
193
  _init() {
73
- // Build state from original select
74
194
  this._buildState();
75
-
76
- // Hide original select
77
195
  this.selectElement.dataset.filteredSelectMultiple = "true";
78
196
  this.previousDisplay = this.selectElement.style.display;
79
197
  this.selectElement.style.display = "none";
80
-
81
- // Create placeholder comment for restoring position
82
198
  this.placeholder = this.doc.createComment("filtered-select-multiple");
83
199
  this.selectElement.parentNode.insertBefore(this.placeholder, this.selectElement);
84
-
85
- // Build and render UI
86
200
  this._buildUI();
87
201
  this._attachEvents();
88
202
  this._render();
@@ -90,8 +204,7 @@ export class FilteredSelectMultiple {
90
204
 
91
205
  _buildState() {
92
206
  const options = Array.from(this.selectElement.options);
93
- const orderKeys = [];
94
-
207
+
95
208
  options.forEach((option, index) => {
96
209
  const key = `fsm-${index}`;
97
210
  this.options.set(key, {
@@ -104,16 +217,14 @@ export class FilteredSelectMultiple {
104
217
  title: option.title,
105
218
  original: option,
106
219
  });
107
-
108
- orderKeys.push(key);
220
+
109
221
  if (option.selected) {
110
222
  this.chosen.push(key);
111
223
  } else {
112
224
  this.available.push(key);
113
225
  }
114
226
  });
115
-
116
- // Sort by original order
227
+
117
228
  this._sortByIndex(this.available);
118
229
  if (!this.preserveSelectionOrder) {
119
230
  this._sortByIndex(this.chosen);
@@ -122,18 +233,20 @@ export class FilteredSelectMultiple {
122
233
 
123
234
  _sortByIndex(keys) {
124
235
  keys.sort((a, b) => {
125
- const aIndex = this.options.get(a)?.index ?? Number.MAX_SAFE_INTEGER;
126
- const bIndex = this.options.get(b)?.index ?? Number.MAX_SAFE_INTEGER;
127
- return aIndex - bIndex;
236
+ const ai = this.options.get(a)?.index ?? Number.MAX_SAFE_INTEGER;
237
+ const bi = this.options.get(b)?.index ?? Number.MAX_SAFE_INTEGER;
238
+ return ai - bi;
128
239
  });
129
240
  }
130
241
 
242
+ // ---------------------------------------------------------------------------
243
+ // UI construction
244
+ // ---------------------------------------------------------------------------
245
+
131
246
  _buildUI() {
132
- // Main container
133
247
  this.container = this.doc.createElement("div");
134
248
  this.container.className = this.theme.container;
135
-
136
- // Available pane
249
+
137
250
  const availablePane = this._createPane({
138
251
  type: "available",
139
252
  label: this.text.availableLabel,
@@ -141,11 +254,10 @@ export class FilteredSelectMultiple {
141
254
  });
142
255
  this.availableSelect = availablePane.select;
143
256
  this.availableFilter = availablePane.filter;
144
-
145
- // Controls (buttons)
257
+ this.availableCounter = availablePane.counter;
258
+
146
259
  const controls = this._createControls();
147
-
148
- // Chosen pane
260
+
149
261
  const chosenPane = this._createPane({
150
262
  type: "chosen",
151
263
  label: this.text.chosenLabel,
@@ -153,8 +265,8 @@ export class FilteredSelectMultiple {
153
265
  });
154
266
  this.chosenSelect = chosenPane.select;
155
267
  this.chosenFilter = chosenPane.filter;
156
-
157
- // Assemble
268
+ this.chosenCounter = chosenPane.counter;
269
+
158
270
  this.container.append(availablePane.column, controls, chosenPane.column);
159
271
  this.placeholder.parentNode.insertBefore(this.container, this.placeholder);
160
272
  }
@@ -162,14 +274,28 @@ export class FilteredSelectMultiple {
162
274
  _createPane({ type, label, filterPlaceholder }) {
163
275
  const column = this.doc.createElement("div");
164
276
  const isAvailable = type === "available";
165
- column.className = this.theme.column + " " + (isAvailable ? this.theme.availableColumn : this.theme.chosenColumn);
166
-
167
- // Label
277
+ column.className =
278
+ this.theme.column + " " +
279
+ (isAvailable ? this.theme.availableColumn : this.theme.chosenColumn);
280
+
281
+ // Unique ids for aria relationships
282
+ const labelId = `fsm-label-${type}-${Date.now()}`;
283
+
284
+ // Label + counter
168
285
  const labelEl = this.doc.createElement("div");
169
286
  labelEl.className = this.theme.label;
170
- labelEl.textContent = label;
287
+ labelEl.id = labelId;
288
+
289
+ const labelText = this.doc.createTextNode(label + " ");
290
+ labelEl.appendChild(labelText);
291
+
292
+ const counter = this.doc.createElement("span");
293
+ counter.className = this.theme.counter;
294
+ counter.setAttribute("aria-live", "polite");
295
+ labelEl.appendChild(counter);
296
+
171
297
  column.appendChild(labelEl);
172
-
298
+
173
299
  // Filter input (optional)
174
300
  let filter = null;
175
301
  if (this.showFilter) {
@@ -178,111 +304,106 @@ export class FilteredSelectMultiple {
178
304
  filter.className = this.theme.filter;
179
305
  filter.placeholder = filterPlaceholder;
180
306
  filter.dataset.paneType = type;
307
+ filter.setAttribute("aria-label", `${filterPlaceholder} ${label}`);
181
308
  column.appendChild(filter);
182
309
  }
183
-
310
+
184
311
  // Select element
185
312
  const select = this.doc.createElement("select");
186
313
  select.multiple = true;
187
314
  select.size = this.size;
188
315
  select.className = this.theme.select;
189
316
  select.dataset.paneType = type;
317
+ select.setAttribute("aria-labelledby", labelId);
190
318
  column.appendChild(select);
191
-
192
- return { column, select, filter };
319
+
320
+ return { column, select, filter, counter };
193
321
  }
194
322
 
195
323
  _createControls() {
196
324
  const column = this.doc.createElement("div");
197
325
  column.className = this.theme.controls;
198
-
199
- const icons = {
200
- addAll: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="height: 1.2em;">
201
- <path stroke-linecap="round" stroke-linejoin="round" d="m5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5" />
202
- </svg>`,
203
- addSelected: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="height: 1.2em;">
204
- <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
205
- </svg>`,
206
- removeSelected: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="height: 1.2em;">
207
- <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
208
- </svg>`,
209
- removeAll: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="height: 1.2em;">
210
- <path stroke-linecap="round" stroke-linejoin="round" d="m18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5" />
211
- </svg>`,
212
- };
213
-
214
- const buttons = [
326
+ column.setAttribute("role", "group");
327
+ column.setAttribute("aria-label", "Transfer controls");
328
+
329
+ const buttonDefs = [
215
330
  { action: "addAll", label: this.text.addAll, icon: icons.addAll },
216
331
  { action: "addSelected", label: this.text.addSelected, icon: icons.addSelected },
217
332
  { action: "removeSelected", label: this.text.removeSelected, icon: icons.removeSelected },
218
333
  { action: "removeAll", label: this.text.removeAll, icon: icons.removeAll },
219
334
  ];
220
-
221
- buttons.forEach(({ action, label, icon }) => {
335
+
336
+ buttonDefs.forEach(({ action, label, icon }) => {
222
337
  const button = this.doc.createElement("button");
223
338
  button.type = "button";
224
- const specificClass = this.theme[`button_${action}`] || "";
225
- button.className = (this.theme.button + " " + specificClass).trim();
226
-
339
+ const actionKey = `button${action.charAt(0).toUpperCase()}${action.slice(1)}`;
340
+ const actionClass = this.theme[actionKey] || "";
341
+ button.className = (this.theme.button + " " + actionClass).trim();
342
+ button.setAttribute("aria-label", label);
343
+
227
344
  const isRemove = action.startsWith("remove");
228
- const svgClass = this.theme.button_svg ? ` class="${this.theme.button_svg}"` : '';
229
- const modifiedIcon = icon.replace('<svg', `<svg${svgClass}`);
230
- button.innerHTML = isRemove ? `${modifiedIcon} ${label}` : `${label} ${modifiedIcon}`;
345
+ button.innerHTML = isRemove ? `${icon} ${label}` : `${label} ${icon}`;
231
346
  button.dataset.action = action;
232
-
233
347
  this.buttons[action] = button;
234
348
  column.appendChild(button);
235
349
  });
236
-
350
+
237
351
  return column;
238
352
  }
239
353
 
354
+ // ---------------------------------------------------------------------------
355
+ // Events
356
+ // ---------------------------------------------------------------------------
357
+
240
358
  _attachEvents() {
241
- // Filter input events
359
+ const signal = this.#abortController.signal;
360
+
242
361
  if (this.availableFilter) {
243
- this.availableFilter.addEventListener("input", () => this._renderPane("available"));
362
+ this.availableFilter.addEventListener("input", () => this._debouncedRender("available"), { signal });
244
363
  }
245
364
  if (this.chosenFilter) {
246
- this.chosenFilter.addEventListener("input", () => this._renderPane("chosen"));
365
+ this.chosenFilter.addEventListener("input", () => this._debouncedRender("chosen"), { signal });
247
366
  }
248
-
249
- // Selection change events
250
- this.availableSelect.addEventListener("change", () => this._updateButtons());
251
- this.chosenSelect.addEventListener("change", () => this._updateButtons());
252
-
253
- // Double-click to move
367
+
368
+ this.availableSelect.addEventListener("change", () => this._updateButtons(), { signal });
369
+ this.chosenSelect.addEventListener("change", () => this._updateButtons(), { signal });
370
+
254
371
  this.availableSelect.addEventListener("dblclick", () => {
255
- if (!this.availableSelect.disabled) {
256
- this._moveSelected("available", "chosen");
257
- }
258
- });
372
+ if (!this.availableSelect.disabled) this._moveSelected("available", "chosen");
373
+ }, { signal });
259
374
  this.chosenSelect.addEventListener("dblclick", () => {
260
- if (!this.chosenSelect.disabled) {
261
- this._moveSelected("chosen", "available");
262
- }
263
- });
264
-
265
- // Button clicks
375
+ if (!this.chosenSelect.disabled) this._moveSelected("chosen", "available");
376
+ }, { signal });
377
+
378
+ // Keyboard shortcut: Enter to transfer selected items
379
+ this.availableSelect.addEventListener("keydown", (e) => {
380
+ if (e.key === "Enter") { e.preventDefault(); this._moveSelected("available", "chosen"); }
381
+ }, { signal });
382
+ this.chosenSelect.addEventListener("keydown", (e) => {
383
+ if (e.key === "Enter") { e.preventDefault(); this._moveSelected("chosen", "available"); }
384
+ }, { signal });
385
+
266
386
  Object.entries(this.buttons).forEach(([action, button]) => {
267
387
  button.addEventListener("click", () => {
268
388
  switch (action) {
269
- case "addAll":
270
- this._moveAll("available", "chosen");
271
- break;
272
- case "addSelected":
273
- this._moveSelected("available", "chosen");
274
- break;
275
- case "removeSelected":
276
- this._moveSelected("chosen", "available");
277
- break;
278
- case "removeAll":
279
- this._moveAll("chosen", "available");
280
- break;
389
+ case "addAll": this._moveAll("available", "chosen"); break;
390
+ case "addSelected": this._moveSelected("available", "chosen"); break;
391
+ case "removeSelected": this._moveSelected("chosen", "available"); break;
392
+ case "removeAll": this._moveAll("chosen", "available"); break;
281
393
  }
282
- });
394
+ }, { signal });
283
395
  });
284
396
  }
285
397
 
398
+ _debouncedRender(type) {
399
+ clearTimeout(this._filterTimers[type]);
400
+ this._filterTimers[type] = setTimeout(() => this._renderPane(type), 120);
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // Rendering
405
+ // ---------------------------------------------------------------------------
406
+
286
407
  _render() {
287
408
  this._renderPane("available");
288
409
  this._renderPane("chosen");
@@ -292,111 +413,115 @@ export class FilteredSelectMultiple {
292
413
  _renderPane(type) {
293
414
  const select = type === "available" ? this.availableSelect : this.chosenSelect;
294
415
  const filter = type === "available" ? this.availableFilter : this.chosenFilter;
416
+ const counter = type === "available" ? this.availableCounter : this.chosenCounter;
295
417
  const keys = type === "available" ? this.available : this.chosen;
296
-
297
- // Get filter term
298
418
  const term = filter ? filter.value.trim().toLowerCase() : "";
299
-
300
- // Preserve selection
419
+
420
+ // Preserve selection across re-renders
301
421
  const previousSelection = new Set(
302
- Array.from(select.selectedOptions, opt => opt.dataset.key)
422
+ Array.from(select.selectedOptions, (opt) => opt.dataset.key)
303
423
  );
304
-
305
- // Clear and rebuild
306
- select.innerHTML = "";
307
-
308
- keys.forEach(key => {
424
+
425
+ const fragment = this.doc.createDocumentFragment();
426
+ let visibleCount = 0;
427
+
428
+ keys.forEach((key) => {
309
429
  const meta = this.options.get(key);
310
430
  if (!meta) return;
311
-
312
- // Apply filter
313
- if (term && !this._passesFilter(meta.label, term)) {
314
- return;
315
- }
316
-
431
+ if (term && !this._passesFilter(meta.label, term)) return;
432
+
433
+ visibleCount++;
317
434
  const option = this.doc.createElement("option");
318
435
  option.value = meta.value;
319
436
  option.textContent = meta.label;
320
437
  option.dataset.key = key;
321
438
  option.disabled = meta.disabled;
322
-
323
- if (meta.title) {
324
- option.title = meta.title;
325
- }
326
-
439
+ // Always give every option a tooltip so long labels remain readable
440
+ // when a width-constrained parent truncates the visible text. Prefer an
441
+ // explicit title from the source markup, otherwise fall back to the label.
442
+ option.title = meta.title || meta.label;
327
443
  Object.entries(meta.dataset).forEach(([attr, value]) => {
328
444
  option.dataset[attr] = value;
329
445
  });
330
-
331
- if (previousSelection.has(key)) {
332
- option.selected = true;
333
- }
334
-
335
- select.appendChild(option);
446
+ if (previousSelection.has(key)) option.selected = true;
447
+ fragment.appendChild(option);
336
448
  });
449
+
450
+ select.replaceChildren(fragment);
451
+
452
+ // Update counter
453
+ if (counter) {
454
+ const total = keys.length;
455
+ counter.textContent = term
456
+ ? `(${visibleCount} / ${total})`
457
+ : `(${total})`;
458
+ }
459
+
460
+ this._updateButtons();
337
461
  }
338
462
 
339
463
  _passesFilter(label, term) {
340
464
  if (!term) return true;
341
-
342
465
  const haystack = label.toLowerCase();
343
-
344
- if (this.filterMatchMode === "startsWith") {
345
- return haystack.startsWith(term);
346
- }
347
-
348
- // "contains" mode - all tokens must match
466
+ if (this.filterMatchMode === "startsWith") return haystack.startsWith(term);
467
+ // "contains" mode – every whitespace-separated token must match
349
468
  const tokens = term.split(/\s+/).filter(Boolean);
350
- return tokens.every(token => haystack.includes(token));
469
+ return tokens.every((token) => haystack.includes(token));
351
470
  }
352
471
 
472
+ // ---------------------------------------------------------------------------
473
+ // Transfer logic
474
+ // ---------------------------------------------------------------------------
475
+
353
476
  _getSelectedKeys(select) {
354
- return Array.from(select.selectedOptions, opt => opt.dataset.key);
477
+ return Array.from(select.selectedOptions, (opt) => opt.dataset.key);
355
478
  }
356
479
 
357
480
  _moveSelected(fromType, toType) {
358
481
  const fromSelect = fromType === "available" ? this.availableSelect : this.chosenSelect;
359
- const keys = this._getSelectedKeys(fromSelect);
360
- if (keys.length > 0) {
361
- this._transfer(keys, fromType, toType);
362
- }
482
+ const keys = this._getSelectedKeys(fromSelect).filter((key) => {
483
+ const meta = this.options.get(key);
484
+ return meta && !meta.disabled;
485
+ });
486
+ if (keys.length > 0) this._transfer(keys, fromType, toType);
363
487
  }
364
488
 
365
489
  _moveAll(fromType, toType) {
366
- const keys = fromType === "available" ? [...this.available] : [...this.chosen];
367
- if (keys.length > 0) {
368
- this._transfer(keys, fromType, toType);
369
- }
490
+ const source = fromType === "available" ? this.available : this.chosen;
491
+ const keys = source.filter((key) => {
492
+ const meta = this.options.get(key);
493
+ return meta && !meta.disabled;
494
+ });
495
+ if (keys.length > 0) this._transfer(keys, fromType, toType);
370
496
  }
371
497
 
372
498
  _transfer(keys, fromType, toType) {
373
499
  const from = fromType === "available" ? this.available : this.chosen;
374
500
  const to = toType === "available" ? this.available : this.chosen;
375
-
376
501
  const keySet = new Set(keys);
377
-
502
+
378
503
  // Remove from source
379
- const remaining = from.filter(key => !keySet.has(key));
504
+ const remaining = from.filter((key) => !keySet.has(key));
380
505
  from.splice(0, from.length, ...remaining);
381
-
506
+
382
507
  // Add to target
383
508
  to.push(...keys);
384
-
509
+
385
510
  // Sort
386
511
  this._sortByIndex(from);
387
512
  if (!this.preserveSelectionOrder || toType === "available") {
388
513
  this._sortByIndex(to);
389
514
  }
390
-
391
- // Update and notify
392
- this._render();
515
+
516
+ // Sync state to original <select> FIRST, then render, then notify.
393
517
  this._syncToOriginal();
518
+ this._render();
394
519
  this._dispatchChange();
395
520
  }
396
521
 
397
522
  _syncToOriginal() {
398
523
  const chosenSet = new Set(this.chosen);
399
- this.options.forEach(meta => {
524
+ this.options.forEach((meta) => {
400
525
  meta.original.selected = chosenSet.has(meta.key);
401
526
  });
402
527
  }
@@ -406,12 +531,16 @@ export class FilteredSelectMultiple {
406
531
  this.selectElement.dispatchEvent(event);
407
532
  }
408
533
 
534
+ // ---------------------------------------------------------------------------
535
+ // Button state management
536
+ // ---------------------------------------------------------------------------
537
+
409
538
  _updateButtons() {
410
539
  const hasAvailableSelection = this.availableSelect.selectedOptions.length > 0;
411
540
  const hasChosenSelection = this.chosenSelect.selectedOptions.length > 0;
412
- const hasAvailableItems = this.available.length > 0;
413
- const hasChosenItems = this.chosen.length > 0;
414
-
541
+ const hasAvailableItems = this.available.some((k) => !this.options.get(k)?.disabled);
542
+ const hasChosenItems = this.chosen.some((k) => !this.options.get(k)?.disabled);
543
+
415
544
  this._setButtonState("addSelected", !hasAvailableSelection);
416
545
  this._setButtonState("removeSelected", !hasChosenSelection);
417
546
  this._setButtonState("addAll", !hasAvailableItems);
@@ -424,21 +553,23 @@ export class FilteredSelectMultiple {
424
553
 
425
554
  button.disabled = disabled;
426
555
 
427
- // Rebuild className from scratch to avoid classList issues with spaces
428
556
  const baseClasses = this.theme.button.split(/\s+/).filter(Boolean);
429
- const actionClasses = (this.theme[`button_${action}`] || '').split(/\s+/).filter(Boolean);
430
- const disabledClasses = (this.theme.buttonDisabled || '').split(/\s+/).filter(Boolean);
557
+ const actionKey = `button${action.charAt(0).toUpperCase()}${action.slice(1)}`;
558
+ const actionClasses = (this.theme[actionKey] || "").split(/\s+/).filter(Boolean);
559
+ const disabledClasses = (this.theme.buttonDisabled || "").split(/\s+/).filter(Boolean);
431
560
 
432
- let finalClasses = [...baseClasses, ...actionClasses];
433
- if (disabled) {
434
- finalClasses.push(...disabledClasses);
435
- }
436
-
437
- button.className = [...new Set(finalClasses)].join(' ');
561
+ const finalClasses = [...baseClasses, ...actionClasses];
562
+ if (disabled) finalClasses.push(...disabledClasses);
563
+ button.className = [...new Set(finalClasses)].join(" ");
438
564
  }
439
565
 
440
- /** Revert the widget and show the original select element */
566
+ // ---------------------------------------------------------------------------
567
+ // Public API
568
+ // ---------------------------------------------------------------------------
569
+
570
+ /** Revert the widget and restore the original <select> element. */
441
571
  destroy() {
572
+ this.#abortController.abort();
442
573
  this.container.remove();
443
574
  this.selectElement.style.display = this.previousDisplay;
444
575
  this.selectElement.removeAttribute("data-filtered-select-multiple");
package/src/icons.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * SVG icon strings for transfer buttons (Heroicons, MIT license).
3
+ */
4
+
5
+ export const icons = {
6
+ addAll: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" style="height:1.2em;width:auto;">
7
+ <path stroke-linecap="round" stroke-linejoin="round" d="m5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5" />
8
+ </svg>`,
9
+ addSelected: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" style="height:1.2em;width:auto;">
10
+ <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
11
+ </svg>`,
12
+ removeSelected: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" style="height:1.2em;width:auto;">
13
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
14
+ </svg>`,
15
+ removeAll: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" style="height:1.2em;width:auto;">
16
+ <path stroke-linecap="round" stroke-linejoin="round" d="m18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5" />
17
+ </svg>`,
18
+ };
package/src/themes.js CHANGED
@@ -1,6 +1,9 @@
1
1
  /**
2
- * Theme definitions for FilteredSelectMultiple widget
3
- * Simple objects mapping element types to CSS class strings
2
+ * Theme definitions for FilteredSelectMultiple widget.
3
+ * Plain objects mapping element roles to CSS class strings.
4
+ *
5
+ * All keys use camelCase. Every key listed in defaultTheme MUST be present
6
+ * in custom themes (missing keys are filled from defaultTheme at runtime).
4
7
  */
5
8
 
6
9
  /**
@@ -16,12 +19,12 @@ export const defaultTheme = {
16
19
  select: "fsm-select",
17
20
  controls: "fsm-controls",
18
21
  button: "fsm-button",
19
- button_addAll: "fsm-add-all",
20
- button_addSelected: "fsm-add-selected",
21
- button_removeSelected: "fsm-remove-selected",
22
- button_removeAll: "fsm-remove-all",
22
+ buttonAddAll: "fsm-add-all",
23
+ buttonAddSelected: "fsm-add-selected",
24
+ buttonRemoveSelected: "fsm-remove-selected",
25
+ buttonRemoveAll: "fsm-remove-all",
23
26
  buttonDisabled: "fsm-button-disabled",
24
- button_svg: "fsm-button-svg",
27
+ counter: "fsm-counter",
25
28
  };
26
29
 
27
30
  /**
@@ -36,13 +39,13 @@ export const bootstrap5Theme = {
36
39
  filter: "form-control form-control-sm mb-2",
37
40
  select: "form-select flex-grow-1",
38
41
  controls: "d-flex flex-column justify-content-center gap-2 px-3",
39
- button: "btn btn-sm btn-primary",
40
- button_addAll: "",
41
- button_addSelected: "",
42
- button_removeSelected: "",
43
- button_removeAll: "",
44
- buttonDisabled: "disabled",
45
- button_svg: "d-inline-block",
42
+ button: "btn btn-sm d-inline-flex align-items-center gap-1",
43
+ buttonAddAll: "btn-outline-primary justify-content-end",
44
+ buttonAddSelected: "btn-primary justify-content-end",
45
+ buttonRemoveSelected: "btn-primary justify-content-start",
46
+ buttonRemoveAll: "btn-outline-primary justify-content-start",
47
+ buttonDisabled: "btn-secondary",
48
+ counter: "text-muted small",
46
49
  };
47
50
 
48
51
  /**
@@ -57,13 +60,13 @@ export const daisyUITheme = {
57
60
  filter: "input input-bordered input-sm mb-2 w-full",
58
61
  select: "select select-bordered w-full flex-grow",
59
62
  controls: "flex flex-col justify-center gap-2 px-4",
60
- button: "btn btn-sm",
61
- button_addAll: "btn-secondary",
62
- button_addSelected: "btn-primary",
63
- button_removeSelected: "btn-primary",
64
- button_removeAll: "btn-secondary",
63
+ button: "btn btn-sm inline-flex items-center gap-1",
64
+ buttonAddAll: "btn-outline btn-secondary justify-end",
65
+ buttonAddSelected: "btn-primary justify-end",
66
+ buttonRemoveSelected: "btn-primary justify-start",
67
+ buttonRemoveAll: "btn-outline btn-secondary justify-start",
65
68
  buttonDisabled: "btn-disabled",
66
- button_svg: "",
69
+ counter: "text-sm opacity-70",
67
70
  };
68
71
 
69
72
  /**
@@ -78,11 +81,11 @@ export const tailwindTheme = {
78
81
  filter: "border border-slate-300 rounded px-3 py-2 text-sm mb-2 focus:outline-none focus:ring-2 focus:ring-blue-500",
79
82
  select: "border border-slate-300 rounded px-2 py-1 min-w-[200px] flex-grow",
80
83
  controls: "flex flex-col justify-center gap-2 px-4",
81
- button: "inline-flex items-center gap-2 px-3 py-2 text-sm border rounded hover:bg-slate-50 transition-colors",
82
- button_addAll: "border-slate-300",
83
- button_addSelected: "bg-blue-600 text-white border-blue-600 hover:bg-blue-700",
84
- button_removeSelected: "bg-blue-600 text-white border-blue-600 hover:bg-blue-700",
85
- button_removeAll: "border-slate-300",
86
- buttonDisabled: "opacity-50 cursor-not-allowed",
87
- button_svg: "",
84
+ button: "inline-flex items-center gap-2 px-3 py-2 text-sm border rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
85
+ buttonAddAll: "border-slate-300 hover:bg-slate-50 justify-end",
86
+ buttonAddSelected: "bg-blue-600 text-white border-blue-600 hover:bg-blue-700 justify-end",
87
+ buttonRemoveSelected: "bg-blue-600 text-white border-blue-600 hover:bg-blue-700 justify-start",
88
+ buttonRemoveAll: "border-slate-300 hover:bg-slate-50 justify-start",
89
+ buttonDisabled: "",
90
+ counter: "text-xs text-slate-500",
88
91
  };