@keenmate/web-multiselect 1.12.0-rc08 → 2.0.0-rc01

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/docs/usage.md CHANGED
@@ -1,339 +1,396 @@
1
- # Usage & API reference
2
-
3
- This document covers the public surface of `<web-multiselect>` — declarative HTML usage, the full attribute / property / method / event tables, and the data shape.
4
-
5
- ## Declarative (no JavaScript)
6
-
7
- Perfect for simple forms — just use standard HTML `<option>` and `<optgroup>` elements:
8
-
9
- ```html
10
- <!-- Simple choice -->
11
- <web-multiselect multiple="false">
12
- <option value="yes">Yes</option>
13
- <option value="no">No</option>
14
- <option value="maybe" selected>Maybe</option>
15
- </web-multiselect>
16
-
17
- <!-- With icons -->
18
- <web-multiselect>
19
- <option value="apple" data-icon="🍎">Apple</option>
20
- <option value="banana" data-icon="🍌" selected>Banana</option>
21
- <option value="orange" data-icon="🍊">Orange</option>
22
- </web-multiselect>
23
-
24
- <!-- With groups -->
25
- <web-multiselect>
26
- <optgroup label="Frontend">
27
- <option value="js" data-icon="🟨">JavaScript</option>
28
- <option value="ts" data-icon="🔷">TypeScript</option>
29
- </optgroup>
30
- <optgroup label="Backend">
31
- <option value="python" data-icon="🐍" selected>Python</option>
32
- <option value="java" data-icon="☕">Java</option>
33
- </optgroup>
34
- </web-multiselect>
35
- ```
36
-
37
- ## Programmatic (with JavaScript)
38
-
39
- For dynamic data and advanced features:
40
-
41
- ```html
42
- <!-- Multi-select -->
43
- <web-multiselect
44
- id="my-select"
45
- search-placeholder="Search options..."
46
- initial-values='["js","ts"]'>
47
- </web-multiselect>
48
- ```
49
-
50
- ```typescript
51
- // Import the component (includes styles)
52
- import '@keenmate/web-multiselect';
53
-
54
- // Or import styles separately if needed
55
- import '@keenmate/web-multiselect/style.css';
56
-
57
- const multiselect = document.querySelector('web-multiselect');
58
-
59
- // Set options programmatically
60
- multiselect.options = [
61
- { value: 'js', label: 'JavaScript', icon: '🟨' },
62
- { value: 'ts', label: 'TypeScript', icon: '🔷' },
63
- { value: 'py', label: 'Python', icon: '🐍' }
64
- ];
65
-
66
- // Listen for events
67
- multiselect.addEventListener('change', (e) => {
68
- console.log('Selected:', e.detail.selectedOptions);
69
- console.log('Values:', e.detail.selectedValues);
70
- });
71
-
72
- // Public API
73
- const selected = multiselect.getSelected();
74
- multiselect.setSelected(['js', 'ts']);
75
- ```
76
-
77
- ## Attributes
78
-
79
- | Attribute | Type | Default | Description |
80
- |-----------|------|---------|-------------|
81
- | `multiple` | `boolean` | `true` | Allow multiple selections |
82
- | `search-placeholder` | `string` | `'Search...'` | Placeholder text shown while search is usable |
83
- | `select-placeholder` | `string` | `'Pick an option...'` | Placeholder shown when search is disabled (`enable-search="false"`, or `search-input-mode` `readonly`/`hidden`) — the input acts as a picker, not a search box |
84
- | `no-data-placeholder` | `string` | - | Opt-in placeholder shown when the option list is empty, so users see there's no data without opening. Highest priority when the list is empty. Useful for cascade multiselects (a child whose parent isn't resolved yet) |
85
- | `search-hint` | `string` | - | Hint text shown above input when focused |
86
- | `allow-groups` | `boolean` | `true` | Enable option grouping |
87
- | `show-checkboxes` | `boolean` | `true` | Show checkboxes next to options |
88
- | `close-on-select` | `boolean` | `false` | Close dropdown after selecting |
89
- | `dropdown-min-width` | `string` | - | Min width for dropdown (e.g., `'20rem'`) |
90
- | `badges-display-mode` | `'pills' \| 'count' \| 'compact' \| 'partial' \| 'none'` | `'pills'` | How to display selected items. `compact`: first item + count. `none`: no display |
91
- | `badges-threshold` | `number` | - | Auto-switch mode when exceeded (see `badges-threshold-mode`) |
92
- | `badges-threshold-mode` | `'count' \| 'partial'` | `'count'` | Mode after threshold: `count` shows badge, `partial` shows limited badges + more badge |
93
- | `badges-max-visible` | `number` | `3` | Max badges shown in partial mode |
94
- | `badges-position` | `'top' \| 'bottom' \| 'left' \| 'right'` | `'bottom'` | Position of badges container |
95
- | `show-counter` | `boolean` | `false` | Show `[3]` badge next to toggle icon |
96
- | `enable-badge-tooltips` | `boolean` | `false` | Enable tooltips on selected badges |
97
- | `badge-tooltip-placement` | `'top' \| 'bottom' \| 'left' \| 'right'` | `'top'` | Tooltip placement relative to badge |
98
- | `badge-tooltip-delay` | `number` | `100` | Delay in ms before showing tooltip |
99
- | `badge-tooltip-offset` | `number` | `8` | Distance in pixels between badge and tooltip |
100
- | `enable-option-tooltips` | `boolean` | `false` | Enable hover tooltips on dropdown options |
101
- | `option-tooltip-placement` | `'top' \| 'top-start' \| … \| 'left' \| 'right'` | `'top-start'` | Option tooltip placement (anchored to the row's start edge by default; use `left`/`right` for the start/end side on a narrow control) |
102
- | `option-tooltip-delay` | `number` | inherits `badge-tooltip-delay`, then `100` | Delay in ms before showing an option tooltip |
103
- | `option-tooltip-offset` | `number` | inherits `badge-tooltip-offset`, then `8` | Distance in pixels between row and option tooltip |
104
- | `option-tooltip-follow-cursor` | `boolean` | `false` | Anchor the option tooltip to the mouse pointer and follow it across the row (best for full-width rows) |
105
- | `max-height` | `string` | `'20rem'` | Maximum height of dropdown |
106
- | `empty-message` | `string` | `'No results found'` | Message when no options found |
107
- | `loading-message` | `string` | `'Loading...'` | Message while loading async data |
108
- | `min-search-length` | `number` | `0` | Minimum search length for async |
109
- | `search-debounce` | `number` | `0` | Debounce (ms) before the async `searchCallback` fires; coalesces keystroke bursts into one request. Resets on each keystroke. Async callback only — local filtering stays instant. `0` = no debounce |
110
- | `keep-options-on-search` | `boolean` | `true` | Keep initial options visible when `searchCallback` is active (hybrid search) |
111
- | `should-keep-search-on-close` | `boolean` | `true` | Preserve search text and filtered results when dropdown closes |
112
- | `sticky-actions` | `boolean` | `true` | Keep the action-buttons block pinned to its edge while scrolling |
113
- | `actions-layout` | `'nowrap' \| 'wrap'` | `'nowrap'` | Whether buttons in a row wrap: `nowrap` (single line) or `wrap` |
114
- | `actions-position` | `'top' \| 'bottom'` | `'top'` | Place the actions block at the top or bottom (sticky footer) of the dropdown |
115
- | `actions-align` | `'stretch' \| 'left' \| 'right' \| 'center' \| 'space-between'` | `'stretch'` | Horizontal arrangement of buttons within a row (`stretch` = full-width) |
116
- | `lock-placement` | `boolean` | `true` | Lock dropdown placement after first open to prevent flipping |
117
- | `enable-search` | `boolean` | `true` | Enable/disable search functionality |
118
- | `search-input-mode` | `'normal' \| 'readonly' \| 'hidden'` | `'normal'` | Search input display mode |
119
- | `search-mode` | `'filter' \| 'navigate'` | `'filter'` | Search behavior: `filter` hides non-matches, `navigate` jumps to matches |
120
- | `allow-add-new` | `boolean` | `false` | Allow adding new options not in the list |
121
- | `value-member` | `string` | - | Property name for value/ID extraction from custom objects |
122
- | `display-value-member` | `string` | - | Property name for display text extraction from custom objects |
123
- | `search-value-member` | `string` | - | Property name for search text extraction from custom objects |
124
- | `icon-member` | `string` | - | Property name for icon extraction from custom objects |
125
- | `subtitle-member` | `string` | - | Property name for subtitle extraction from custom objects |
126
- | `group-member` | `string` | - | Property name for group extraction from custom objects |
127
- | `disabled-member` | `string` | - | Property name for disabled state extraction from custom objects |
128
- | `name` | `string` | - | HTML form field name for form integration (creates hidden input) |
129
- | `value-format` | `'json' \| 'csv' \| 'array'` | `'json'` | Format for form value serialization |
130
- | `initial-values` | `string` (JSON array) | - | Pre-selected values |
131
- | `enable-virtual-scroll` | `boolean` | `false` | Enable virtual scrolling for large datasets |
132
- | `virtual-scroll-threshold` | `number` | `100` | Minimum items before virtual scroll activates |
133
- | `option-height` | `number` | `50` | Fixed height for each option in pixels (required for virtual scroll) |
134
- | `virtual-scroll-buffer` | `number` | `10` | Buffer size extra items rendered above/below viewport |
135
-
136
- ## Properties
137
-
138
- ```typescript
139
- // Get/set options
140
- multiselect.options = [
141
- { value: 'js', label: 'JavaScript' },
142
- { value: 'ts', label: 'TypeScript' }
143
- ];
144
-
145
- // Async data loading
146
- multiselect.onSearch = async (searchTerm) => {
147
- const response = await fetch(`/api/search?q=${searchTerm}`);
148
- return await response.json();
149
- };
150
-
151
- // Pre-process search terms before calling searchCallback
152
- multiselect.beforeSearchCallback = (searchTerm) => {
153
- // Remove accents: "café" "cafe"
154
- const normalized = searchTerm.normalize('NFD').replace(/[̀-ͯ]/g, '');
155
-
156
- // Block search if too short (return null to prevent search)
157
- if (normalized.length < 2) return null;
158
-
159
- return normalized; // Return transformed term
160
- };
161
-
162
- // Interceptors veto a selection/deselection before it happens.
163
- // Return false to block; return true/undefined to allow. Silent (no event).
164
- // Receives the option being toggled and the current selection (before the change).
165
- // Note: programmatic setSelected() and the Select-All / Clear-All buttons bypass these.
166
- multiselect.beforeSelectCallback = (option, selectedOptions) => {
167
- // Example: don't allow selecting "item1" while "item2" is already selected
168
- if (option.value === 'item1' && selectedOptions.some(o => o.value === 'item2')) {
169
- return false; // blocked
170
- }
171
- };
172
-
173
- multiselect.beforeDeselectCallback = (option, selectedOptions) => {
174
- // Example: "item1" is required can't be removed once chosen
175
- if (option.value === 'item1') return false;
176
- };
177
-
178
- // Event callbacks
179
- multiselect.onSelect = (option) => {
180
- console.log('Selected:', option);
181
- };
182
-
183
- multiselect.onDeselect = (option) => {
184
- console.log('Deselected:', option);
185
- };
186
-
187
- multiselect.onChange = (selectedOptions) => {
188
- console.log('Changed:', selectedOptions);
189
- };
190
-
191
- // Badge display customization (show different text in badges vs dropdown)
192
- multiselect.getBadgeDisplayCallback = (item) => {
193
- // Show shorter text in badges (e.g., just name instead of "name (email)")
194
- return item.name; // Dropdown might show "John Doe (john@example.com)"
195
- };
196
-
197
- // Badge tooltip customization
198
- multiselect.getBadgeTooltipCallback = (item) => {
199
- return `${item.label} - ${item.subtitle}`;
200
- };
201
-
202
- // Option (dropdown row) tooltip customization — requires enable-option-tooltips
203
- multiselect.getOptionTooltipCallback = (item) => {
204
- return `${item.label} — ${item.description}`;
205
- };
206
-
207
- // Action buttons (Select All, Clear All, custom actions)
208
- multiselect.actionButtons = [
209
- {
210
- action: 'select-all',
211
- text: 'Select All',
212
- tooltip: 'Select all items',
213
- cssClass: 'my-custom-class',
214
- getIsVisibleCallback: (multiselect) => multiselect.getSelected().length < 5 // Hide if 5+ selected
215
- },
216
- {
217
- action: 'clear-all',
218
- text: 'Clear All',
219
- tooltip: 'Clear selection',
220
- isVisible: true, // Static visibility
221
- isDisabled: false // Static disabled state
222
- },
223
- {
224
- action: 'custom',
225
- text: 'Invert',
226
- tooltip: 'Invert selection',
227
- onClick: (multiselect) => {
228
- // Custom action - invert selection
229
- const allValues = multiselect.options.map(opt => opt.value);
230
- const selectedValues = multiselect.getValue();
231
- const inverted = allValues.filter(v => !selectedValues.includes(v));
232
- multiselect.setSelected(inverted);
233
- },
234
- // Dynamic callbacks (take priority over static properties)
235
- getIsDisabledCallback: (multiselect) => multiselect.getSelected().length === 0,
236
- getTextCallback: (multiselect) => multiselect.getSelected().length > 0 ? 'Invert' : 'Select Items First',
237
- getClassCallback: (multiselect) => multiselect.getSelected().length > 0 ? 'active' : 'inactive'
238
- }
239
- ];
240
-
241
- // Counter i18n/pluralization
242
- multiselect.getCounterCallback = (count, moreCount) => {
243
- if (moreCount !== undefined) {
244
- return `+${moreCount} more`; // Partial mode badge
245
- }
246
- return `${count} selected`; // Count mode display
247
- };
248
-
249
- // Data extraction - Member properties (for simple property names)
250
- multiselect.valueMember = 'id';
251
- multiselect.displayValueMember = 'name';
252
- multiselect.iconMember = 'icon';
253
- multiselect.subtitleMember = 'description';
254
- multiselect.groupMember = 'category';
255
- multiselect.disabledMember = 'isDisabled';
256
-
257
- // Data extraction - Callback functions (for complex logic)
258
- multiselect.getValueCallback = (item) => item.id || item.value;
259
- multiselect.getDisplayValueCallback = (item) => item.label || item.name;
260
- multiselect.getSearchValueCallback = (item) => `${item.name} ${item.tags.join(' ')}`;
261
- multiselect.getIconCallback = (item) => item.icon || '📄';
262
- multiselect.getSubtitleCallback = (item) => `${item.price} - ${item.stock} in stock`;
263
- multiselect.getGroupCallback = (item) => item.category;
264
- multiselect.getDisabledCallback = (item) => item.stock === 0;
265
-
266
- // Custom rendering - Full HTML control
267
- multiselect.renderGroupLabelContentCallback = (groupName) => {
268
- return `<strong>📦 ${groupName.toUpperCase()}</strong>`;
269
- };
270
-
271
- multiselect.renderOptionContentCallback = (item, context) => {
272
- return `<strong>${item.name}</strong> <span class="badge">${item.status}</span>`;
273
- };
274
-
275
- multiselect.renderBadgeContentCallback = (item, context) => {
276
- return context.isInPopover
277
- ? `${item.icon} ${item.name} - ${item.description}`
278
- : `${item.icon} ${item.name}`;
279
- };
280
-
281
- multiselect.renderSelectedContentCallback = (item) => {
282
- // Customize selected item text in single-select mode (plain text only)
283
- return item.firstName;
284
- };
285
-
286
- // Form integration
287
- multiselect.name = 'selected_items';
288
- multiselect.valueFormat = 'json'; // 'json' | 'csv' | 'array'
289
- multiselect.getValueFormatCallback = (values) => values.join('|'); // Custom format
290
-
291
- // Read-only properties
292
- const selectedValue = multiselect.selectedValue; // string | number | array | null
293
- const selectedItem = multiselect.selectedItem; // First selected item object
294
-
295
- // Add new option callback
296
- multiselect.addNewCallback = async (value) => {
297
- const newOption = await fetch('/api/options', {
298
- method: 'POST',
299
- body: JSON.stringify({ name: value })
300
- }).then(r => r.json());
301
- return newOption;
302
- };
303
- ```
304
-
305
- ## Methods
306
-
307
- | Method | Description |
308
- |--------|-------------|
309
- | `getSelected()` | Get currently selected options as array of option objects |
310
- | `setSelected(values: (string \| number)[])` | Set selected values by ID/value |
311
- | `getValue()` | Get selected value(s) — returns single value in single-select mode, array in multi-select mode |
312
- | `setAttributes(attrs: Record<string, string \| number \| boolean \| null>)` | Set several attributes in a single in-place update (one re-render instead of one per attribute). Keys are kebab-case attribute names, as `setAttribute`; `null`/`undefined`/`false` removes the attribute, `true` sets it to `""`. Handy for i18n switches that change multiple strings at once |
313
- | `destroy()` | Clean up and destroy instance |
314
-
315
- ## Events
316
-
317
- | Event | Detail | Description |
318
- |-------|--------|-------------|
319
- | `select` | `{ option, selectedOptions }` | Fired when an option is selected |
320
- | `deselect` | `{ option, selectedOptions }` | Fired when an option is deselected |
321
- | `change` | `{ selectedOptions, selectedValues }` | Fired when selection changes |
322
-
323
- ## Option structure
324
-
325
- ```typescript
326
- interface MultiSelectOption {
327
- value: string; // Required: Unique identifier
328
- label: string; // Required: Display text
329
- icon?: string; // Optional: Icon or emoji
330
- subtitle?: string; // Optional: Subtitle/description
331
- group?: string; // Optional: Group name
332
- disabled?: boolean; // Optional: Disable selection
333
- }
334
- ```
335
-
336
- The component also accepts:
337
-
338
- - **Tuple arrays** like `[['js', 'JavaScript'], ['ts', 'TypeScript']]` — first element becomes value, second becomes display text.
339
- - **Arbitrary custom objects** via member attributes (`value-member`, `display-value-member`, etc.) or callbacks (`getValueCallback`, `getDisplayValueCallback`, etc.). See [examples.md → Flexible Data Handling](./examples.md#flexible-data-handling).
1
+ # Usage & API reference
2
+
3
+ This document covers the public surface of `<web-multiselect>` — declarative HTML usage, the full attribute / property / method / event tables, and the data shape.
4
+
5
+ ## Declarative (no JavaScript)
6
+
7
+ Perfect for simple forms — just use standard HTML `<option>` and `<optgroup>` elements:
8
+
9
+ ```html
10
+ <!-- Simple choice -->
11
+ <web-multiselect multiple="false">
12
+ <option value="yes">Yes</option>
13
+ <option value="no">No</option>
14
+ <option value="maybe" selected>Maybe</option>
15
+ </web-multiselect>
16
+
17
+ <!-- With icons -->
18
+ <web-multiselect>
19
+ <option value="apple" data-icon="🍎">Apple</option>
20
+ <option value="banana" data-icon="🍌" selected>Banana</option>
21
+ <option value="orange" data-icon="🍊">Orange</option>
22
+ </web-multiselect>
23
+
24
+ <!-- With groups -->
25
+ <web-multiselect>
26
+ <optgroup label="Frontend">
27
+ <option value="js" data-icon="🟨">JavaScript</option>
28
+ <option value="ts" data-icon="🔷">TypeScript</option>
29
+ </optgroup>
30
+ <optgroup label="Backend">
31
+ <option value="python" data-icon="🐍" selected>Python</option>
32
+ <option value="java" data-icon="☕">Java</option>
33
+ </optgroup>
34
+ </web-multiselect>
35
+ ```
36
+
37
+ ### Option data in HTML (`data-options`)
38
+
39
+ For simple static lists without `<option>` children or JavaScript, put the data
40
+ in the `data-options` attribute and pick a format with `data-options-format`:
41
+
42
+ ```html
43
+ <!-- json (default): array of objects or [value, label] tuples -->
44
+ <web-multiselect
45
+ value-member="value" display-value-member="label"
46
+ data-options='[{"value":"js","label":"JavaScript"},{"value":"ts","label":"TypeScript"}]'>
47
+ </web-multiselect>
48
+
49
+ <!-- csv: first row is a header; map the columns via *-member -->
50
+ <web-multiselect
51
+ data-options-format="csv"
52
+ value-member="value" display-value-member="label"
53
+ data-options="value,label&#10;js,JavaScript&#10;ts,TypeScript">
54
+ </web-multiselect>
55
+
56
+ <!-- plain: comma/newline-separated bare values (value === label), no member config -->
57
+ <web-multiselect data-options-format="plain" data-options="Apple,Banana,Cherry"></web-multiselect>
58
+
59
+ <!-- custom delimiters: semicolon cells, pipe rows (single-line csv) -->
60
+ <web-multiselect
61
+ data-options-format="csv"
62
+ data-options-splitter=";" data-options-row-splitter="|"
63
+ value-member="value" display-value-member="label"
64
+ data-options="value;label|js;JavaScript|ts;TypeScript">
65
+ </web-multiselect>
66
+ ```
67
+
68
+ `data-options-splitter` / `data-options-row-splitter` customize the `csv` and
69
+ `plain` delimiters (default `,` and newline); use `\t` for a tab (TSV). They're
70
+ ignored for `json`.
71
+
72
+ Both attributes are reactive — changing either re-renders. Declarative `<option>`
73
+ children and a `.options` property set in JS both take precedence over `data-options`.
74
+
75
+ ## Programmatic (with JavaScript)
76
+
77
+ For dynamic data and advanced features:
78
+
79
+ ```html
80
+ <!-- Multi-select -->
81
+ <web-multiselect
82
+ id="my-select"
83
+ search-placeholder="Search options..."
84
+ initial-values='["js","ts"]'>
85
+ </web-multiselect>
86
+ ```
87
+
88
+ ```typescript
89
+ // Import the component (includes styles)
90
+ import '@keenmate/web-multiselect';
91
+
92
+ // Or import styles separately if needed
93
+ import '@keenmate/web-multiselect/style.css';
94
+
95
+ const multiselect = document.querySelector('web-multiselect');
96
+
97
+ // Set options programmatically
98
+ multiselect.options = [
99
+ { value: 'js', label: 'JavaScript', icon: '🟨' },
100
+ { value: 'ts', label: 'TypeScript', icon: '🔷' },
101
+ { value: 'py', label: 'Python', icon: '🐍' }
102
+ ];
103
+
104
+ // Listen for events
105
+ multiselect.addEventListener('change', (e) => {
106
+ console.log('Selected:', e.detail.selectedOptions);
107
+ console.log('Values:', e.detail.selectedValues);
108
+ });
109
+
110
+ // Public API
111
+ const selected = multiselect.getSelected();
112
+ multiselect.setSelected(['js', 'ts']);
113
+ ```
114
+
115
+ ## Attributes
116
+
117
+ | Attribute | Type | Default | Description |
118
+ |-----------|------|---------|-------------|
119
+ | `multiple` | `boolean` | `true` | Allow multiple selections |
120
+ | `search-placeholder` | `string` | `'Search...'` | Placeholder text shown while search is usable |
121
+ | `select-placeholder` | `string` | `'Pick an option...'` | Placeholder shown when search is disabled (`enable-search="false"`, or `search-input-mode` `readonly`/`hidden`) — the input acts as a picker, not a search box |
122
+ | `no-data-placeholder` | `string` | - | Opt-in placeholder shown when the option list is empty, so users see there's no data without opening. Highest priority when the list is empty. Useful for cascade multiselects (a child whose parent isn't resolved yet) |
123
+ | `search-hint` | `string` | - | Hint text shown above input when focused |
124
+ | `allow-groups` | `boolean` | `true` | Enable option grouping |
125
+ | `show-checkboxes` | `boolean` | `true` | Show checkboxes next to options |
126
+ | `close-on-select` | `boolean` | `false` | Close dropdown after selecting |
127
+ | `dropdown-min-width` | `string` | - | Min width for dropdown (e.g., `'20rem'`) |
128
+ | `badges-display-mode` | `'pills' \| 'count' \| 'compact' \| 'partial' \| 'none'` | `'pills'` | How to display selected items. `compact`: first item + count. `none`: no display |
129
+ | `badges-threshold` | `number` | - | Auto-switch mode when exceeded (see `badges-threshold-mode`) |
130
+ | `badges-threshold-mode` | `'count' \| 'partial'` | `'count'` | Mode after threshold: `count` shows badge, `partial` shows limited badges + more badge |
131
+ | `badges-max-visible` | `number` | `3` | Max badges shown in partial mode |
132
+ | `badges-position` | `'top' \| 'bottom' \| 'left' \| 'right'` | `'bottom'` | Position of badges container |
133
+ | `show-counter` | `boolean` | `false` | Show `[3]` badge next to toggle icon |
134
+ | `enable-badge-tooltips` | `boolean` | `false` | Enable tooltips on selected badges |
135
+ | `badge-tooltip-placement` | `'top' \| 'bottom' \| 'left' \| 'right'` | `'top'` | Tooltip placement relative to badge |
136
+ | `badge-tooltip-delay` | `number` | `100` | Delay in ms before showing tooltip |
137
+ | `badge-tooltip-offset` | `number` | `8` | Distance in pixels between badge and tooltip |
138
+ | `enable-option-tooltips` | `boolean` | `false` | Enable hover tooltips on dropdown options |
139
+ | `option-tooltip-placement` | `'top' \| 'top-start' \| … \| 'left' \| 'right'` | `'top-start'` | Option tooltip placement (anchored to the row's start edge by default; use `left`/`right` for the start/end side on a narrow control) |
140
+ | `option-tooltip-delay` | `number` | inherits `badge-tooltip-delay`, then `100` | Delay in ms before showing an option tooltip |
141
+ | `option-tooltip-offset` | `number` | inherits `badge-tooltip-offset`, then `8` | Distance in pixels between row and option tooltip |
142
+ | `option-tooltip-follow-cursor` | `boolean` | `false` | Anchor the option tooltip to the mouse pointer and follow it across the row (best for full-width rows) |
143
+ | `max-height` | `string` | `'20rem'` | Maximum height of dropdown |
144
+ | `empty-message` | `string` | `'No results found'` | Message when no options found |
145
+ | `loading-message` | `string` | `'Loading...'` | Message while loading async data |
146
+ | `min-search-length` | `number` | `0` | Minimum search length for async |
147
+ | `search-debounce` | `number` | `0` | Debounce (ms) before the async `searchCallback` fires; coalesces keystroke bursts into one request. Resets on each keystroke. Async callback only — local filtering stays instant. `0` = no debounce |
148
+ | `keep-options-on-search` | `boolean` | `true` | Keep initial options visible when `searchCallback` is active (hybrid search) |
149
+ | `should-keep-search-on-close` | `boolean` | `true` | Preserve search text and filtered results when dropdown closes |
150
+ | `sticky-actions` | `boolean` | `true` | Keep the action-buttons block pinned to its edge while scrolling |
151
+ | `actions-layout` | `'nowrap' \| 'wrap'` | `'nowrap'` | Whether buttons in a row wrap: `nowrap` (single line) or `wrap` |
152
+ | `actions-position` | `'top' \| 'bottom'` | `'top'` | Place the actions block at the top or bottom (sticky footer) of the dropdown |
153
+ | `actions-align` | `'stretch' \| 'left' \| 'right' \| 'center' \| 'space-between'` | `'stretch'` | Horizontal arrangement of buttons within a row (`stretch` = full-width) |
154
+ | `lock-placement` | `boolean` | `true` | Lock dropdown placement after first open to prevent flipping |
155
+ | `enable-search` | `boolean` | `true` | Enable/disable search functionality |
156
+ | `search-input-mode` | `'normal' \| 'readonly' \| 'hidden'` | `'normal'` | Search input display mode |
157
+ | `search-mode` | `'filter' \| 'navigate'` | `'filter'` | Search behavior: `filter` hides non-matches, `navigate` jumps to matches |
158
+ | `allow-add-new` | `boolean` | `false` | Allow adding new options not in the list |
159
+ | `value-member` | `string` | - | Property name for value/ID extraction from custom objects |
160
+ | `display-value-member` | `string` | - | Property name for display text extraction from custom objects |
161
+ | `search-value-member` | `string` | - | Property name for search text extraction from custom objects |
162
+ | `icon-member` | `string` | - | Property name for icon extraction from custom objects |
163
+ | `subtitle-member` | `string` | - | Property name for subtitle extraction from custom objects |
164
+ | `group-member` | `string` | - | Property name for group extraction from custom objects |
165
+ | `disabled-member` | `string` | - | Property name for disabled state extraction from custom objects |
166
+ | `name` | `string` | - | HTML form field name for form integration (creates hidden input) |
167
+ | `value-format` | `'json' \| 'csv' \| 'array'` | `'json'` | Format for form value serialization |
168
+ | `initial-values` | `string` (JSON array or CSV) | - | Pre-selected values. Accepts `["js","ts"]` or a bare `js,ts` |
169
+ | `data-options` | `string` | - | HTML-authoring source for the option list, parsed per `data-options-format`. Prefer the `options` property in JS. Reactive; declarative `<option>` children and a set `options` property both take precedence |
170
+ | `data-options-format` | `'json' \| 'csv' \| 'plain'` | `'json'` | How to parse `data-options`: `json` (array of objects or `[value,label]` tuples), `csv` (first row = header → object per row, keyed by the header cells; map columns via `*-member`), or `plain` (bare values → `value=label` options) |
171
+ | `data-options-splitter` | `string` | `','` | Field/cell delimiter for the `csv` and `plain` formats (e.g. `;`, `\|`). Escapes `\t` `\n` `\r` are honoured (`"\t"` → TSV). Ignored for `json` |
172
+ | `data-options-row-splitter` | `string` | newline | Row/record delimiter for the `csv` and `plain` formats (e.g. `;` for single-line data). Escapes honoured. Ignored for `json` |
173
+ | `enable-virtual-scroll` | `boolean` | `false` | Enable virtual scrolling for large datasets |
174
+ | `virtual-scroll-threshold` | `number` | `100` | Minimum items before virtual scroll activates |
175
+ | `option-height` | `number` | `50` | Fixed height for each option in pixels (required for virtual scroll) |
176
+ | `virtual-scroll-buffer` | `number` | `10` | Buffer size — extra items rendered above/below viewport |
177
+
178
+ ## Properties
179
+
180
+ ```typescript
181
+ // Get/set options
182
+ multiselect.options = [
183
+ { value: 'js', label: 'JavaScript' },
184
+ { value: 'ts', label: 'TypeScript' }
185
+ ];
186
+
187
+ // Async data loading. `signal` aborts when a newer search supersedes this one —
188
+ // forward it to fetch to cancel the stale request.
189
+ multiselect.searchCallback = async (searchTerm, signal) => {
190
+ const response = await fetch(`/api/search?q=${searchTerm}`, { signal });
191
+ return await response.json();
192
+ };
193
+
194
+ // Pre-process search terms before calling searchCallback
195
+ multiselect.beforeSearchCallback = (searchTerm) => {
196
+ // Remove accents: "café" → "cafe"
197
+ const normalized = searchTerm.normalize('NFD').replace(/[̀-ͯ]/g, '');
198
+
199
+ // Block search if too short (return null to prevent search)
200
+ if (normalized.length < 2) return null;
201
+
202
+ return normalized; // Return transformed term
203
+ };
204
+
205
+ // Interceptors — veto a selection/deselection before it happens.
206
+ // Return false to block; return true/undefined to allow. Silent (no event).
207
+ // Receives the option being toggled and the current selection (before the change).
208
+ // Note: programmatic setSelected() and the Select-All / Clear-All buttons bypass these.
209
+ multiselect.beforeSelectCallback = (option, selectedOptions) => {
210
+ // Example: don't allow selecting "item1" while "item2" is already selected
211
+ if (option.value === 'item1' && selectedOptions.some(o => o.value === 'item2')) {
212
+ return false; // blocked
213
+ }
214
+ };
215
+
216
+ multiselect.beforeDeselectCallback = (option, selectedOptions) => {
217
+ // Example: "item1" is required — can't be removed once chosen
218
+ if (option.value === 'item1') return false;
219
+ };
220
+
221
+ // Event handler properties. Since v2 these are real listeners: each receives
222
+ // the same CustomEvent addEventListener('select', ...) would get, so read the
223
+ // payload off `e.detail` — NOT as a bare argument.
224
+ multiselect.onSelect = (e) => {
225
+ console.log('Selected:', e.detail.option);
226
+ };
227
+
228
+ multiselect.onDeselect = (e) => {
229
+ console.log('Deselected:', e.detail.option);
230
+ };
231
+
232
+ multiselect.onChange = (e) => {
233
+ console.log('Changed:', e.detail.selectedOptions, e.detail.selectedValues);
234
+ };
235
+
236
+ // Badge display customization (show different text in badges vs dropdown)
237
+ multiselect.getBadgeDisplayCallback = (item) => {
238
+ // Show shorter text in badges (e.g., just name instead of "name (email)")
239
+ return item.name; // Dropdown might show "John Doe (john@example.com)"
240
+ };
241
+
242
+ // Badge tooltip customization
243
+ multiselect.getBadgeTooltipCallback = (item) => {
244
+ return `${item.label} - ${item.subtitle}`;
245
+ };
246
+
247
+ // Option (dropdown row) tooltip customization — requires enable-option-tooltips
248
+ multiselect.getOptionTooltipCallback = (item) => {
249
+ return `${item.label} ${item.description}`;
250
+ };
251
+
252
+ // Action buttons (Select All, Clear All, custom actions)
253
+ multiselect.actionButtons = [
254
+ {
255
+ action: 'select-all',
256
+ text: 'Select All',
257
+ tooltip: 'Select all items',
258
+ cssClass: 'my-custom-class',
259
+ getIsVisibleCallback: (multiselect) => multiselect.getSelected().length < 5 // Hide if 5+ selected
260
+ },
261
+ {
262
+ action: 'clear-all',
263
+ text: 'Clear All',
264
+ tooltip: 'Clear selection',
265
+ isVisible: true, // Static visibility
266
+ isDisabled: false // Static disabled state
267
+ },
268
+ {
269
+ action: 'custom',
270
+ text: 'Invert',
271
+ tooltip: 'Invert selection',
272
+ onClick: (multiselect) => {
273
+ // Custom action - invert selection
274
+ const allValues = multiselect.options.map(opt => opt.value);
275
+ const selectedValues = multiselect.getValue();
276
+ const inverted = allValues.filter(v => !selectedValues.includes(v));
277
+ multiselect.setSelected(inverted);
278
+ },
279
+ // Dynamic callbacks (take priority over static properties)
280
+ getIsDisabledCallback: (multiselect) => multiselect.getSelected().length === 0,
281
+ getTextCallback: (multiselect) => multiselect.getSelected().length > 0 ? 'Invert' : 'Select Items First',
282
+ getClassCallback: (multiselect) => multiselect.getSelected().length > 0 ? 'active' : 'inactive'
283
+ }
284
+ ];
285
+
286
+ // Counter i18n/pluralization
287
+ multiselect.getCounterCallback = (count, moreCount) => {
288
+ if (moreCount !== undefined) {
289
+ return `+${moreCount} more`; // Partial mode badge
290
+ }
291
+ return `${count} selected`; // Count mode display
292
+ };
293
+
294
+ // Data extraction - Member properties (for simple property names)
295
+ multiselect.valueMember = 'id';
296
+ multiselect.displayValueMember = 'name';
297
+ multiselect.iconMember = 'icon';
298
+ multiselect.subtitleMember = 'description';
299
+ multiselect.groupMember = 'category';
300
+ multiselect.disabledMember = 'isDisabled';
301
+
302
+ // Data extraction - Callback functions (for complex logic)
303
+ multiselect.getValueCallback = (item) => item.id || item.value;
304
+ multiselect.getDisplayValueCallback = (item) => item.label || item.name;
305
+ multiselect.getSearchValueCallback = (item) => `${item.name} ${item.tags.join(' ')}`;
306
+ multiselect.getIconCallback = (item) => item.icon || '📄';
307
+ multiselect.getSubtitleCallback = (item) => `${item.price} - ${item.stock} in stock`;
308
+ multiselect.getGroupCallback = (item) => item.category;
309
+ multiselect.getDisabledCallback = (item) => item.stock === 0;
310
+
311
+ // Custom rendering - Full HTML control
312
+ multiselect.renderGroupLabelContentCallback = (groupName) => {
313
+ return `<strong>📦 ${groupName.toUpperCase()}</strong>`;
314
+ };
315
+
316
+ multiselect.renderOptionContentCallback = (item, context) => {
317
+ return `<strong>${item.name}</strong> <span class="badge">${item.status}</span>`;
318
+ };
319
+
320
+ multiselect.renderBadgeContentCallback = (item, context) => {
321
+ return context.isInPopover
322
+ ? `${item.icon} ${item.name} - ${item.description}`
323
+ : `${item.icon} ${item.name}`;
324
+ };
325
+
326
+ multiselect.renderSelectedContentCallback = (item) => {
327
+ // Customize selected item text in single-select mode (plain text only)
328
+ return item.firstName;
329
+ };
330
+
331
+ // Form integration
332
+ multiselect.name = 'selected_items';
333
+ multiselect.valueFormat = 'json'; // 'json' | 'csv' | 'array'
334
+ multiselect.getValueFormatCallback = (values) => values.join('|'); // Custom format
335
+
336
+ // Read-only properties
337
+ const selectedValue = multiselect.selectedValue; // string | number | array | null
338
+ const selectedItem = multiselect.selectedItem; // First selected item object
339
+
340
+ // Add new option callback
341
+ multiselect.addNewCallback = async (value) => {
342
+ const newOption = await fetch('/api/options', {
343
+ method: 'POST',
344
+ body: JSON.stringify({ name: value })
345
+ }).then(r => r.json());
346
+ return newOption;
347
+ };
348
+ ```
349
+
350
+ ### Reading back state after a write (v2)
351
+
352
+ Since v2, **property writes are coalesced** — setting a property (e.g. `el.options = […]`) applies on a microtask, not synchronously. This only matters when you read *rendered DOM* right after a write:
353
+
354
+ ```javascript
355
+ el.options = data;
356
+ await el.whenSettled(); // wait for the pending re-render
357
+ console.log(el.shadowRoot.querySelectorAll('.ms__option').length);
358
+ ```
359
+
360
+ You do **not** need to await between a property write and an imperative method — `getSelected()` / `setSelected()` / `getValue()` / `selectedValue` / `selectedItem` flush any pending write internally, so `el.options = data; el.setSelected(sel)` works with no `await` in between. `setAttributes()` and `batch()` also flush synchronously.
361
+
362
+ ## Methods
363
+
364
+ | Method | Description |
365
+ |--------|-------------|
366
+ | `getSelected()` | Get currently selected options as array of option objects |
367
+ | `setSelected(values: (string \| number)[])` | Set selected values by ID/value |
368
+ | `getValue()` | Get selected value(s) — returns single value in single-select mode, array in multi-select mode |
369
+ | `setAttributes(values: Record<string, unknown>)` | Apply several inputs as a single in-place update (one re-render instead of one per property). Keys are property names (`configKey`, e.g. `searchPlaceholder`) or their kebab attribute (`search-placeholder`); values are **typed property values**, validated exactly like a direct property assignment — not raw attribute strings. Flushes synchronously. Handy for i18n switches that change several strings at once. To batch raw attribute **strings** instead, use `batch(() => { setAttribute('search-placeholder', '…'); … })` |
370
+ | `destroy()` | Clean up and destroy instance |
371
+
372
+ ## Events
373
+
374
+ | Event | Detail | Description |
375
+ |-------|--------|-------------|
376
+ | `select` | `{ option, selectedOptions }` | Fired when an option is selected |
377
+ | `deselect` | `{ option, selectedOptions }` | Fired when an option is deselected |
378
+ | `change` | `{ selectedOptions, selectedValues }` | Fired when selection changes |
379
+
380
+ ## Option structure
381
+
382
+ ```typescript
383
+ interface MultiSelectOption {
384
+ value: string; // Required: Unique identifier
385
+ label: string; // Required: Display text
386
+ icon?: string; // Optional: Icon or emoji
387
+ subtitle?: string; // Optional: Subtitle/description
388
+ group?: string; // Optional: Group name
389
+ disabled?: boolean; // Optional: Disable selection
390
+ }
391
+ ```
392
+
393
+ The component also accepts:
394
+
395
+ - **Tuple arrays** like `[['js', 'JavaScript'], ['ts', 'TypeScript']]` — first element becomes value, second becomes display text.
396
+ - **Arbitrary custom objects** via member attributes (`value-member`, `display-value-member`, etc.) or callbacks (`getValueCallback`, `getDisplayValueCallback`, etc.). See [examples.md → Flexible Data Handling](./examples.md#flexible-data-handling).