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 +51 -25
- package/package.json +12 -6
- package/src/FilteredSelectMultiple.js +308 -177
- package/src/icons.js +18 -0
- package/src/themes.js +30 -27
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Filtered Select Multiple Widget
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/filtered-select-multiple-widget)
|
|
4
|
+
[](https://github.com/tombreit/filtered-select-multiple-widget/blob/main/LICENSE)
|
|
5
|
+
[](https://github.com/tombreit/filtered-select-multiple-widget/actions/workflows/publish.yml)
|
|
6
|
+
[](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
|

|
|
@@ -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
|
|
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
|
|
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
|
|
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` | `
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
```js
|
|
119
|
+
const customTheme = {
|
|
106
120
|
container: "my-widget-container",
|
|
107
121
|
button: "my-button-class",
|
|
108
|
-
buttonAddSelected: "my-
|
|
122
|
+
buttonAddSelected: "my-primary-button",
|
|
109
123
|
filter: "my-input-class",
|
|
110
124
|
select: "my-select-class",
|
|
111
|
-
// ...
|
|
112
|
-
}
|
|
125
|
+
// ... only override what you need
|
|
126
|
+
};
|
|
113
127
|
|
|
114
128
|
new FilteredSelectMultiple(select, { theme: customTheme });
|
|
115
129
|
```
|
|
116
130
|
|
|
117
|
-
#### Available Theme
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
+
"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
|
-
"
|
|
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
|
-
"
|
|
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,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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
|
367
|
-
|
|
368
|
-
this.
|
|
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
|
-
//
|
|
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.
|
|
413
|
-
const hasChosenItems = this.chosen.
|
|
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
|
|
430
|
-
const
|
|
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
|
-
|
|
433
|
-
if (disabled)
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
};
|