filtered-select-multiple-widget 0.0.3 → 0.0.5
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 +46 -25
- package/package.json +12 -6
- package/src/FilteredSelectMultiple.js +305 -177
- package/src/icons.js +18 -0
- package/src/themes.js +30 -27
package/README.md
CHANGED
|
@@ -27,10 +27,14 @@ The Django widget is a thousand times better than mine. But I hope to gradually
|
|
|
27
27
|
|
|
28
28
|
- Zero dependencies and ES module friendly.
|
|
29
29
|
- Works with any native `<select multiple>` element.
|
|
30
|
-
- Optional client-side filtering of the available choices.
|
|
31
|
-
- Keyboard friendly and
|
|
30
|
+
- Optional client-side filtering of the available choices (with debounce).
|
|
31
|
+
- Keyboard friendly: select items and press Enter to transfer. Form-ready (synced back to the original select element).
|
|
32
|
+
- Ships default CSS — no extra stylesheet needed for out-of-the-box styling.
|
|
32
33
|
- Integrates with Bootstrap 5, DaisyUI, and Tailwind CSS (see live demo with theme switcher).
|
|
33
34
|
- Automatically detects pane labels from associated `<label>` elements.
|
|
35
|
+
- Items count indicator on each pane.
|
|
36
|
+
- Accessible: `aria-labelledby`, `aria-label`, `aria-live` regions.
|
|
37
|
+
- Disabled `<option>` elements are respected and excluded from bulk transfers.
|
|
34
38
|
|
|
35
39
|
## Demo
|
|
36
40
|
|
|
@@ -44,7 +48,7 @@ The demo includes a theme switcher to showcase integration with Bootstrap 5, Dai
|
|
|
44
48
|
## Installation
|
|
45
49
|
|
|
46
50
|
```bash
|
|
47
|
-
npm install
|
|
51
|
+
npm install filtered-select-multiple-widget
|
|
48
52
|
```
|
|
49
53
|
|
|
50
54
|
## Usage
|
|
@@ -68,12 +72,17 @@ const widget = new FilteredSelectMultiple(select);
|
|
|
68
72
|
|
|
69
73
|
| Option | Type | Default | Description |
|
|
70
74
|
| ------ | ---- | ------- | ----------- |
|
|
71
|
-
| `showFilter` | `boolean` | `true` | Toggle the search box above
|
|
72
|
-
| `filterMatchMode` | `'contains' \| 'startsWith'` | `'contains'` | How filtering behaves. |
|
|
73
|
-
| `size` | `number` | `select.size \|\| clamp(optionCount)` | Number of visible rows for each list. |
|
|
75
|
+
| `showFilter` | `boolean` | `true` | Toggle the search box above each list. |
|
|
76
|
+
| `filterMatchMode` | `'contains' \| 'startsWith'` | `'contains'` | How filtering behaves. `'contains'` supports multiple space-separated tokens. |
|
|
77
|
+
| `size` | `number` | `select.size \|\| clamp(optionCount, 4, 12)` | Number of visible rows for each list. |
|
|
74
78
|
| `preserveSelectionOrder` | `boolean` | `false` | Keep the order items were added in the chosen list rather than the original option order. |
|
|
75
79
|
| `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` | `
|
|
80
|
+
| `theme` | `object` | `defaultTheme` | CSS theme configuration for styling framework integration. |
|
|
81
|
+
|
|
82
|
+
### Keyboard Support
|
|
83
|
+
|
|
84
|
+
- **Enter** on either select list: transfers the currently selected items.
|
|
85
|
+
- **Double-click** on an option: transfers that single item.
|
|
77
86
|
|
|
78
87
|
### Theming
|
|
79
88
|
|
|
@@ -99,32 +108,40 @@ new FilteredSelectMultiple(select, { theme: tailwindTheme });
|
|
|
99
108
|
|
|
100
109
|
#### Custom Themes
|
|
101
110
|
|
|
102
|
-
|
|
103
|
-
import { Theme } from "filtered-select-multiple-widget";
|
|
111
|
+
A theme is a plain object mapping element roles to CSS class strings. Any keys you omit are filled in from `defaultTheme`:
|
|
104
112
|
|
|
105
|
-
|
|
113
|
+
```js
|
|
114
|
+
const customTheme = {
|
|
106
115
|
container: "my-widget-container",
|
|
107
116
|
button: "my-button-class",
|
|
108
|
-
buttonAddSelected: "my-
|
|
117
|
+
buttonAddSelected: "my-primary-button",
|
|
109
118
|
filter: "my-input-class",
|
|
110
119
|
select: "my-select-class",
|
|
111
|
-
// ...
|
|
112
|
-
}
|
|
120
|
+
// ... only override what you need
|
|
121
|
+
};
|
|
113
122
|
|
|
114
123
|
new FilteredSelectMultiple(select, { theme: customTheme });
|
|
115
124
|
```
|
|
116
125
|
|
|
117
|
-
#### Available Theme
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
#### Available Theme Keys
|
|
127
|
+
|
|
128
|
+
| Key | Element |
|
|
129
|
+
| --- | ------- |
|
|
130
|
+
| `container` | Main widget wrapper |
|
|
131
|
+
| `column` | Pane column |
|
|
132
|
+
| `availableColumn` | Available pane column modifier |
|
|
133
|
+
| `chosenColumn` | Chosen pane column modifier |
|
|
134
|
+
| `label` | Pane labels |
|
|
135
|
+
| `counter` | Item count indicator inside labels |
|
|
136
|
+
| `filter` | Search input fields |
|
|
137
|
+
| `select` | Multi-select elements |
|
|
138
|
+
| `controls` | Button container |
|
|
139
|
+
| `button` | Base button class (applied to all buttons) |
|
|
140
|
+
| `buttonAddAll` | "Add all" button |
|
|
141
|
+
| `buttonAddSelected` | "Add selected" button |
|
|
142
|
+
| `buttonRemoveSelected` | "Remove selected" button |
|
|
143
|
+
| `buttonRemoveAll` | "Remove all" button |
|
|
144
|
+
| `buttonDisabled` | Disabled button modifier |
|
|
128
145
|
|
|
129
146
|
### Destroying the widget
|
|
130
147
|
|
|
@@ -132,7 +149,11 @@ new FilteredSelectMultiple(select, { theme: customTheme });
|
|
|
132
149
|
widget.destroy();
|
|
133
150
|
```
|
|
134
151
|
|
|
135
|
-
The original `<select>` is restored and
|
|
152
|
+
The original `<select>` is restored and all event listeners are cleaned up.
|
|
153
|
+
|
|
154
|
+
## Note on `<optgroup>`
|
|
155
|
+
|
|
156
|
+
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
157
|
|
|
137
158
|
## Third-Party Assets
|
|
138
159
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "filtered-select-multiple-widget",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
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
|
-
"
|
|
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 -sf ../src docs/src",
|
|
45
51
|
"demo": "http-server docs/ -p 8000 -o",
|
|
46
|
-
"
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
41
|
-
if (
|
|
42
|
-
|
|
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
|
-
//
|
|
57
|
-
this.options = new Map();
|
|
167
|
+
|
|
168
|
+
// Internal state
|
|
169
|
+
this.options = new Map();
|
|
58
170
|
this.available = [];
|
|
59
171
|
this.chosen = [];
|
|
60
|
-
|
|
61
|
-
// UI
|
|
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
|
-
|
|
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
|
|
126
|
-
const
|
|
127
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
166
|
-
|
|
167
|
-
|
|
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.
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
335
|
+
|
|
336
|
+
buttonDefs.forEach(({ action, label, icon }) => {
|
|
222
337
|
const button = this.doc.createElement("button");
|
|
223
338
|
button.type = "button";
|
|
224
|
-
const
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
359
|
+
const signal = this.#abortController.signal;
|
|
360
|
+
|
|
242
361
|
if (this.availableFilter) {
|
|
243
|
-
this.availableFilter.addEventListener("input", () => this.
|
|
362
|
+
this.availableFilter.addEventListener("input", () => this._debouncedRender("available"), { signal });
|
|
244
363
|
}
|
|
245
364
|
if (this.chosenFilter) {
|
|
246
|
-
this.chosenFilter.addEventListener("input", () => this.
|
|
365
|
+
this.chosenFilter.addEventListener("input", () => this._debouncedRender("chosen"), { signal });
|
|
247
366
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
this.
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
case "
|
|
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,112 @@ 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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
+
if (meta.title) option.title = meta.title;
|
|
327
440
|
Object.entries(meta.dataset).forEach(([attr, value]) => {
|
|
328
441
|
option.dataset[attr] = value;
|
|
329
442
|
});
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
option.selected = true;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
select.appendChild(option);
|
|
443
|
+
if (previousSelection.has(key)) option.selected = true;
|
|
444
|
+
fragment.appendChild(option);
|
|
336
445
|
});
|
|
446
|
+
|
|
447
|
+
select.replaceChildren(fragment);
|
|
448
|
+
|
|
449
|
+
// Update counter
|
|
450
|
+
if (counter) {
|
|
451
|
+
const total = keys.length;
|
|
452
|
+
counter.textContent = term
|
|
453
|
+
? `(${visibleCount} / ${total})`
|
|
454
|
+
: `(${total})`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
this._updateButtons();
|
|
337
458
|
}
|
|
338
459
|
|
|
339
460
|
_passesFilter(label, term) {
|
|
340
461
|
if (!term) return true;
|
|
341
|
-
|
|
342
462
|
const haystack = label.toLowerCase();
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
return haystack.startsWith(term);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// "contains" mode - all tokens must match
|
|
463
|
+
if (this.filterMatchMode === "startsWith") return haystack.startsWith(term);
|
|
464
|
+
// "contains" mode – every whitespace-separated token must match
|
|
349
465
|
const tokens = term.split(/\s+/).filter(Boolean);
|
|
350
|
-
return tokens.every(token => haystack.includes(token));
|
|
466
|
+
return tokens.every((token) => haystack.includes(token));
|
|
351
467
|
}
|
|
352
468
|
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// Transfer logic
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
|
|
353
473
|
_getSelectedKeys(select) {
|
|
354
|
-
return Array.from(select.selectedOptions, opt => opt.dataset.key);
|
|
474
|
+
return Array.from(select.selectedOptions, (opt) => opt.dataset.key);
|
|
355
475
|
}
|
|
356
476
|
|
|
357
477
|
_moveSelected(fromType, toType) {
|
|
358
478
|
const fromSelect = fromType === "available" ? this.availableSelect : this.chosenSelect;
|
|
359
|
-
const keys = this._getSelectedKeys(fromSelect)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
479
|
+
const keys = this._getSelectedKeys(fromSelect).filter((key) => {
|
|
480
|
+
const meta = this.options.get(key);
|
|
481
|
+
return meta && !meta.disabled;
|
|
482
|
+
});
|
|
483
|
+
if (keys.length > 0) this._transfer(keys, fromType, toType);
|
|
363
484
|
}
|
|
364
485
|
|
|
365
486
|
_moveAll(fromType, toType) {
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
this.
|
|
369
|
-
|
|
487
|
+
const source = fromType === "available" ? this.available : this.chosen;
|
|
488
|
+
const keys = source.filter((key) => {
|
|
489
|
+
const meta = this.options.get(key);
|
|
490
|
+
return meta && !meta.disabled;
|
|
491
|
+
});
|
|
492
|
+
if (keys.length > 0) this._transfer(keys, fromType, toType);
|
|
370
493
|
}
|
|
371
494
|
|
|
372
495
|
_transfer(keys, fromType, toType) {
|
|
373
496
|
const from = fromType === "available" ? this.available : this.chosen;
|
|
374
497
|
const to = toType === "available" ? this.available : this.chosen;
|
|
375
|
-
|
|
376
498
|
const keySet = new Set(keys);
|
|
377
|
-
|
|
499
|
+
|
|
378
500
|
// Remove from source
|
|
379
|
-
const remaining = from.filter(key => !keySet.has(key));
|
|
501
|
+
const remaining = from.filter((key) => !keySet.has(key));
|
|
380
502
|
from.splice(0, from.length, ...remaining);
|
|
381
|
-
|
|
503
|
+
|
|
382
504
|
// Add to target
|
|
383
505
|
to.push(...keys);
|
|
384
|
-
|
|
506
|
+
|
|
385
507
|
// Sort
|
|
386
508
|
this._sortByIndex(from);
|
|
387
509
|
if (!this.preserveSelectionOrder || toType === "available") {
|
|
388
510
|
this._sortByIndex(to);
|
|
389
511
|
}
|
|
390
|
-
|
|
391
|
-
//
|
|
392
|
-
this._render();
|
|
512
|
+
|
|
513
|
+
// Sync state to original <select> FIRST, then render, then notify.
|
|
393
514
|
this._syncToOriginal();
|
|
515
|
+
this._render();
|
|
394
516
|
this._dispatchChange();
|
|
395
517
|
}
|
|
396
518
|
|
|
397
519
|
_syncToOriginal() {
|
|
398
520
|
const chosenSet = new Set(this.chosen);
|
|
399
|
-
this.options.forEach(meta => {
|
|
521
|
+
this.options.forEach((meta) => {
|
|
400
522
|
meta.original.selected = chosenSet.has(meta.key);
|
|
401
523
|
});
|
|
402
524
|
}
|
|
@@ -406,12 +528,16 @@ export class FilteredSelectMultiple {
|
|
|
406
528
|
this.selectElement.dispatchEvent(event);
|
|
407
529
|
}
|
|
408
530
|
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
// Button state management
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
|
|
409
535
|
_updateButtons() {
|
|
410
536
|
const hasAvailableSelection = this.availableSelect.selectedOptions.length > 0;
|
|
411
537
|
const hasChosenSelection = this.chosenSelect.selectedOptions.length > 0;
|
|
412
|
-
const hasAvailableItems = this.available.
|
|
413
|
-
const hasChosenItems = this.chosen.
|
|
414
|
-
|
|
538
|
+
const hasAvailableItems = this.available.some((k) => !this.options.get(k)?.disabled);
|
|
539
|
+
const hasChosenItems = this.chosen.some((k) => !this.options.get(k)?.disabled);
|
|
540
|
+
|
|
415
541
|
this._setButtonState("addSelected", !hasAvailableSelection);
|
|
416
542
|
this._setButtonState("removeSelected", !hasChosenSelection);
|
|
417
543
|
this._setButtonState("addAll", !hasAvailableItems);
|
|
@@ -424,21 +550,23 @@ export class FilteredSelectMultiple {
|
|
|
424
550
|
|
|
425
551
|
button.disabled = disabled;
|
|
426
552
|
|
|
427
|
-
// Rebuild className from scratch to avoid classList issues with spaces
|
|
428
553
|
const baseClasses = this.theme.button.split(/\s+/).filter(Boolean);
|
|
429
|
-
const
|
|
430
|
-
const
|
|
554
|
+
const actionKey = `button${action.charAt(0).toUpperCase()}${action.slice(1)}`;
|
|
555
|
+
const actionClasses = (this.theme[actionKey] || "").split(/\s+/).filter(Boolean);
|
|
556
|
+
const disabledClasses = (this.theme.buttonDisabled || "").split(/\s+/).filter(Boolean);
|
|
431
557
|
|
|
432
|
-
|
|
433
|
-
if (disabled)
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
button.className = [...new Set(finalClasses)].join(' ');
|
|
558
|
+
const finalClasses = [...baseClasses, ...actionClasses];
|
|
559
|
+
if (disabled) finalClasses.push(...disabledClasses);
|
|
560
|
+
button.className = [...new Set(finalClasses)].join(" ");
|
|
438
561
|
}
|
|
439
562
|
|
|
440
|
-
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// Public API
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
/** Revert the widget and restore the original <select> element. */
|
|
441
568
|
destroy() {
|
|
569
|
+
this.#abortController.abort();
|
|
442
570
|
this.container.remove();
|
|
443
571
|
this.selectElement.style.display = this.previousDisplay;
|
|
444
572
|
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
|
-
*
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
buttonDisabled: "
|
|
45
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
buttonDisabled: "
|
|
87
|
-
|
|
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
|
};
|