@keenmate/web-multiselect 1.12.0-rc01 → 1.12.0-rc03

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.
@@ -0,0 +1,1180 @@
1
+ # Examples & cookbook
2
+
3
+ Working examples and recipes for `@keenmate/web-multiselect`. For the runnable demos, open any of the HTML files at the package root:
4
+
5
+ | File | What it shows |
6
+ |------|---------------|
7
+ | [`examples-classic.html`](../examples-classic.html) | Baseline declarative + programmatic usage |
8
+ | [`examples-new-api.html`](../examples-new-api.html) | Modern programmatic API |
9
+ | [`examples-templating.html`](../examples-templating.html) | Custom rendering callbacks |
10
+ | [`examples-action-buttons.html`](../examples-action-buttons.html) | Select All / Clear All / custom actions |
11
+ | [`examples-base-variables.html`](../examples-base-variables.html) | `--base-*` two-layer theming |
12
+ | [`examples-theming.html`](../examples-theming.html) | Full `--ms-*` theming surface |
13
+ | [`examples-sizes.html`](../examples-sizes.html) | Size variants and `--ms-rem` scaling |
14
+ | [`examples-positioning.html`](../examples-positioning.html) | Floating UI positioning edge cases |
15
+ | [`examples-performance.html`](../examples-performance.html) | Virtual scroll with 15,000 items |
16
+ | [`examples-logging.html`](../examples-logging.html) | Debug logging |
17
+
18
+ ## Rich content with icons
19
+
20
+ Icons support multiple formats — emojis, SVG markup, Font Awesome, images, or any HTML:
21
+
22
+ ```html
23
+ <web-multiselect id="frameworks"></web-multiselect>
24
+
25
+ <script type="module">
26
+ const select = document.getElementById('frameworks');
27
+ select.options = [
28
+ {
29
+ value: 'react',
30
+ label: 'React',
31
+ icon: '⚛️', // Emoji
32
+ subtitle: 'A JavaScript library for building user interfaces'
33
+ },
34
+ {
35
+ value: 'vue',
36
+ label: 'Vue.js',
37
+ icon: '<svg viewBox="0 0 24 24"><path d="M2 3l10 18L22 3h-4l-6 10.5L6 3H2z"/></svg>', // SVG
38
+ subtitle: 'The Progressive JavaScript Framework'
39
+ },
40
+ {
41
+ value: 'angular',
42
+ label: 'Angular',
43
+ icon: '<i class="fab fa-angular"></i>', // Font Awesome
44
+ subtitle: 'Platform for building mobile and desktop apps'
45
+ },
46
+ {
47
+ value: 'svelte',
48
+ label: 'Svelte',
49
+ icon: '<img src="svelte-logo.png" alt="Svelte" />', // Image
50
+ subtitle: 'Cybernetically enhanced web apps'
51
+ }
52
+ ];
53
+ </script>
54
+ ```
55
+
56
+ ## Grouped options
57
+
58
+ ```javascript
59
+ select.options = [
60
+ { value: 'js', label: 'JavaScript', group: 'Frontend' },
61
+ { value: 'ts', label: 'TypeScript', group: 'Frontend' },
62
+ { value: 'python', label: 'Python', group: 'Backend' },
63
+ { value: 'java', label: 'Java', group: 'Backend' }
64
+ ];
65
+ ```
66
+
67
+ ## Async data loading
68
+
69
+ ```html
70
+ <web-multiselect
71
+ id="async-select"
72
+ min-search-length="2"
73
+ loading-message="Searching..."
74
+ empty-message="No products found">
75
+ </web-multiselect>
76
+
77
+ <script type="module">
78
+ const select = document.getElementById('async-select');
79
+
80
+ select.onSearch = async (searchTerm) => {
81
+ const response = await fetch(`/api/products?q=${searchTerm}`);
82
+ const data = await response.json();
83
+ return data.products;
84
+ };
85
+ </script>
86
+ ```
87
+
88
+ ## Hybrid static + dynamic search
89
+
90
+ Show popular items initially, then switch to full database search when the user types. Perfect for showing "Top 10" items while supporting comprehensive search:
91
+
92
+ ```html
93
+ <web-multiselect
94
+ id="hybrid-select"
95
+ min-search-length="3"
96
+ keep-options-on-search="true">
97
+ </web-multiselect>
98
+
99
+ <script type="module">
100
+ const select = document.getElementById('hybrid-select');
101
+
102
+ // Set initial popular items (shown when dropdown opens)
103
+ select.options = [
104
+ { id: 1, name: 'React' },
105
+ { id: 2, name: 'Vue' },
106
+ { id: 3, name: 'Angular' },
107
+ { id: 4, name: 'Svelte' },
108
+ { id: 5, name: 'Solid' }
109
+ ];
110
+
111
+ // Pre-process search terms (remove accents, validate, etc.)
112
+ select.beforeSearchCallback = (searchTerm) => {
113
+ const normalized = searchTerm.normalize('NFD').replace(/[̀-ͯ]/g, '');
114
+ if (normalized.length < 2) return null;
115
+ return normalized;
116
+ };
117
+
118
+ // Search full database when user types 3+ characters
119
+ select.onSearch = async (searchTerm) => {
120
+ const response = await fetch(`/api/frameworks/search?q=${searchTerm}`);
121
+ return await response.json();
122
+ };
123
+ </script>
124
+ ```
125
+
126
+ **How it works:**
127
+ 1. **Dropdown opens** → shows 5 popular frameworks.
128
+ 2. **User types "rea"** → calls API, shows all matching results from database.
129
+ 3. **User clears search** → shows 5 popular frameworks again.
130
+ 4. **User types "café"** → `beforeSearchCallback` converts to "cafe", then searches.
131
+
132
+ **Key options:**
133
+ - `keep-options-on-search="true"` (default) — keep initial options visible when search is empty/short.
134
+ - `beforeSearchCallback` — transform search text or block search by returning `null`.
135
+ - `min-search-length` — minimum characters before triggering search (shows initial options below this).
136
+
137
+ ## Virtual scrolling for large datasets
138
+
139
+ Handle 10,000+ options with smooth 60fps performance by rendering only visible items:
140
+
141
+ ```html
142
+ <web-multiselect
143
+ id="large-dataset"
144
+ enable-virtual-scroll="true"
145
+ virtual-scroll-threshold="100"
146
+ option-height="50"
147
+ virtual-scroll-buffer="10"
148
+ search-mode="filter"
149
+ max-height="400px">
150
+ </web-multiselect>
151
+
152
+ <script type="module">
153
+ import '@keenmate/web-multiselect';
154
+
155
+ const select = document.getElementById('large-dataset');
156
+
157
+ // Generate 15,000 options
158
+ const largeDataset = Array.from({ length: 15000 }, (_, i) => ({
159
+ value: i,
160
+ label: `Item ${i.toString().padStart(5, '0')}`
161
+ }));
162
+
163
+ select.options = largeDataset;
164
+ </script>
165
+ ```
166
+
167
+ **Performance comparison (15,000 items):**
168
+
169
+ | Metric | Without virtual scroll | With virtual scroll | Improvement |
170
+ |--------|------------------------|---------------------|-------------|
171
+ | Initial render | 750ms | 30ms | **25× faster** |
172
+ | Search keystroke | 200-500ms | 15ms | **13-33× faster** |
173
+ | DOM nodes | 15,000 | ~30 | **99.8% reduction** |
174
+ | Memory usage | ~7.5 MB | ~15 KB | **500× less** |
175
+
176
+ **Configuration:**
177
+
178
+ - `enable-virtual-scroll="true"` — opt-in to virtual scrolling (default: `false`).
179
+ - `virtual-scroll-threshold="100"` — auto-activate when this many items are present (default: `100`).
180
+ - `option-height="50"` — fixed height per option in pixels (default: `50px`).
181
+ - `virtual-scroll-buffer="10"` — extra items rendered above/below viewport for smooth scrolling (default: `10`).
182
+
183
+ **How it works:**
184
+
185
+ - Only renders ~30 visible items instead of all 15,000 DOM elements.
186
+ - Uses absolute positioning with calculated offsets.
187
+ - Maintains 10-item buffer zones above/below viewport for smooth scrolling.
188
+ - Automatically calculates visible range based on scroll position.
189
+ - Works seamlessly with search filtering and selection.
190
+
191
+ **Requirements / limitations:**
192
+
193
+ - All options must have the same fixed height (enforced via CSS).
194
+ - Groups (`<optgroup>`) are disabled in virtual scroll mode — automatically falls back to standard rendering.
195
+ - Works with both filter and navigate search modes.
196
+
197
+ **Example with API data:**
198
+
199
+ ```html
200
+ <web-multiselect
201
+ id="products"
202
+ enable-virtual-scroll="true"
203
+ search-mode="filter"
204
+ value-member="id"
205
+ display-value-member="name"
206
+ max-height="400px">
207
+ </web-multiselect>
208
+
209
+ <script type="module">
210
+ const select = document.getElementById('products');
211
+
212
+ const response = await fetch('/api/products');
213
+ const products = await response.json();
214
+
215
+ select.options = products; // Could be 10,000+ items
216
+ </script>
217
+ ```
218
+
219
+ **Live demo:** [`examples-performance.html`](../examples-performance.html) — working demo with 15,000 randomly generated options.
220
+
221
+ ## Search modes: filter vs navigate
222
+
223
+ Choose between two search behaviors:
224
+
225
+ **Filter mode** (default) — hide non-matching options as you type:
226
+
227
+ ```html
228
+ <web-multiselect search-mode="filter" id="countries"></web-multiselect>
229
+ ```
230
+
231
+ **Navigate mode** — keep all options visible, jump to matches:
232
+
233
+ ```html
234
+ <web-multiselect search-mode="navigate" id="states"></web-multiselect>
235
+
236
+ <script>
237
+ const select = document.getElementById('states');
238
+ select.options = [/* ...50 US states... */];
239
+
240
+ // User types "cal" → Jumps to "California", shows all states
241
+ // Matching options are highlighted with left border
242
+ </script>
243
+ ```
244
+
245
+ **When to use each mode:**
246
+
247
+ - **Filter mode** — large datasets where narrowing down is essential (product catalogs, user lists, search results).
248
+ - **Navigate mode** — quick selection from familiar lists (countries, states, keyboard shortcuts, known options).
249
+
250
+ **Key differences:**
251
+
252
+ - Filter mode hides non-matches, navigate mode highlights matches with a left border.
253
+ - Navigate mode keeps previous focus if no match is found (type "xyz" → stays on current option).
254
+ - Navigate mode only works with local data (automatically falls back to filter mode when using `searchCallback`).
255
+ - Both modes respect `beforeSearchCallback` for search term preprocessing (accent removal, validation).
256
+ - **Ctrl+↑/↓** jumps between matches only (navigate mode); regular arrows navigate through all items.
257
+
258
+ ## Display modes
259
+
260
+ Perfect for different use cases and space constraints:
261
+
262
+ ```html
263
+ <!-- Badges mode (default) - Show all selections as removable badges -->
264
+ <web-multiselect badges-display-mode="pills"></web-multiselect>
265
+
266
+ <!-- Count mode - Show "X selected" text with clear button -->
267
+ <web-multiselect badges-display-mode="count" show-counter="true"></web-multiselect>
268
+
269
+ <!-- Compact mode - Show first item + count in a single removable badge -->
270
+ <web-multiselect badges-display-mode="compact"></web-multiselect>
271
+ <!-- Example output: [JavaScript (+2 more) | x] -->
272
+
273
+ <!-- None mode - No display in badges area (minimal UI) -->
274
+ <web-multiselect badges-display-mode="none" show-counter="true"></web-multiselect>
275
+ <!-- Only shows [X] badge next to toggle icon -->
276
+
277
+ <!-- Auto-switch from badges to count at threshold -->
278
+ <web-multiselect
279
+ badges-threshold="3"
280
+ badges-threshold-mode="count"
281
+ show-counter="true">
282
+ </web-multiselect>
283
+
284
+ <!-- Partial mode - Show limited badges + "+X more" badge -->
285
+ <web-multiselect
286
+ badges-threshold="5"
287
+ badges-threshold-mode="partial"
288
+ badges-max-visible="3">
289
+ </web-multiselect>
290
+ ```
291
+
292
+ **Display mode behavior:**
293
+
294
+ - **`pills`** — individual removable badges for each selected item. Calls `getBadgeDisplayCallback` for each item.
295
+ - **`count`** — shows "X selected" text with clear button. Calls `getCounterCallback(count)`.
296
+ - **`compact`** — shows first item + count in single badge (e.g., "JavaScript (+2 more)"). Calls `getBadgeDisplayCallback(firstItem)` and `getCounterCallback(count, remainingCount)`.
297
+ - **`partial`** — shows first N badges + "+X more" badge. Calls `getBadgeDisplayCallback` for visible items and `getCounterCallback(count, remainingCount)` for badge.
298
+ - **`none`** — no display in badges area. No callbacks invoked. Use with `show-counter="true"` for minimal UI.
299
+
300
+ **Badge styling:**
301
+
302
+ - **Data badges** (selected items like "JavaScript", "Python") — blue styling by default.
303
+ - **Badge counters** ("+3 more", "5 selected", compact mode display) — gray styling to distinguish from data.
304
+ - Both can be customized via CSS variables (see `--ms-badge-*` and `--ms-badge-counter-*` in [theming.md](./theming.md)).
305
+
306
+ **Counter (`show-counter="true"`)** — independent feature showing `[X]` next to toggle icon. Works with all display modes. Not affected by callbacks.
307
+
308
+ ## Badge positioning
309
+
310
+ Control where selected item badges appear relative to the input:
311
+
312
+ ```html
313
+ <!-- Badges below input (default) -->
314
+ <web-multiselect badges-position="bottom"></web-multiselect>
315
+
316
+ <!-- Badges above input -->
317
+ <web-multiselect badges-position="top"></web-multiselect>
318
+
319
+ <!-- Badges to the left of input -->
320
+ <web-multiselect badges-position="left"></web-multiselect>
321
+
322
+ <!-- Badges to the right of input -->
323
+ <web-multiselect badges-position="right"></web-multiselect>
324
+ ```
325
+
326
+ **Inline vertical alignment:** For left/right positioning, control vertical alignment with `--ms-inline-align`:
327
+
328
+ ```html
329
+ <!-- Center aligned (default) -->
330
+ <web-multiselect badges-position="right" style="--ms-inline-align: center;"></web-multiselect>
331
+
332
+ <!-- Top aligned -->
333
+ <web-multiselect badges-position="right" style="--ms-inline-align: flex-start;"></web-multiselect>
334
+
335
+ <!-- Bottom aligned -->
336
+ <web-multiselect badges-position="left" style="--ms-inline-align: flex-end;"></web-multiselect>
337
+ ```
338
+
339
+ > **Note:** In RTL mode, left/right positions are automatically mirrored — `badges-position="left"` will appear on the physical right side in RTL languages.
340
+
341
+ ## Badge tooltips
342
+
343
+ Enable tooltips on selected item badges with customizable placement and delay:
344
+
345
+ ```html
346
+ <!-- Basic tooltips -->
347
+ <web-multiselect
348
+ enable-badge-tooltips="true"
349
+ badge-tooltip-placement="top">
350
+ </web-multiselect>
351
+
352
+ <!-- Fast tooltips with custom delay -->
353
+ <web-multiselect
354
+ enable-badge-tooltips="true"
355
+ badge-tooltip-delay="100">
356
+ </web-multiselect>
357
+
358
+ <script type="module">
359
+ const select = document.querySelector('web-multiselect');
360
+
361
+ // Custom tooltip content
362
+ select.getBadgeTooltipCallback = (item) => {
363
+ return `${item.label} - ${item.subtitle}`;
364
+ };
365
+ </script>
366
+ ```
367
+
368
+ ## Internationalization (i18n)
369
+
370
+ Customize counter text for proper pluralization and localization:
371
+
372
+ ```html
373
+ <web-multiselect
374
+ id="i18n-select"
375
+ badges-threshold="5"
376
+ badges-threshold-mode="partial"
377
+ badges-max-visible="3">
378
+ </web-multiselect>
379
+
380
+ <script type="module">
381
+ const select = document.getElementById('i18n-select');
382
+
383
+ // Spanish pluralization example
384
+ select.getCounterCallback = (count, moreCount) => {
385
+ if (moreCount !== undefined) {
386
+ // Partial mode: "+X more" badge
387
+ return moreCount === 1 ? '+1 más' : `+${moreCount} más`;
388
+ }
389
+ // Count mode: total count
390
+ return count === 1 ? '1 elemento seleccionado' : `${count} elementos seleccionados`;
391
+ };
392
+ </script>
393
+ ```
394
+
395
+ ## Right-to-left (RTL) language support
396
+
397
+ Full RTL support for Arabic, Hebrew, Persian, Urdu, and other right-to-left languages with automatic detection and complete UI mirroring:
398
+
399
+ ```html
400
+ <!-- Automatic RTL detection from dir attribute -->
401
+ <web-multiselect dir="rtl" search-placeholder="ابحث..."></web-multiselect>
402
+
403
+ <!-- RTL inherited from parent element -->
404
+ <div dir="rtl">
405
+ <web-multiselect search-placeholder="חיפוש..."></web-multiselect>
406
+ </div>
407
+
408
+ <!-- RTL on page level -->
409
+ <html dir="rtl">
410
+ <!-- All multi-selects will auto-detect RTL -->
411
+ </html>
412
+ ```
413
+
414
+ **RTL features:**
415
+
416
+ - **Auto-detection** — detects `dir="rtl"` on component or any ancestor element.
417
+ - **Complete UI mirroring** — toggle icon, text alignment, badges, dropdown.
418
+ - **Logical positioning** — `badges-position="left"` becomes physically right in RTL.
419
+ - **Badge remove buttons** — flip to left side in RTL mode.
420
+ - **Text direction** — all text content properly right-aligned.
421
+ - **No configuration needed** — just set `dir="rtl"` attribute.
422
+
423
+ ## Custom rendering
424
+
425
+ The component provides powerful custom rendering callbacks that allow you to fully customize how options, badges, and selected items are displayed while maintaining the component's structure and functionality.
426
+
427
+ Three rendering callbacks are available:
428
+
429
+ - **`renderOptionContentCallback`** — customize dropdown option content.
430
+ - **`renderBadgeContentCallback`** — customize badge (selected item) content.
431
+ - **`renderSelectedContentCallback`** — customize selected value text (single-select mode).
432
+
433
+ All callbacks can return either **HTML strings** or **HTMLElement** objects (except `renderSelectedContentCallback`, which returns plain text).
434
+
435
+ ### HTML injection (XSS) notice
436
+
437
+ The following callbacks allow **raw HTML injection** and are intentionally **NOT XSS-safe**. This gives developers full control over rendering but requires sanitizing untrusted data:
438
+
439
+ | Callback | Output used in | Risk level |
440
+ |----------|----------------|------------|
441
+ | `renderOptionContentCallback` | Dropdown options (innerHTML) | HTML injection |
442
+ | `renderBadgeContentCallback` | Badges (innerHTML) | HTML injection |
443
+ | `renderSelectedItemContentCallback` | Selected items popover (innerHTML) | HTML injection |
444
+ | `renderGroupLabelContentCallback` | Group headers (innerHTML) | HTML injection |
445
+ | `getIconCallback` | Option icons (innerHTML) | HTML injection |
446
+ | `getSubtitleCallback` | Option subtitles (innerHTML) | HTML injection |
447
+ | `getDisplayValueCallback` | Option titles, badges (innerHTML) | HTML injection |
448
+ | `getBadgeDisplayCallback` | Badge text (innerHTML) | HTML injection |
449
+ | `getCounterCallback` | Count badges (innerHTML) | HTML injection |
450
+ | `getBadgeTooltipCallback` | Tooltips (innerHTML if HTMLElement) | HTML injection |
451
+ | `customStylesCallback` | Style tag (textContent) | CSS injection |
452
+
453
+ **Safe callbacks** (output is escaped or used as data):
454
+
455
+ - `getValueCallback`, `getSearchValueCallback`, `getGroupCallback`, `getDisabledCallback`
456
+ - `getBadgeClassCallback`, `getSelectedItemClassCallback` (CSS class names only)
457
+ - `beforeSearchCallback`, `searchCallback`, `addNewCallback`
458
+ - `selectCallback`, `deselectCallback`, `changeCallback`
459
+ - `getRemoveButtonTooltipCallback` (used as title attribute)
460
+ - `getValueFormatCallback` (form value)
461
+
462
+ **If displaying user-generated content**, sanitize it before returning from these callbacks.
463
+
464
+ ### Custom option rendering
465
+
466
+ Customize how options appear in the dropdown:
467
+
468
+ ```html
469
+ <web-multiselect id="custom-options"></web-multiselect>
470
+
471
+ <script type="module">
472
+ import '@keenmate/web-multiselect';
473
+
474
+ const select = document.getElementById('custom-options');
475
+
476
+ select.options = [
477
+ { id: 1, name: 'React', stars: 220000, trending: true },
478
+ { id: 2, name: 'Vue', stars: 207000, trending: false },
479
+ { id: 3, name: 'Angular', stars: 94000, trending: false },
480
+ { id: 4, name: 'Svelte', stars: 76000, trending: true }
481
+ ];
482
+
483
+ select.renderOptionContentCallback = (item, context) => {
484
+ // Context provides: { index, isSelected, isFocused, isMatched, isDisabled }
485
+
486
+ return `
487
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
488
+ <strong>${item.name}</strong>
489
+ <span style="color: #666; font-size: 0.875rem;">⭐ ${(item.stars / 1000).toFixed(0)}k</span>
490
+ ${item.trending ? '<span style="background: #10b981; color: white; padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.75rem;">🔥 Trending</span>' : ''}
491
+ </div>
492
+ `;
493
+ };
494
+ </script>
495
+ ```
496
+
497
+ **Context object** (`OptionContentRenderContext`):
498
+
499
+ - `index: number` — index of the option in the filtered list.
500
+ - `isSelected: boolean` — whether the option is currently selected.
501
+ - `isFocused: boolean` — whether the option is currently focused (keyboard navigation).
502
+ - `isMatched: boolean` — whether the option matches the current search term (navigate mode only).
503
+ - `isDisabled: boolean` — whether the option is disabled.
504
+
505
+ ### Custom badge rendering
506
+
507
+ Customize how selected items appear as badges:
508
+
509
+ ```javascript
510
+ const select = document.querySelector('web-multiselect');
511
+
512
+ select.options = [
513
+ { id: 1, name: 'John Doe', role: 'Admin', avatar: '👨‍💼' },
514
+ { id: 2, name: 'Jane Smith', role: 'Developer', avatar: '👩‍💻' },
515
+ { id: 3, name: 'Bob Johnson', role: 'Designer', avatar: '🎨' }
516
+ ];
517
+
518
+ // Custom badge rendering in main badges area
519
+ select.renderBadgeContentCallback = (item, context) => {
520
+ return `${item.avatar} ${item.name}`;
521
+ };
522
+
523
+ // Custom rendering for selected items popover (separate callback)
524
+ select.renderSelectedItemContentCallback = (item) => {
525
+ return `
526
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
527
+ <span>${item.avatar}</span>
528
+ <div>
529
+ <div><strong>${item.name}</strong></div>
530
+ <div style="font-size: 0.75rem; color: #666;">${item.role}</div>
531
+ </div>
532
+ </div>
533
+ `;
534
+ };
535
+ ```
536
+
537
+ **Separate callbacks for badges vs. popover:**
538
+
539
+ - `renderBadgeContentCallback` — renders badges in the main badges area (compact display).
540
+ - `renderSelectedItemContentCallback` — renders items in the selected items popover (can be more detailed).
541
+ - If `renderSelectedItemContentCallback` is not defined, falls back to `renderBadgeContentCallback`.
542
+ - Users can assign the same function to both if identical rendering is desired.
543
+
544
+ **Context object** (`BadgeContentRenderContext` for `renderBadgeContentCallback`):
545
+
546
+ - `displayMode: BadgesDisplayMode` — current badges display mode (`'pills'`, `'count'`, `'compact'`, `'partial'`, `'none'`).
547
+ - `isInPopover: boolean` — whether the badge is being rendered in the selected items popover (always false for this callback).
548
+
549
+ ### Custom group label rendering
550
+
551
+ Customize how group headers are displayed using `renderGroupLabelContentCallback`:
552
+
553
+ ```javascript
554
+ const select = document.querySelector('web-multiselect');
555
+
556
+ select.options = [
557
+ { value: 'react', label: 'React', group: 'frontend' },
558
+ { value: 'vue', label: 'Vue', group: 'frontend' },
559
+ { value: 'nodejs', label: 'Node.js', group: 'backend' },
560
+ { value: 'postgres', label: 'PostgreSQL', group: 'database' }
561
+ ];
562
+
563
+ select.isGroupsAllowed = true;
564
+ select.groupMember = 'group';
565
+
566
+ select.renderGroupLabelContentCallback = (groupName) => {
567
+ const emojis = {
568
+ 'frontend': '🎨',
569
+ 'backend': '🔧',
570
+ 'database': '🗄️'
571
+ };
572
+ const emoji = emojis[groupName] || '📦';
573
+ return `<strong>${emoji} ${groupName.toUpperCase()}</strong>`;
574
+ };
575
+ ```
576
+
577
+ **Signature:** `(groupName: string) => string | HTMLElement`
578
+
579
+ **Use cases:**
580
+
581
+ - Capitalize or format group names.
582
+ - Add icons, emojis, or badges to group headers.
583
+ - Apply HTML formatting (bold, colors, etc.).
584
+ - Internationalization (i18n) — translate group names.
585
+ - Add group-specific metadata or counts.
586
+
587
+ **Notes:**
588
+
589
+ - Keeps standard `.ms__group-label` wrapper for consistent styling.
590
+ - Can return HTML string or HTMLElement.
591
+ - Group name is passed as a string parameter.
592
+
593
+ ### Custom badge styling with CSS classes
594
+
595
+ Add custom CSS classes to badges based on item data for semantic styling:
596
+
597
+ ```javascript
598
+ const select = document.querySelector('web-multiselect');
599
+
600
+ select.options = [
601
+ { id: 1, task: 'Fix security bug', priority: 'urgent' },
602
+ { id: 2, task: 'Update docs', priority: 'normal' },
603
+ { id: 3, task: 'Refactor code', priority: 'low' }
604
+ ];
605
+
606
+ // Add CSS class based on priority
607
+ select.getBadgeClassCallback = (item) => {
608
+ return `badge-${item.priority}`; // Returns 'badge-urgent', 'badge-normal', etc.
609
+ };
610
+
611
+ // Can also return array of classes
612
+ select.getBadgeClassCallback = (item) => {
613
+ const classes = [`badge-${item.priority}`];
614
+ if (item.urgent) classes.push('badge-blink');
615
+ return classes;
616
+ };
617
+ ```
618
+
619
+ Then style with CSS:
620
+
621
+ ```css
622
+ .badge-urgent {
623
+ --ms-badge-text-bg: #fee2e2;
624
+ --ms-badge-text-color: #dc2626;
625
+ --ms-badge-remove-bg: #dc2626;
626
+ }
627
+
628
+ .badge-normal {
629
+ --ms-badge-text-bg: #dbeafe;
630
+ --ms-badge-text-color: #2563eb;
631
+ --ms-badge-remove-bg: #2563eb;
632
+ }
633
+
634
+ .badge-low {
635
+ --ms-badge-text-bg: #d1fae5;
636
+ --ms-badge-text-color: #059669;
637
+ --ms-badge-remove-bg: #059669;
638
+ }
639
+ ```
640
+
641
+ The callback:
642
+
643
+ - Takes the item as a parameter.
644
+ - Returns a string (single class) or array of strings (multiple classes).
645
+ - Classes are added to the badge's base `.ms__badge` element.
646
+ - Works across all rendering locations (main badges, partial mode, popover).
647
+
648
+ **Separate class callbacks for badges vs. popover:**
649
+
650
+ Similar to rendering callbacks, you can use different class callbacks for badges and selected items:
651
+
652
+ ```javascript
653
+ // Add classes to badges in main area
654
+ select.getBadgeClassCallback = (item) => {
655
+ return `badge-${item.priority}`;
656
+ };
657
+
658
+ // Add different/additional classes to selected items in popover
659
+ select.getSelectedItemClassCallback = (item) => {
660
+ return [`badge-${item.priority}`, 'badge-detailed'];
661
+ };
662
+ ```
663
+
664
+ - `getBadgeClassCallback` — adds classes to badges in the main badges area.
665
+ - `getSelectedItemClassCallback` — adds classes to items in the selected items popover.
666
+ - If `getSelectedItemClassCallback` is not defined, falls back to `getBadgeClassCallback`.
667
+ - Users can assign the same function to both if identical styling is desired.
668
+
669
+ **Shadow DOM CSS injection:**
670
+
671
+ Since the component uses Shadow DOM, regular page CSS cannot style shadow elements. Use `customStylesCallback` to inject CSS directly into the Shadow DOM:
672
+
673
+ ```javascript
674
+ const select = document.querySelector('web-multiselect');
675
+
676
+ select.getBadgeClassCallback = (item) => {
677
+ return `badge-${item.priority}`;
678
+ };
679
+
680
+ select.customStylesCallback = () => `
681
+ .badge-urgent {
682
+ --ms-badge-text-bg: #fee2e2;
683
+ --ms-badge-text-color: #dc2626;
684
+ --ms-badge-remove-bg: #dc2626;
685
+ }
686
+
687
+ .badge-normal {
688
+ --ms-badge-text-bg: #dbeafe;
689
+ --ms-badge-text-color: #2563eb;
690
+ --ms-badge-remove-bg: #2563eb;
691
+ }
692
+
693
+ .badge-low {
694
+ --ms-badge-text-bg: #d1fae5;
695
+ --ms-badge-text-color: #059669;
696
+ --ms-badge-remove-bg: #059669;
697
+ }
698
+ `;
699
+ ```
700
+
701
+ The `customStylesCallback`:
702
+
703
+ - Returns a CSS string (not HTML).
704
+ - Styles are injected into the Shadow DOM on initialization.
705
+ - Can be updated dynamically — new styles replace old ones.
706
+ - Works with all custom classes (from `getBadgeClassCallback`, `renderOptionContentCallback`, etc.).
707
+
708
+ ### Custom selected item rendering (single-select)
709
+
710
+ Customize the text shown in the input field when in single-select mode:
711
+
712
+ ```javascript
713
+ const select = document.querySelector('web-multiselect[multiple="false"]');
714
+
715
+ select.options = [
716
+ { id: 1, firstName: 'John', lastName: 'Doe', email: 'john@example.com' },
717
+ { id: 2, firstName: 'Jane', lastName: 'Smith', email: 'jane@example.com' }
718
+ ];
719
+
720
+ // Show just first name when closed
721
+ select.renderSelectedContentCallback = (item) => {
722
+ return item.firstName; // Plain text (not HTML)
723
+ };
724
+
725
+ // While dropdown shows full details
726
+ select.getDisplayValueCallback = (item) => {
727
+ return `${item.firstName} ${item.lastName} (${item.email})`;
728
+ };
729
+ ```
730
+
731
+ ### Conditional rendering example
732
+
733
+ Use JavaScript logic for conditional rendering:
734
+
735
+ ```javascript
736
+ select.renderOptionContentCallback = (item, context) => {
737
+ const classes = [];
738
+ if (context.isSelected) classes.push('selected');
739
+ if (context.isFocused) classes.push('focused');
740
+
741
+ return `
742
+ <div class="${classes.join(' ')}">
743
+ ${item.isNew ? '<span class="badge-new">NEW</span>' : ''}
744
+ <strong>${item.name}</strong>
745
+ ${item.description ? `<p style="font-size: 0.875rem; color: #666;">${item.description}</p>` : ''}
746
+ ${item.tags ? `<div class="tags">${item.tags.map(tag => `<span class="tag">${tag}</span>`).join('')}</div>` : ''}
747
+ </div>
748
+ `;
749
+ };
750
+ ```
751
+
752
+ ### Returning HTMLElement
753
+
754
+ You can also return DOM elements for more complex rendering:
755
+
756
+ ```javascript
757
+ select.renderOptionContentCallback = (item, context) => {
758
+ const div = document.createElement('div');
759
+ div.style.display = 'flex';
760
+ div.style.alignItems = 'center';
761
+ div.style.gap = '0.5rem';
762
+
763
+ const img = document.createElement('img');
764
+ img.src = item.avatarUrl;
765
+ img.style.width = '32px';
766
+ img.style.height = '32px';
767
+ img.style.borderRadius = '50%';
768
+
769
+ const span = document.createElement('span');
770
+ span.textContent = item.name;
771
+
772
+ div.appendChild(img);
773
+ div.appendChild(span);
774
+
775
+ return div; // Return HTMLElement instead of string
776
+ };
777
+ ```
778
+
779
+ ### Virtual scroll compatibility
780
+
781
+ When using `renderOptionContentCallback` with virtual scroll enabled:
782
+
783
+ > ⚠️ **Important:** Custom option content **must fit within** the configured `optionHeight` (default: 50px).
784
+
785
+ ```html
786
+ <web-multiselect
787
+ id="large-dataset"
788
+ enable-virtual-scroll="true"
789
+ option-height="60">
790
+ </web-multiselect>
791
+
792
+ <script type="module">
793
+ const select = document.getElementById('large-dataset');
794
+
795
+ select.renderOptionContentCallback = (item) => {
796
+ return `
797
+ <div style="height: 60px; display: flex; align-items: center;">
798
+ <strong>${item.name}</strong>
799
+ </div>
800
+ `;
801
+ };
802
+ </script>
803
+ ```
804
+
805
+ **Virtual scroll requirements:**
806
+
807
+ - Content height must be **fixed** and match `optionHeight`.
808
+ - Overflow will be clipped.
809
+ - Variable-height content only works in non-virtual mode.
810
+
811
+ ### Callback priority
812
+
813
+ The component uses a fallback chain when callbacks are not provided:
814
+
815
+ **For options:**
816
+
817
+ 1. `renderOptionContentCallback` (full HTML control)
818
+ 2. Default: icon + `getDisplayValueCallback` + subtitle
819
+
820
+ **For badges:**
821
+
822
+ 1. `renderBadgeContentCallback` (full HTML control)
823
+ 2. `getBadgeDisplayCallback` (text only)
824
+ 3. `getDisplayValueCallback` (text only)
825
+
826
+ **For selected item (single-select):**
827
+
828
+ 1. `renderSelectedContentCallback` (text only)
829
+ 2. `getDisplayValueCallback` (text only)
830
+
831
+ ### Checkbox control
832
+
833
+ Control checkbox appearance and alignment with CSS variables and attributes:
834
+
835
+ **Checkbox alignment (via attribute):**
836
+
837
+ ```html
838
+ <web-multiselect checkbox-align="top"></web-multiselect> <!-- Default -->
839
+ <web-multiselect checkbox-align="center"></web-multiselect> <!-- Middle aligned -->
840
+ <web-multiselect checkbox-align="bottom"></web-multiselect> <!-- Bottom aligned -->
841
+ ```
842
+
843
+ **Checkbox size/scale (via CSS):**
844
+
845
+ ```html
846
+ <style>
847
+ /* Change checkbox size */
848
+ web-multiselect {
849
+ --ms-checkbox-size: 20px; /* Width and height (default: 16px) */
850
+ }
851
+
852
+ /* Scale checkbox */
853
+ web-multiselect {
854
+ --ms-checkbox-scale: 1.5; /* Scale multiplier (default: 1) */
855
+ }
856
+
857
+ /* Fine-tune checkbox positioning */
858
+ web-multiselect {
859
+ --ms-checkbox-margin-top: 0.5rem; /* Vertical alignment (default: 0.125rem) */
860
+ --ms-checkbox-margin-right: 0; /* Right spacing (default: 0) */
861
+ --ms-checkbox-margin-bottom: 0; /* Bottom spacing (default: 0) */
862
+ --ms-checkbox-margin-left: 0; /* Left spacing (default: 0) */
863
+ }
864
+ </style>
865
+ ```
866
+
867
+ **CSS Grid/Flexbox in custom content:**
868
+
869
+ Custom rendering callbacks support full CSS layout control:
870
+
871
+ ```javascript
872
+ // CSS Grid example
873
+ multiselect.renderOptionContentCallback = (item, context) => {
874
+ return `
875
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;">
876
+ <div><strong>Name:</strong> ${item.name}</div>
877
+ <div><strong>Price:</strong> ${item.price}</div>
878
+ <div><strong>Stock:</strong> ${item.stock}</div>
879
+ <div><strong>Rating:</strong> ${item.rating}</div>
880
+ </div>
881
+ `;
882
+ };
883
+
884
+ // Flexbox example
885
+ multiselect.renderOptionContentCallback = (item, context) => {
886
+ return `
887
+ <div style="display: flex; justify-content: space-between; align-items: center;">
888
+ <div style="display: flex; flex-direction: column;">
889
+ <strong>${item.name}</strong>
890
+ <span style="font-size: 0.875rem; color: #666;">${item.description}</span>
891
+ </div>
892
+ <div style="text-align: right;">
893
+ <div>${item.price}</div>
894
+ <div style="font-size: 0.875rem;">${item.stock} in stock</div>
895
+ </div>
896
+ </div>
897
+ `;
898
+ };
899
+ ```
900
+
901
+ **Available CSS variables:**
902
+
903
+ - `--ms-checkbox-size` — checkbox width/height (default: `16px`).
904
+ - `--ms-checkbox-scale` — scale multiplier (default: `1`).
905
+ - `--ms-checkbox-margin-top` — top margin for vertical alignment (default: `0.125rem`).
906
+ - `--ms-checkbox-margin-right` — right margin (default: `0`).
907
+ - `--ms-checkbox-margin-bottom` — bottom margin (default: `0`).
908
+ - `--ms-checkbox-margin-left` — left margin (default: `0`).
909
+ - `--ms-checkbox-align` — alignment value (default: `flex-start`).
910
+ - `--ms-option-gap` — gap between checkbox and content (default: `0.5rem`).
911
+
912
+ > **Note:** Horizontal and bottom margins default to `0` since spacing is handled by flexbox gap. Override for custom layouts.
913
+
914
+ ## Flexible data handling
915
+
916
+ The component supports **any data structure** through a member/callback pattern, allowing you to work with custom objects, tuple arrays, or existing API responses without transformation.
917
+
918
+ ### Member properties (simple property names)
919
+
920
+ For objects with consistent property names, use member attributes:
921
+
922
+ ```html
923
+ <web-multiselect
924
+ id="products"
925
+ value-member="productId"
926
+ display-value-member="productName"
927
+ icon-member="icon"
928
+ subtitle-member="description"
929
+ group-member="category">
930
+ </web-multiselect>
931
+
932
+ <script type="module">
933
+ const select = document.getElementById('products');
934
+ select.options = [
935
+ {
936
+ productId: 'p1',
937
+ productName: 'Laptop',
938
+ icon: '💻',
939
+ description: 'High-performance laptop',
940
+ category: 'Electronics'
941
+ },
942
+ {
943
+ productId: 'p2',
944
+ productName: 'Mouse',
945
+ icon: '🖱️',
946
+ description: 'Wireless mouse',
947
+ category: 'Electronics'
948
+ }
949
+ ];
950
+ </script>
951
+ ```
952
+
953
+ ### Callback functions (complex logic)
954
+
955
+ For complex data extraction or conditional logic, use callbacks:
956
+
957
+ ```javascript
958
+ const select = document.querySelector('web-multiselect');
959
+
960
+ // Custom value extraction
961
+ select.getValueCallback = (item) => item.id || item.code || item.value;
962
+
963
+ // Combine multiple fields for display
964
+ select.getDisplayValueCallback = (item) => {
965
+ return `${item.firstName} ${item.lastName}`;
966
+ };
967
+
968
+ // Include multiple fields in search
969
+ select.getSearchValueCallback = (item) => {
970
+ return `${item.name} ${item.sku} ${item.tags.join(' ')}`;
971
+ };
972
+
973
+ // Conditional icons
974
+ select.getIconCallback = (item) => {
975
+ return item.inStock ? '✅' : '❌';
976
+ };
977
+
978
+ // Dynamic subtitles
979
+ select.getSubtitleCallback = (item) => {
980
+ return `$${item.price} - ${item.stock} in stock`;
981
+ };
982
+
983
+ // Disable based on conditions
984
+ select.getDisabledCallback = (item) => {
985
+ return item.stock === 0 || item.discontinued;
986
+ };
987
+
988
+ // Customize badge display (show different text in badges vs dropdown)
989
+ select.getBadgeDisplayCallback = (item) => {
990
+ return item.name;
991
+ };
992
+ ```
993
+
994
+ ### Tuple array auto-detection
995
+
996
+ The component automatically detects `[key, value]` tuple arrays:
997
+
998
+ ```javascript
999
+ select.options = [
1000
+ ['js', 'JavaScript'],
1001
+ ['ts', 'TypeScript'],
1002
+ ['py', 'Python']
1003
+ ];
1004
+ // First element becomes value, second becomes display text
1005
+ ```
1006
+
1007
+ ### Priority order
1008
+
1009
+ When multiple extraction methods are defined, the component uses this priority:
1010
+
1011
+ 1. **Callbacks** (highest) — `getValueCallback`, `getDisplayValueCallback`, etc.
1012
+ 2. **Member properties** — `valueMember`, `displayValueMember`, etc.
1013
+ 3. **Default properties** (lowest) — falls back to `value`, `label`, `name`, etc.
1014
+
1015
+ ### TypeScript support
1016
+
1017
+ The component is fully typed with generics:
1018
+
1019
+ ```typescript
1020
+ import type { MultiSelectElement } from '@keenmate/web-multiselect';
1021
+
1022
+ interface Product {
1023
+ id: string;
1024
+ name: string;
1025
+ price: number;
1026
+ category: string;
1027
+ }
1028
+
1029
+ const select = document.querySelector<MultiSelectElement<Product>>('web-multiselect');
1030
+ select.options = [
1031
+ { id: 'p1', name: 'Laptop', price: 999, category: 'Electronics' }
1032
+ ];
1033
+ ```
1034
+
1035
+ ## Form integration
1036
+
1037
+ The component seamlessly integrates with standard HTML forms by automatically creating hidden inputs in the light DOM (outside Shadow DOM) so FormData can access them.
1038
+
1039
+ ### Basic form integration
1040
+
1041
+ ```html
1042
+ <form id="userForm" action="/submit" method="POST">
1043
+ <label>Select Skills:</label>
1044
+ <web-multiselect
1045
+ name="skills"
1046
+ value-format="json"
1047
+ multiple="true">
1048
+ </web-multiselect>
1049
+
1050
+ <button type="submit">Submit</button>
1051
+ </form>
1052
+
1053
+ <script type="module">
1054
+ import '@keenmate/web-multiselect';
1055
+
1056
+ const form = document.getElementById('userForm');
1057
+ const select = form.querySelector('web-multiselect');
1058
+
1059
+ select.options = [
1060
+ { value: 'js', label: 'JavaScript' },
1061
+ { value: 'ts', label: 'TypeScript' },
1062
+ { value: 'py', label: 'Python' }
1063
+ ];
1064
+
1065
+ form.addEventListener('submit', (e) => {
1066
+ e.preventDefault();
1067
+ const formData = new FormData(form);
1068
+ const skills = formData.get('skills');
1069
+ console.log('Selected skills:', skills);
1070
+ // Output: ["js","ts"] (JSON string)
1071
+ });
1072
+ </script>
1073
+ ```
1074
+
1075
+ ### Value formats
1076
+
1077
+ Choose how selected values are serialized in forms:
1078
+
1079
+ **JSON format** (default):
1080
+
1081
+ ```html
1082
+ <web-multiselect name="items" value-format="json"></web-multiselect>
1083
+ <!-- FormData result: items = ["item1","item2","item3"] -->
1084
+ ```
1085
+
1086
+ **CSV format:**
1087
+
1088
+ ```html
1089
+ <web-multiselect name="items" value-format="csv"></web-multiselect>
1090
+ <!-- FormData result: items = "item1,item2,item3" -->
1091
+ ```
1092
+
1093
+ **Array format** (multiple inputs):
1094
+
1095
+ ```html
1096
+ <web-multiselect name="items" value-format="array"></web-multiselect>
1097
+ <!-- FormData result:
1098
+ items[] = "item1"
1099
+ items[] = "item2"
1100
+ items[] = "item3"
1101
+ -->
1102
+ ```
1103
+
1104
+ ### Custom value formatting
1105
+
1106
+ For advanced use cases, provide a custom formatting function:
1107
+
1108
+ ```javascript
1109
+ const select = document.querySelector('web-multiselect');
1110
+
1111
+ select.name = 'product_ids';
1112
+ select.getValueFormatCallback = (values) => {
1113
+ // Custom format: pipe-separated with prefix
1114
+ return values.map(v => `ID:${v}`).join('|');
1115
+ };
1116
+
1117
+ // When submitted, FormData will have:
1118
+ // product_ids = "ID:123|ID:456|ID:789"
1119
+ ```
1120
+
1121
+ ### Using getValue() for JavaScript submissions
1122
+
1123
+ For JavaScript-based form submissions (AJAX, fetch), use `getValue()`:
1124
+
1125
+ ```javascript
1126
+ // Single-select mode
1127
+ const select = document.querySelector('web-multiselect[multiple="false"]');
1128
+ const selectedId = select.getValue();
1129
+ // Returns: "js" or null
1130
+
1131
+ // Multi-select mode
1132
+ const multiSelect = document.querySelector('web-multiselect[multiple="true"]');
1133
+ const selectedIds = multiSelect.getValue();
1134
+ // Returns: ["js", "ts", "py"] or []
1135
+
1136
+ // Submit with fetch
1137
+ const response = await fetch('/api/update', {
1138
+ method: 'POST',
1139
+ headers: { 'Content-Type': 'application/json' },
1140
+ body: JSON.stringify({
1141
+ skills: multiSelect.getValue()
1142
+ })
1143
+ });
1144
+ ```
1145
+
1146
+ ### Working with numeric values
1147
+
1148
+ The component handles both string and numeric values correctly:
1149
+
1150
+ ```javascript
1151
+ select.options = [
1152
+ { value: 1, label: 'Option 1' },
1153
+ { value: 2, label: 'Option 2' },
1154
+ { value: 3, label: 'Option 3' }
1155
+ ];
1156
+
1157
+ // getValue() preserves types
1158
+ const values = select.getValue();
1159
+ // Returns: [1, 2, 3] (numbers, not strings)
1160
+
1161
+ // FormData serialization
1162
+ // JSON format: [1,2,3]
1163
+ // CSV format: 1,2,3
1164
+ // Array format: items[]=1, items[]=2, items[]=3
1165
+ ```
1166
+
1167
+ ## Disabled options
1168
+
1169
+ ```javascript
1170
+ select.options = [
1171
+ { value: 'basic', label: 'Basic License', subtitle: 'Free forever' },
1172
+ { value: 'pro', label: 'Pro License', subtitle: 'Available for purchase' },
1173
+ {
1174
+ value: 'enterprise',
1175
+ label: 'Enterprise License',
1176
+ subtitle: 'Contact sales',
1177
+ disabled: true
1178
+ }
1179
+ ];
1180
+ ```