@keenmate/web-multiselect 1.11.0 → 1.12.0-rc02

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,1929 +1,96 @@
1
- # MultiSelect Web Component
1
+ # @keenmate/web-multiselect
2
2
 
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
4
  [![npm version](https://img.shields.io/npm/v/@keenmate/web-multiselect.svg)](https://www.npmjs.com/package/@keenmate/web-multiselect)
5
5
 
6
- A lightweight, accessible multiselect web component with typeahead search, RTL language support, rich content, and excellent keyboard navigation.
6
+ > A lightweight, themeable multi-select web component with typeahead search, RTL support, rich content, and full keyboard navigation.
7
7
 
8
- > **⚠️ Security Notice:** This component intentionally allows raw HTML in rendering callbacks to give developers full control over content display. If you display user-generated content, you must sanitize it yourself. See [HTML Injection (XSS) Notice](#html-injection-xss-notice) for the complete list of affected callbacks.
8
+ ## What is it
9
9
 
10
- ## What's New in v1.11.0
10
+ `@keenmate/web-multiselect` is a custom element (`<web-multiselect>`) that turns a list of options into a searchable, themeable multi-select dropdown. Framework-agnostic — works in React, Vue, Svelte, Blazor, plain HTML.
11
11
 
12
- - **OS-aware light/dark defaults via `light-dark()`** set `color-scheme: dark` on your page (`:root`, `body`, etc.) and the multiselect picks readable dark text/background colors automatically. No more enumerating ~15 `--base-*` overrides just to get usable defaults on a dark theme.
13
- - **Drift-detection warning for positioning edge cases** — if an exotic ancestor CSS property (e.g. `contain: paint`, or `container-type` in certain shadow-DOM layouts) makes the dropdown land somewhere other than where the library told the browser to put it, a `console.warn` fires once with the likely culprit element and an actionable fix suggestion.
14
- - **Dropdown no longer stranded to the side when an ancestor uses `container-type`** — Floating UI was walking up to a `container-type: inline-size` ancestor (notably pure-admin's `.pa-layout__main`), but the browser wouldn't actually anchor the fixed panel there. The library now uses a custom `getOffsetParent` that only walks up properties browsers reliably honor for fixed positioning.
15
- - **Dropdown no longer opens shifted to the side of its input** — when the dropdown's natural content was wider than the input, Floating UI's `shift()` middleware was measuring the unclamped panel and pushing it left, then the subsequent width clamp left it stranded next to the input. Panel sizing now happens before positioning. Same fix applies to the selected-items popover.
16
- - **`--base-*` taxonomy aligned with KeenMate cross-component naming** (theming change — see CHANGELOG migration table): `--base-primary-bg` → `--base-hover-bg`, `--base-primary-bg-hover` → `--base-active-bg`. `--base-dropdown-bg` and `--base-tooltip-bg` continue to work; new chain fallbacks to `--base-elevated-bg` / `--base-inverse-bg`.
17
- - **Option hover stays visible on dark themes** — `--ms-primary-bg` now mixes 8% of the text color into the main background by default, so the hover is always a visible step toward the text. No more invisible hover when the consumer forgets to override `--base-hover-bg`.
18
- - **New `examples-positioning.html`** — walks through baseline / transformed-ancestor / container-type / drift-detection scenarios.
19
- - **New dark-mode e2e suite** — 4 specs verifying WCAG-AA option contrast on a dark page across fully-themed, minimal-override, and pure OS-inheritance configurations.
12
+ Reads `--base-*` variables from the page if [`@keenmate/theme-designer`](https://theme-designer.keenmate.dev) is present, falls back to sensible OS-aware defaults otherwise, and ships with first-class dark-mode and per-instance theming.
20
13
 
21
- ## What's New in v1.10.0
14
+ **Headline features:**
22
15
 
23
- - **`data-options` attribute on `<web-multiselect>`**set options declaratively from HTML, no JS bootstrap required (works alongside `initial-values` for pure-HTML / server-rendered / SharePoint workbench scenarios).
24
- - **`form.reset()` now clears the selection** the element is now form-associated (`static formAssociated = true` + `ElementInternals` + `formResetCallback()`).
25
- - **Dropdown / hint / selected popover no longer clipped inside scrollable ancestors** — Floating UI now uses `strategy: 'fixed'` for all three panels, so they escape `overflow: hidden|auto|scroll` containers (e.g. SharePoint Framework workbenches).
26
- - **`initial-values` now works when options arrive after init** values are reconciled on every `options` mutation, not just at construction.
27
- - **Remove / close (×) buttons render as SVG masks** — pixel-centered regardless of font; color still flows through the existing `--ms-*-color` variables via `currentColor`; three new `--ms-*-icon-size` variables for theming.
28
- - **Keyboard navigation now keeps working after a mouse click on an option** — previously, clicking an option moved focus from the search input to the option's checkbox (knocking the `keydown` listener offline) *and* left `focusedIndex` at its pre-click value, so subsequent ArrowDown / ArrowUp / Enter went nowhere visible. Click now anchors `focusedIndex` to the clicked option and refocuses the search input, so arrow keys continue from where you clicked and Enter toggles the option under the cursor.
29
- - **Count-clear / popover-close hover backdrop now matches the rest of the component** — was a circle (`border-radius: 50%`), now a small rounded rectangle (`--ms-border-radius-sm`) consistent with every other interactive element. Themes that prefer the circle can set `--ms-count-clear-border-radius` and `--ms-selected-popover-close-border-radius` back to `50%`.
30
- - **Keyboard `Enter` respects disabled options** — previously only the click handler did.
31
- - **End-to-end test suite** — 114 Playwright specs across 19 fixture pages (`npm run test:e2e`).
32
- - **`THEMING.md`** — new reference cataloguing every theme-able component state and the CSS variables that drive it.
16
+ - Declarative `<option>` / `<optgroup>` markup — no JavaScript required for simple cases.
17
+ - Virtual scrolling for 10,000+ option datasets (25× faster opening, 99.8% memory reduction).
18
+ - Filter or navigate search modes; async / hybrid search.
19
+ - Five badge display modes (pills, count, compact, partial, none) with positioning on any side.
20
+ - Full keyboard navigation, RTL language support, badge tooltips.
21
+ - Custom rendering callbacks for options, badges, and group headers.
22
+ - Form integration via standard hidden inputs (FormData-compatible).
33
23
 
34
- ## Features
24
+ ## What's new
35
25
 
36
- - 📝 **Declarative HTML** - Use standard `<option>` and `<optgroup>` elements - no JavaScript required for simple cases!
37
- - ⚡ **Virtual Scrolling** - Handle 15,000+ options instantly (25× faster opening, 99.8% memory reduction)
38
- - 🔍 **Flexible Search Modes** - Filter (hide non-matches) or navigate (jump to matches, keep all visible)
39
- - ⌨️ **Keyboard Navigation** - Full keyboard support (arrows, Enter, Esc, Tab)
40
- - 🎨 **Rich Content** - Icons, subtitles, and multiline text support
41
- - 📊 **Multiple Display Modes** - Badges, count, compact, partial, or none (minimal UI)
42
- - 💬 **Badge Tooltips** - Customizable tooltips on selected items with placement control
43
- - 🎯 **Single & Multi-Select** - Switch between single and multiple selection modes
44
- - 🔄 **Async Data Loading** - On-demand data fetching support
45
- - 📦 **Grouped Options** - Organize options into collapsible groups
46
- - 🎉 **Smart Positioning** - Uses Floating UI for intelligent dropdown placement
47
- - 🌍 **i18n Support** - Customizable callbacks for pluralization and localization
48
- - 🌐 **RTL Support** - Full right-to-left language support (Arabic, Hebrew, Persian, Urdu, etc.)
49
- - ✨ **Modern** - Web Component with Shadow DOM, TypeScript, bundled with Vite
50
- - 🌐 **Framework Agnostic** - Works with any framework or vanilla JS
26
+ **v1.12.0-rc02** Badge tooltips now theme correctly when portaled (real dark-mode contrast fix); FOUC-prevention rule renamed to match the actual tag; README split into slim landing page + `docs/` (usage, theming, examples, accessibility). See [CHANGELOG.md](./CHANGELOG.md) for details.
51
27
 
52
- ## Installation
28
+ **v1.12.0-rc01** — Dark mode responds to framework theme classes (`data-bs-theme`, `.dark`, `data-theme`) via Strategy B; CSS cascade layers; canonical Tier 1+2+3 file structure; BEM-aligned class names.
53
29
 
54
- ```bash
55
- npm install @keenmate/web-multiselect
56
- ```
57
-
58
- ## Usage
59
-
60
- ### Declarative (No JavaScript!)
61
-
62
- Perfect for simple forms - just use standard HTML `<option>` elements:
63
-
64
- ```html
65
- <!-- Simple choice -->
66
- <web-multiselect multiple="false">
67
- <option value="yes">Yes</option>
68
- <option value="no">No</option>
69
- <option value="maybe" selected>Maybe</option>
70
- </web-multiselect>
71
-
72
- <!-- With icons -->
73
- <web-multiselect>
74
- <option value="apple" data-icon="🍎">Apple</option>
75
- <option value="banana" data-icon="🍌" selected>Banana</option>
76
- <option value="orange" data-icon="🍊">Orange</option>
77
- </web-multiselect>
78
-
79
- <!-- With groups -->
80
- <web-multiselect>
81
- <optgroup label="Frontend">
82
- <option value="js" data-icon="🟨">JavaScript</option>
83
- <option value="ts" data-icon="🔷">TypeScript</option>
84
- </optgroup>
85
- <optgroup label="Backend">
86
- <option value="python" data-icon="🐍" selected>Python</option>
87
- <option value="java" data-icon="☕">Java</option>
88
- </optgroup>
89
- </web-multiselect>
90
- ```
91
-
92
- ### Programmatic (With JavaScript)
93
-
94
- For dynamic data and advanced features:
95
-
96
- ```html
97
- <!-- Multi-select -->
98
- <web-multiselect
99
- id="my-select"
100
- search-placeholder="Search options..."
101
- initial-values='["js","ts"]'>
102
- </web-multiselect>
103
- ```
104
-
105
- ```typescript
106
- // Import the component (includes styles)
107
- import '@keenmate/web-multiselect';
108
-
109
- // Or import styles separately if needed
110
- import '@keenmate/web-multiselect/style.css';
111
-
112
- const multiselect = document.querySelector('web-multiselect');
113
-
114
- // Set options programmatically
115
- multiselect.options = [
116
- { value: 'js', label: 'JavaScript', icon: '🟨' },
117
- { value: 'ts', label: 'TypeScript', icon: '🔷' },
118
- { value: 'py', label: 'Python', icon: '🐍' }
119
- ];
120
-
121
- // Listen for events
122
- multiselect.addEventListener('change', (e) => {
123
- console.log('Selected:', e.detail.selectedOptions);
124
- console.log('Values:', e.detail.selectedValues);
125
- });
126
-
127
- // Public API
128
- const selected = multiselect.getSelected();
129
- multiselect.setSelected(['js', 'ts']);
130
- ```
131
-
132
- ## Attributes
133
-
134
- | Attribute | Type | Default | Description |
135
- |-----------|------|---------|-------------|
136
- | `multiple` | `boolean` | `true` | Allow multiple selections |
137
- | `search-placeholder` | `string` | `'Search...'` | Placeholder text for search input |
138
- | `search-hint` | `string` | - | Hint text shown above input when focused |
139
- | `allow-groups` | `boolean` | `true` | Enable option grouping |
140
- | `show-checkboxes` | `boolean` | `true` | Show checkboxes next to options |
141
- | `close-on-select` | `boolean` | `false` | Close dropdown after selecting |
142
- | `dropdown-min-width` | `string` | - | Min width for dropdown (e.g., '20rem') |
143
- | `badges-display-mode` | `'pills' \| 'count' \| 'compact' \| 'partial' \| 'none'` | `'pills'` | How to display selected items. `compact`: first item + count. `none`: no display |
144
- | `badges-threshold` | `number` | - | Auto-switch mode when exceeded (see badges-threshold-mode) |
145
- | `badges-threshold-mode` | `'count' \| 'partial'` | `'count'` | Mode after threshold: 'count' shows badge, 'partial' shows limited badges + more badge |
146
- | `badges-max-visible` | `number` | `3` | Max badges shown in partial mode |
147
- | `badges-position` | `'top' \| 'bottom' \| 'left' \| 'right'` | `'bottom'` | Position of badges container |
148
- | `show-counter` | `boolean` | `false` | Show [3] badge next to toggle icon |
149
- | `enable-badge-tooltips` | `boolean` | `false` | Enable tooltips on selected badges |
150
- | `badge-tooltip-placement` | `'top' \| 'bottom' \| 'left' \| 'right'` | `'top'` | Tooltip placement relative to badge |
151
- | `badge-tooltip-delay` | `number` | `100` | Delay in ms before showing tooltip |
152
- | `badge-tooltip-offset` | `number` | `8` | Distance in pixels between badge and tooltip |
153
- | `max-height` | `string` | `'20rem'` | Maximum height of dropdown |
154
- | `empty-message` | `string` | `'No results found'` | Message when no options found |
155
- | `loading-message` | `string` | `'Loading...'` | Message while loading async data |
156
- | `min-search-length` | `number` | `0` | Minimum search length for async |
157
- | `keep-options-on-search` | `boolean` | `true` | Keep initial options visible when searchCallback is active (hybrid search) |
158
- | `should-keep-search-on-close` | `boolean` | `true` | Preserve search text and filtered results when dropdown closes |
159
- | `sticky-actions` | `boolean` | `true` | Keep action buttons fixed at top while scrolling |
160
- | `actions-layout` | `'nowrap' \| 'wrap'` | `'nowrap'` | Layout mode for action buttons: 'nowrap' (single row) or 'wrap' (multi-row) |
161
- | `lock-placement` | `boolean` | `true` | Lock dropdown placement after first open to prevent flipping |
162
- | `enable-search` | `boolean` | `true` | Enable/disable search functionality |
163
- | `search-input-mode` | `'normal' \| 'readonly' \| 'hidden'` | `'normal'` | Search input display mode |
164
- | `search-mode` | `'filter' \| 'navigate'` | `'filter'` | Search behavior: 'filter' hides non-matches, 'navigate' jumps to matches |
165
- | `allow-add-new` | `boolean` | `false` | Allow adding new options not in the list |
166
- | `value-member` | `string` | - | Property name for value/ID extraction from custom objects |
167
- | `display-value-member` | `string` | - | Property name for display text extraction from custom objects |
168
- | `search-value-member` | `string` | - | Property name for search text extraction from custom objects |
169
- | `icon-member` | `string` | - | Property name for icon extraction from custom objects |
170
- | `subtitle-member` | `string` | - | Property name for subtitle extraction from custom objects |
171
- | `group-member` | `string` | - | Property name for group extraction from custom objects |
172
- | `disabled-member` | `string` | - | Property name for disabled state extraction from custom objects |
173
- | `name` | `string` | - | HTML form field name for form integration (creates hidden input) |
174
- | `value-format` | `'json' \| 'csv' \| 'array'` | `'json'` | Format for form value serialization |
175
- | `initial-values` | `string` (JSON array) | - | Pre-selected values |
176
- | `enable-virtual-scroll` | `boolean` | `false` | Enable virtual scrolling for large datasets |
177
- | `virtual-scroll-threshold` | `number` | `100` | Minimum items before virtual scroll activates |
178
- | `option-height` | `number` | `50` | Fixed height for each option in pixels (required for virtual scroll) |
179
- | `virtual-scroll-buffer` | `number` | `10` | Buffer size - extra items rendered above/below viewport |
180
-
181
- ## Properties
182
-
183
- ```typescript
184
- // Get/set options
185
- multiselect.options = [
186
- { value: 'js', label: 'JavaScript' },
187
- { value: 'ts', label: 'TypeScript' }
188
- ];
189
-
190
- // Async data loading
191
- multiselect.onSearch = async (searchTerm) => {
192
- const response = await fetch(`/api/search?q=${searchTerm}`);
193
- return await response.json();
194
- };
195
-
196
- // Pre-process search terms before calling searchCallback
197
- multiselect.beforeSearchCallback = (searchTerm) => {
198
- // Remove accents: "café" → "cafe"
199
- const normalized = searchTerm.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
200
-
201
- // Block search if too short (return null to prevent search)
202
- if (normalized.length < 2) return null;
203
-
204
- return normalized; // Return transformed term
205
- };
206
-
207
- // Event callbacks
208
- multiselect.onSelect = (option) => {
209
- console.log('Selected:', option);
210
- };
211
-
212
- multiselect.onDeselect = (option) => {
213
- console.log('Deselected:', option);
214
- };
215
-
216
- multiselect.onChange = (selectedOptions) => {
217
- console.log('Changed:', selectedOptions);
218
- };
219
-
220
- // Badge display customization (show different text in badges vs dropdown)
221
- multiselect.getBadgeDisplayCallback = (item) => {
222
- // Show shorter text in badges (e.g., just name instead of "name (email)")
223
- return item.name; // Dropdown might show "John Doe (john@example.com)"
224
- };
225
-
226
- // Badge tooltip customization
227
- multiselect.getBadgeTooltipCallback = (item) => {
228
- return `${item.label} - ${item.subtitle}`;
229
- };
230
-
231
- // Action buttons (Select All, Clear All, custom actions)
232
- multiselect.actionButtons = [
233
- {
234
- action: 'select-all',
235
- text: 'Select All',
236
- tooltip: 'Select all items',
237
- cssClass: 'my-custom-class',
238
- isVisibleCallback: (multiselect) => multiselect.getSelected().length < 5 // Hide if 5+ selected
239
- },
240
- {
241
- action: 'clear-all',
242
- text: 'Clear All',
243
- tooltip: 'Clear selection',
244
- isVisible: true, // Static visibility
245
- isDisabled: false // Static disabled state
246
- },
247
- {
248
- action: 'custom',
249
- text: 'Invert',
250
- tooltip: 'Invert selection',
251
- onClick: (multiselect) => {
252
- // Custom action - invert selection
253
- const allValues = multiselect.options.map(opt => opt.value);
254
- const selectedValues = multiselect.getValue();
255
- const inverted = allValues.filter(v => !selectedValues.includes(v));
256
- multiselect.setSelected(inverted);
257
- },
258
- // Dynamic callbacks (take priority over static properties)
259
- isDisabledCallback: (multiselect) => multiselect.getSelected().length === 0,
260
- getTextCallback: (multiselect) => multiselect.getSelected().length > 0 ? 'Invert' : 'Select Items First',
261
- getClassCallback: (multiselect) => multiselect.getSelected().length > 0 ? 'active' : 'inactive'
262
- }
263
- ];
264
-
265
- // Counter i18n/pluralization
266
- multiselect.getCounterCallback = (count, moreCount) => {
267
- if (moreCount !== undefined) {
268
- return `+${moreCount} more`; // Partial mode badge
269
- }
270
- return `${count} selected`; // Count mode display
271
- };
272
-
273
- // Data extraction - Member properties (for simple property names)
274
- multiselect.valueMember = 'id';
275
- multiselect.displayValueMember = 'name';
276
- multiselect.iconMember = 'icon';
277
- multiselect.subtitleMember = 'description';
278
- multiselect.groupMember = 'category';
279
- multiselect.disabledMember = 'isDisabled';
280
-
281
- // Data extraction - Callback functions (for complex logic)
282
- multiselect.getValueCallback = (item) => item.id || item.value;
283
- multiselect.getDisplayValueCallback = (item) => item.label || item.name;
284
- multiselect.getSearchValueCallback = (item) => `${item.name} ${item.tags.join(' ')}`;
285
- multiselect.getIconCallback = (item) => item.icon || '📄';
286
- multiselect.getSubtitleCallback = (item) => `${item.price} - ${item.stock} in stock`;
287
- multiselect.getGroupCallback = (item) => item.category;
288
- multiselect.getDisabledCallback = (item) => item.stock === 0;
289
-
290
- // Custom rendering - Full HTML control
291
- multiselect.renderGroupLabelContentCallback = (groupName) => {
292
- // Customize group header display (HTML string or HTMLElement)
293
- return `<strong>📦 ${groupName.toUpperCase()}</strong>`;
294
- };
295
-
296
- multiselect.renderOptionContentCallback = (item, context) => {
297
- // Customize option content (HTML string or HTMLElement)
298
- return `<strong>${item.name}</strong> <span class="badge">${item.status}</span>`;
299
- };
300
-
301
- multiselect.renderBadgeContentCallback = (item, context) => {
302
- // Customize badge content (HTML string or HTMLElement)
303
- return context.isInPopover
304
- ? `${item.icon} ${item.name} - ${item.description}`
305
- : `${item.icon} ${item.name}`;
306
- };
307
-
308
- multiselect.renderSelectedContentCallback = (item) => {
309
- // Customize selected item text in single-select mode (plain text only)
310
- return item.firstName; // Show just first name when closed
311
- };
312
-
313
- // Form integration
314
- multiselect.name = 'selected_items';
315
- multiselect.valueFormat = 'json'; // 'json' | 'csv' | 'array'
316
- multiselect.getValueFormatCallback = (values) => values.join('|'); // Custom format
317
-
318
- // Read-only properties
319
- const selectedValue = multiselect.selectedValue; // string | number | array | null
320
- const selectedItem = multiselect.selectedItem; // First selected item object
321
-
322
- // Add new option callback
323
- multiselect.addNewCallback = async (value) => {
324
- // Validate and create new option
325
- const newOption = await fetch('/api/options', {
326
- method: 'POST',
327
- body: JSON.stringify({ name: value })
328
- }).then(r => r.json());
329
- return newOption;
330
- };
331
- ```
332
-
333
- ## Methods
334
-
335
- | Method | Description |
336
- |--------|-------------|
337
- | `getSelected()` | Get currently selected options as array of option objects |
338
- | `setSelected(values: (string \| number)[])` | Set selected values by ID/value |
339
- | `getValue()` | Get selected value(s) - returns single value in single-select mode, array in multi-select mode |
340
- | `destroy()` | Clean up and destroy instance |
341
-
342
- ## Events
343
-
344
- | Event | Detail | Description |
345
- |-------|--------|-------------|
346
- | `select` | `{ option, selectedOptions }` | Fired when an option is selected |
347
- | `deselect` | `{ option, selectedOptions }` | Fired when an option is deselected |
348
- | `change` | `{ selectedOptions, selectedValues }` | Fired when selection changes |
349
-
350
- ## Keyboard Shortcuts
351
-
352
- - **↑ ↓** - Navigate up/down through options
353
- - **Ctrl+↑ Ctrl+↓** - Jump between matched items (navigate mode only)
354
- - **Page Up / Page Down** - Move focus by 10 options at a time
355
- - **Home / End** - Jump to first / last option
356
- - **Enter** - Select focused option (or add new entry when `allow-add-new="true"` and the search has text)
357
- - **Escape** - Close popover → Clear search → Close dropdown (priority order)
358
- - **Tab** - Close dropdown and move to next field
359
- - **Type** - Filter options by search term
360
-
361
- > 💡 To surface these shortcuts to your users, set the `search-hint` attribute — the hint floats above the input when focused. Example: `<web-multiselect search-mode="navigate" search-hint="Ctrl/Cmd + ↓ / ↑ to jump between matches">`.
362
-
363
- ## Advanced Features
364
-
365
- ### Rich Content with Icons
30
+ > ⚠️ **Security notice:** This component intentionally allows raw HTML in rendering callbacks to give developers full control over content display. If you display user-generated content, you must sanitize it yourself. See [docs/examples.md → HTML Injection (XSS) notice](./docs/examples.md#html-injection-xss-notice) for the complete list of affected callbacks.
366
31
 
367
- Icons support multiple formats - emojis, SVG markup, Font Awesome, images, or any HTML:
32
+ ## Demos & docs
368
33
 
369
- ```html
370
- <web-multiselect id="frameworks"></web-multiselect>
371
-
372
- <script type="module">
373
- const select = document.getElementById('frameworks');
374
- select.options = [
375
- {
376
- value: 'react',
377
- label: 'React',
378
- icon: '⚛️', // Emoji
379
- subtitle: 'A JavaScript library for building user interfaces'
380
- },
381
- {
382
- value: 'vue',
383
- label: 'Vue.js',
384
- icon: '<svg viewBox="0 0 24 24"><path d="M2 3l10 18L22 3h-4l-6 10.5L6 3H2z"/></svg>', // SVG
385
- subtitle: 'The Progressive JavaScript Framework'
386
- },
387
- {
388
- value: 'angular',
389
- label: 'Angular',
390
- icon: '<i class="fab fa-angular"></i>', // Font Awesome
391
- subtitle: 'Platform for building mobile and desktop apps'
392
- },
393
- {
394
- value: 'svelte',
395
- label: 'Svelte',
396
- icon: '<img src="svelte-logo.png" alt="Svelte" />', // Image
397
- subtitle: 'Cybernetically enhanced web apps'
398
- }
399
- ];
400
- </script>
401
- ```
402
-
403
- ### Grouped Options
404
-
405
- ```javascript
406
- select.options = [
407
- { value: 'js', label: 'JavaScript', group: 'Frontend' },
408
- { value: 'ts', label: 'TypeScript', group: 'Frontend' },
409
- { value: 'python', label: 'Python', group: 'Backend' },
410
- { value: 'java', label: 'Java', group: 'Backend' }
411
- ];
412
- ```
413
-
414
- ### Async Data Loading
415
-
416
- ```html
417
- <web-multiselect
418
- id="async-select"
419
- min-search-length="2"
420
- loading-message="Searching..."
421
- empty-message="No products found">
422
- </web-multiselect>
34
+ - 🚀 [Live demo](https://web-multiselect.keenmate.dev)
35
+ - 📘 [Usage / API reference](./docs/usage.md) — attributes, properties, methods, events.
36
+ - 🎨 [Theming](./docs/theming.md) — `--ms-*` variables, dark mode, cascade layers, Theme Designer integration.
37
+ - 📚 [Examples / cookbook](./docs/examples.md) — rich content, async search, virtual scroll, custom rendering, forms.
38
+ - [Accessibility](./docs/accessibility.md) — keyboard model, ARIA labels, focus behavior.
423
39
 
424
- <script type="module">
425
- const select = document.getElementById('async-select');
40
+ ## Install
426
41
 
427
- select.onSearch = async (searchTerm) => {
428
- const response = await fetch(`/api/products?q=${searchTerm}`);
429
- const data = await response.json();
430
- return data.products;
431
- };
432
- </script>
433
- ```
434
-
435
- ### Hybrid Static + Dynamic Search
436
-
437
- Show popular items initially, then switch to full database search when the user types. Perfect for showing "Top 10" items while supporting comprehensive search:
438
-
439
- ```html
440
- <web-multiselect
441
- id="hybrid-select"
442
- min-search-length="3"
443
- keep-options-on-search="true">
444
- </web-multiselect>
445
-
446
- <script type="module">
447
- const select = document.getElementById('hybrid-select');
448
-
449
- // Set initial popular items (shown when dropdown opens)
450
- select.options = [
451
- { id: 1, name: 'React' },
452
- { id: 2, name: 'Vue' },
453
- { id: 3, name: 'Angular' },
454
- { id: 4, name: 'Svelte' },
455
- { id: 5, name: 'Solid' }
456
- ];
457
-
458
- // Pre-process search terms (remove accents, validate, etc.)
459
- select.beforeSearchCallback = (searchTerm) => {
460
- // Remove accents: "café" → "cafe"
461
- const normalized = searchTerm.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
462
-
463
- // Block search if too short (return null to prevent search)
464
- if (normalized.length < 2) return null;
465
-
466
- return normalized;
467
- };
468
-
469
- // Search full database when user types 3+ characters
470
- select.onSearch = async (searchTerm) => {
471
- const response = await fetch(`/api/frameworks/search?q=${searchTerm}`);
472
- return await response.json();
473
- };
474
- </script>
42
+ ```bash
43
+ npm install @keenmate/web-multiselect
475
44
  ```
476
45
 
477
- **How it works:**
478
- 1. **Dropdown opens** → Shows 5 popular frameworks
479
- 2. **User types "rea"** → Calls API, shows all matching results from database
480
- 3. **User clears search** → Shows 5 popular frameworks again
481
- 4. **User types "café"** → `beforeSearchCallback` converts to "cafe", then searches
482
-
483
- **Key options:**
484
- - `keep-options-on-search="true"` (default) - Keep initial options visible when search is empty/short
485
- - `beforeSearchCallback` - Transform search text or block search by returning `null`
486
- - `min-search-length` - Minimum characters before triggering search (shows initial options below this)
46
+ ## Quick start
487
47
 
488
- ### Virtual Scrolling for Large Datasets
489
-
490
- Handle 10,000+ options with smooth 60fps performance by rendering only visible items:
48
+ **Declarative no JavaScript required:**
491
49
 
492
50
  ```html
493
- <web-multiselect
494
- id="large-dataset"
495
- enable-virtual-scroll="true"
496
- virtual-scroll-threshold="100"
497
- option-height="50"
498
- virtual-scroll-buffer="10"
499
- search-mode="filter"
500
- max-height="400px">
501
- </web-multiselect>
502
-
503
51
  <script type="module">
504
52
  import '@keenmate/web-multiselect';
505
-
506
- const select = document.getElementById('large-dataset');
507
-
508
- // Generate 15,000 options
509
- const largeDataset = Array.from({ length: 15000 }, (_, i) => ({
510
- value: i,
511
- label: `Item ${i.toString().padStart(5, '0')}`
512
- }));
513
-
514
- select.options = largeDataset;
515
53
  </script>
516
- ```
517
-
518
- **Performance Comparison (15,000 items):**
519
-
520
- | Metric | Without Virtual Scroll | With Virtual Scroll | Improvement |
521
- |--------|------------------------|---------------------|-------------|
522
- | Initial render | 750ms | 30ms | **25× faster** |
523
- | Search keystroke | 200-500ms | 15ms | **13-33× faster** |
524
- | DOM nodes | 15,000 | ~30 | **99.8% reduction** |
525
- | Memory usage | ~7.5 MB | ~15 KB | **500× less** |
526
-
527
- **Configuration:**
528
-
529
- - `enable-virtual-scroll="true"` - Enable virtual scrolling (default: `false`)
530
- - `virtual-scroll-threshold="100"` - Auto-activate when this many items are present (default: `100`)
531
- - `option-height="50"` - Fixed height per option in pixels (default: `50px`)
532
- - `virtual-scroll-buffer="10"` - Extra items rendered above/below viewport for smooth scrolling (default: `10`)
533
-
534
- **How it works:**
535
- - Only renders ~30 visible items instead of all 15,000 DOM elements
536
- - Uses absolute positioning with calculated offsets
537
- - Maintains 10-item buffer zones above/below viewport for smooth scrolling
538
- - Automatically calculates visible range based on scroll position
539
- - Works seamlessly with search filtering and selection
540
-
541
- **Requirements:**
542
- - All options must have the same fixed height (enforced via CSS)
543
- - Not compatible with grouped options (automatically falls back to normal rendering)
544
- - Works with both filter and navigate search modes
545
54
 
546
- **Example with search:**
547
- ```html
548
- <!-- Virtual scroll + filter search for optimal large dataset performance -->
549
- <web-multiselect
550
- id="products"
551
- enable-virtual-scroll="true"
552
- search-mode="filter"
553
- value-member="id"
554
- display-value-member="name"
555
- max-height="400px">
55
+ <web-multiselect placeholder="Pick a country">
56
+ <option value="cz">Czech Republic</option>
57
+ <option value="sk">Slovakia</option>
58
+ <option value="at">Austria</option>
556
59
  </web-multiselect>
557
-
558
- <script type="module">
559
- const select = document.getElementById('products');
560
-
561
- // Load from API
562
- const response = await fetch('/api/products');
563
- const products = await response.json();
564
-
565
- select.options = products; // Could be 10,000+ items
566
- </script>
567
60
  ```
568
61
 
569
- **Live Demo:**
570
- See [examples-performance.html](examples-performance.html) for a working demo with 15,000 randomly generated options.
571
-
572
- ### Virtual Scrolling
573
-
574
- Handle massive datasets (10,000+ items) with instant performance using virtual scrolling. Only visible items (~30) are rendered in the DOM, dramatically reducing memory usage and improving responsiveness.
62
+ **Programmatic — dynamic data + events:**
575
63
 
576
- **Enable virtual scrolling:**
577
64
  ```html
578
- <web-multiselect
579
- enable-virtual-scroll="true"
580
- virtual-scroll-threshold="100"
581
- option-height="50"
582
- virtual-scroll-buffer="10">
583
- </web-multiselect>
584
- ```
585
-
586
- **Performance improvements with 15,000 items:**
587
- - **Dropdown opening**: 750ms → 30ms (25× faster)
588
- - **Search performance**: 200-500ms → 15ms per keystroke (13-33× faster)
589
- - **Memory usage**: 7.5 MB → 15 KB (99.8% reduction)
590
- - **DOM nodes**: 15,000 → ~30 visible items
591
-
592
- **Configuration:**
593
- - `enable-virtual-scroll="true"` - Opt-in to virtual scrolling
594
- - `virtual-scroll-threshold="100"` - Auto-activates at 100+ items (default)
595
- - `option-height="50"` - Fixed height per option in pixels (default: 50px)
596
- - `virtual-scroll-buffer="10"` - Extra items rendered above/below viewport (default: 10)
597
-
598
- **Features:**
599
- - Full keyboard navigation (arrows, Page Up/Down, Home/End)
600
- - Smooth mouse wheel scrolling
601
- - Drag scrollbar support
602
- - Works with search in both filter and navigate modes
603
- - Automatic activation based on threshold
604
-
605
- **Limitations:**
606
- - Groups (`<optgroup>`) are disabled in virtual scroll mode (automatically falls back to standard rendering)
607
- - All options must have consistent height (enforced via CSS)
608
-
609
- **Live Demo:**
610
- See [examples-performance.html](examples-performance.html) for a working demo testing virtual scroll with 15,000 randomly generated options.
611
-
612
- ### Search Modes: Filter vs Navigate
613
-
614
- Choose between two search behaviors:
615
-
616
- **Filter Mode** (default) - Hide non-matching options as you type:
617
- ```html
618
- <web-multiselect search-mode="filter" id="countries"></web-multiselect>
619
- ```
620
-
621
- **Navigate Mode** - Keep all options visible, jump to matches:
622
- ```html
623
- <web-multiselect search-mode="navigate" id="states"></web-multiselect>
624
-
625
- <script>
626
- const select = document.getElementById('states');
627
- select.options = [...50 US states...];
628
-
629
- // User types "cal" → Jumps to "California", shows all states
630
- // Matching options are highlighted with left border
631
- </script>
632
- ```
633
-
634
- **When to use each mode:**
635
- - **Filter Mode**: Large datasets where narrowing down is essential (product catalogs, user lists, search results)
636
- - **Navigate Mode**: Quick selection from familiar lists (countries, states, keyboard shortcuts, known options)
637
-
638
- **Key differences:**
639
- - Filter mode hides non-matches, navigate mode highlights matches with a left border
640
- - Navigate mode keeps previous focus if no match is found (type "xyz" → stays on current option)
641
- - Navigate mode only works with local data (automatically falls back to filter mode when using `searchCallback`)
642
- - Both modes respect `beforeSearchCallback` for search term preprocessing (accent removal, validation)
643
- - **Ctrl+↑/↓** jumps between matches only (navigate mode) - regular arrows navigate through all items
644
-
645
- ### Display Modes
646
-
647
- Perfect for different use cases and space constraints:
648
-
649
- ```html
650
- <!-- Badges mode (default) - Show all selections as removable badges -->
651
- <web-multiselect badges-display-mode="pills"></web-multiselect>
652
-
653
- <!-- Count mode - Show "X selected" text with clear button -->
654
- <web-multiselect badges-display-mode="count" show-counter="true"></web-multiselect>
655
-
656
- <!-- Compact mode - Show first item + count in a single removable badge -->
657
- <web-multiselect badges-display-mode="compact"></web-multiselect>
658
- <!-- Example output: [JavaScript (+2 more) | x] -->
659
-
660
- <!-- None mode - No display in badges area (minimal UI) -->
661
- <web-multiselect badges-display-mode="none" show-counter="true"></web-multiselect>
662
- <!-- Only shows [X] badge next to toggle icon -->
663
-
664
- <!-- Auto-switch from badges to count at threshold -->
665
- <web-multiselect
666
- badges-threshold="3"
667
- badges-threshold-mode="count"
668
- show-counter="true">
669
- </web-multiselect>
670
-
671
- <!-- Partial mode - Show limited badges + "+X more" badge -->
672
- <web-multiselect
673
- badges-threshold="5"
674
- badges-threshold-mode="partial"
675
- badges-max-visible="3">
676
- </web-multiselect>
677
- ```
678
-
679
- **Display Mode Behavior:**
680
- - **`pills`**: Individual removable badges for each selected item. Calls `getBadgeDisplayCallback` for each item.
681
- - **`count`**: Shows "X selected" text with clear button. Calls `getCounterCallback(count)`.
682
- - **`compact`**: Shows first item + count in single badge (e.g., "JavaScript (+2 more)"). Calls `getBadgeDisplayCallback(firstItem)` and `getCounterCallback(count, remainingCount)`.
683
- - **`partial`**: Shows first N badges + "+X more" badge. Calls `getBadgeDisplayCallback` for visible items and `getCounterCallback(count, remainingCount)` for badge.
684
- - **`none`**: No display in badges area. No callbacks invoked. Use with `show-counter="true"` for minimal UI.
685
-
686
- **Badge Styling:**
687
- - **Data badges** (selected items like "JavaScript", "Python"): Blue styling by default
688
- - **BadgeCounters** ("+3 more", "5 selected", compact mode display): Gray styling to distinguish from data
689
- - Both can be customized via CSS variables (see `--ms-badge-*` and `--ms-badge-counter-*`)
690
-
691
- **Counter (`show-counter="true"`)**: Independent feature showing `[X]` next to toggle icon. Works with all display modes. Not affected by callbacks.
692
-
693
- ### Badge Positioning
694
-
695
- Control where selected item badges appear relative to the input:
696
-
697
- ```html
698
- <!-- Badges below input (default) -->
699
- <web-multiselect badges-position="bottom"></web-multiselect>
700
-
701
- <!-- Badges above input -->
702
- <web-multiselect badges-position="top"></web-multiselect>
703
-
704
- <!-- Badges to the left of input -->
705
- <web-multiselect badges-position="left"></web-multiselect>
706
-
707
- <!-- Badges to the right of input -->
708
- <web-multiselect badges-position="right"></web-multiselect>
709
- ```
710
-
711
- **Inline Vertical Alignment:** For left/right positioning, control vertical alignment with `--ms-inline-align`:
712
-
713
- ```html
714
- <!-- Center aligned (default) -->
715
- <web-multiselect badges-position="right" style="--ms-inline-align: center;"></web-multiselect>
716
-
717
- <!-- Top aligned -->
718
- <web-multiselect badges-position="right" style="--ms-inline-align: flex-start;"></web-multiselect>
719
-
720
- <!-- Bottom aligned -->
721
- <web-multiselect badges-position="left" style="--ms-inline-align: flex-end;"></web-multiselect>
722
- ```
723
-
724
- **Note:** In RTL mode, left/right positions are automatically mirrored - `badges-position="left"` will appear on the physical right side in RTL languages.
725
-
726
- ### Badge Tooltips
727
-
728
- Enable tooltips on selected item badges with customizable placement and delay:
729
-
730
- ```html
731
- <!-- Basic tooltips -->
732
- <web-multiselect
733
- enable-badge-tooltips="true"
734
- badge-tooltip-placement="top">
735
- </web-multiselect>
736
-
737
- <!-- Fast tooltips with custom delay -->
738
- <web-multiselect
739
- enable-badge-tooltips="true"
740
- badge-tooltip-delay="100">
741
- </web-multiselect>
742
-
743
- <script type="module">
744
- const select = document.querySelector('web-multiselect');
745
-
746
- // Custom tooltip content
747
- select.getBadgeTooltipCallback = (item) => {
748
- return `${item.label} - ${item.subtitle}`;
749
- };
750
- </script>
751
- ```
752
-
753
- ### Internationalization (i18n)
754
-
755
- Customize counter text for proper pluralization and localization:
756
-
757
- ```html
758
- <web-multiselect
759
- id="i18n-select"
760
- badges-threshold="5"
761
- badges-threshold-mode="partial"
762
- badges-max-visible="3">
763
- </web-multiselect>
764
-
765
- <script type="module">
766
- const select = document.getElementById('i18n-select');
767
-
768
- // Spanish pluralization example
769
- select.getCounterCallback = (count, moreCount) => {
770
- if (moreCount !== undefined) {
771
- // Partial mode: "+X more" badge
772
- return moreCount === 1 ? '+1 más' : `+${moreCount} más`;
773
- }
774
- // Count mode: total count
775
- return count === 1 ? '1 elemento seleccionado' : `${count} elementos seleccionados`;
776
- };
777
- </script>
778
- ```
779
-
780
- ### Right-to-Left (RTL) Language Support
781
-
782
- Full RTL support for Arabic, Hebrew, Persian, Urdu, and other right-to-left languages with automatic detection and complete UI mirroring:
783
-
784
- ```html
785
- <!-- Automatic RTL detection from dir attribute -->
786
- <web-multiselect dir="rtl" search-placeholder="ابحث..."></web-multiselect>
787
-
788
- <!-- RTL inherited from parent element -->
789
- <div dir="rtl">
790
- <web-multiselect search-placeholder="חיפוש..."></web-multiselect>
791
- </div>
792
-
793
- <!-- RTL on page level -->
794
- <html dir="rtl">
795
- <!-- All multi-selects will auto-detect RTL -->
796
- </html>
797
- ```
798
-
799
- **RTL Features:**
800
- - ✅ **Auto-detection** - Detects `dir="rtl"` on component or any ancestor element
801
- - ✅ **Complete UI mirroring** - Toggle icon, text alignment, badges, dropdown, badges
802
- - ✅ **Logical positioning** - `badges-position="left"` becomes physically right in RTL
803
- - ✅ **Badge remove buttons** - Flip to left side in RTL mode
804
- - ✅ **Text direction** - All text content properly right-aligned
805
- - ✅ **No configuration needed** - Just set `dir="rtl"` attribute
806
-
807
- ### Custom Rendering
808
-
809
- 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.
810
-
811
- #### Overview
812
-
813
- Three rendering callbacks are available:
814
- - **`renderOptionContentCallback`** - Customize dropdown option content
815
- - **`renderBadgeContentCallback`** - Customize badge (selected item) content
816
- - **`renderSelectedContentCallback`** - Customize selected value text (single-select mode)
817
-
818
- All callbacks can return either **HTML strings** or **HTMLElement** objects (except `renderSelectedContentCallback` which returns plain text).
819
-
820
- #### HTML Injection (XSS) Notice
821
-
822
- 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:
823
-
824
- | Callback | Output Used In | Risk Level |
825
- |----------|---------------|------------|
826
- | `renderOptionContentCallback` | Dropdown options (innerHTML) | HTML injection |
827
- | `renderBadgeContentCallback` | Badges (innerHTML) | HTML injection |
828
- | `renderSelectedItemContentCallback` | Selected items popover (innerHTML) | HTML injection |
829
- | `renderGroupLabelContentCallback` | Group headers (innerHTML) | HTML injection |
830
- | `getIconCallback` | Option icons (innerHTML) | HTML injection |
831
- | `getSubtitleCallback` | Option subtitles (innerHTML) | HTML injection |
832
- | `getDisplayValueCallback` | Option titles, badges (innerHTML) | HTML injection |
833
- | `getBadgeDisplayCallback` | Badge text (innerHTML) | HTML injection |
834
- | `getCounterCallback` | Count badges (innerHTML) | HTML injection |
835
- | `getBadgeTooltipCallback` | Tooltips (innerHTML if HTMLElement) | HTML injection |
836
- | `customStylesCallback` | Style tag (textContent) | CSS injection |
837
-
838
- **Safe callbacks** (output is escaped or used as data):
839
- - `getValueCallback`, `getSearchValueCallback`, `getGroupCallback`, `getDisabledCallback`
840
- - `getBadgeClassCallback`, `getSelectedItemClassCallback` (CSS class names only)
841
- - `beforeSearchCallback`, `searchCallback`, `addNewCallback`
842
- - `selectCallback`, `deselectCallback`, `changeCallback`
843
- - `getRemoveButtonTooltipCallback` (used as title attribute)
844
- - `getValueFormatCallback` (form value)
845
-
846
- **If displaying user-generated content**, sanitize it before returning from these callbacks.
847
-
848
- #### Custom Option Rendering
849
-
850
- Customize how options appear in the dropdown:
851
-
852
- ```html
853
- <web-multiselect id="custom-options"></web-multiselect>
65
+ <web-multiselect id="picker" search-placeholder="Search…"></web-multiselect>
854
66
 
855
67
  <script type="module">
856
68
  import '@keenmate/web-multiselect';
857
69
 
858
- const select = document.getElementById('custom-options');
859
-
860
- select.options = [
861
- { id: 1, name: 'React', stars: 220000, trending: true },
862
- { id: 2, name: 'Vue', stars: 207000, trending: false },
863
- { id: 3, name: 'Angular', stars: 94000, trending: false },
864
- { id: 4, name: 'Svelte', stars: 76000, trending: true }
70
+ const picker = document.getElementById('picker');
71
+ picker.options = [
72
+ { value: 'js', label: 'JavaScript', icon: '🟨' },
73
+ { value: 'ts', label: 'TypeScript', icon: '🔷' },
74
+ { value: 'py', label: 'Python', icon: '🐍' }
865
75
  ];
866
76
 
867
- // Custom renderer with full HTML control
868
- select.renderOptionContentCallback = (item, context) => {
869
- // Context provides: { index, isSelected, isFocused, isMatched, isDisabled }
870
-
871
- return `
872
- <div style="display: flex; align-items: center; gap: 0.5rem;">
873
- <strong>${item.name}</strong>
874
- <span style="color: #666; font-size: 0.875rem;">⭐ ${(item.stars / 1000).toFixed(0)}k</span>
875
- ${item.trending ? '<span style="background: #10b981; color: white; padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.75rem;">🔥 Trending</span>' : ''}
876
- </div>
877
- `;
878
- };
879
- </script>
880
- ```
881
-
882
- **Context object** (`OptionContentRenderContext`):
883
- - `index: number` - Index of the option in the filtered list
884
- - `isSelected: boolean` - Whether the option is currently selected
885
- - `isFocused: boolean` - Whether the option is currently focused (keyboard navigation)
886
- - `isMatched: boolean` - Whether the option matches the current search term (navigate mode only)
887
- - `isDisabled: boolean` - Whether the option is disabled
888
-
889
- #### Custom Badge Rendering
890
-
891
- Customize how selected items appear as badges:
892
-
893
- ```javascript
894
- const select = document.querySelector('web-multiselect');
895
-
896
- select.options = [
897
- { id: 1, name: 'John Doe', role: 'Admin', avatar: '👨‍💼' },
898
- { id: 2, name: 'Jane Smith', role: 'Developer', avatar: '👩‍💻' },
899
- { id: 3, name: 'Bob Johnson', role: 'Designer', avatar: '🎨' }
900
- ];
901
-
902
- // Custom badge rendering in main badges area
903
- select.renderBadgeContentCallback = (item, context) => {
904
- // Compact view in badges area
905
- return `${item.avatar} ${item.name}`;
906
- };
907
-
908
- // Custom rendering for selected items popover (separate callback)
909
- select.renderSelectedItemContentCallback = (item) => {
910
- // Full details in popover - has more space
911
- return `
912
- <div style="display: flex; align-items: center; gap: 0.5rem;">
913
- <span>${item.avatar}</span>
914
- <div>
915
- <div><strong>${item.name}</strong></div>
916
- <div style="font-size: 0.75rem; color: #666;">${item.role}</div>
917
- </div>
918
- </div>
919
- `;
920
- };
921
- ```
922
-
923
- **Separate Callbacks for Badges vs. Popover:**
924
- - `renderBadgeContentCallback` - Renders badges in the main badges area (compact display)
925
- - `renderSelectedItemContentCallback` - Renders items in the selected items popover (can be more detailed)
926
- - If `renderSelectedItemContentCallback` is not defined, falls back to `renderBadgeContentCallback`
927
- - Users can assign the same function to both if identical rendering is desired
928
-
929
- **Context object** (`BadgeContentRenderContext` for `renderBadgeContentCallback`):
930
- - `displayMode: BadgesDisplayMode` - Current badges display mode ('pills', 'count', 'compact', 'partial', 'none')
931
- - `isInPopover: boolean` - Whether the badge is being rendered in the selected items popover (always false for this callback)
932
-
933
- #### Custom Group Label Rendering
934
-
935
- Customize how group headers are displayed using `renderGroupLabelContentCallback`:
936
-
937
- ```javascript
938
- const select = document.querySelector('web-multiselect');
939
-
940
- select.options = [
941
- { value: 'react', label: 'React', group: 'frontend' },
942
- { value: 'vue', label: 'Vue', group: 'frontend' },
943
- { value: 'nodejs', label: 'Node.js', group: 'backend' },
944
- { value: 'postgres', label: 'PostgreSQL', group: 'database' }
945
- ];
946
-
947
- select.isGroupsAllowed = true;
948
- select.groupMember = 'group';
949
-
950
- // Customize group label display
951
- select.renderGroupLabelContentCallback = (groupName) => {
952
- const emojis = {
953
- 'frontend': '🎨',
954
- 'backend': '🔧',
955
- 'database': '🗄️'
956
- };
957
- const emoji = emojis[groupName] || '📦';
958
- return `<strong>${emoji} ${groupName.toUpperCase()}</strong>`;
959
- };
960
- ```
961
-
962
- **Signature:** `(groupName: string) => string | HTMLElement`
963
-
964
- **Use cases:**
965
- - Capitalize or format group names
966
- - Add icons, emojis, or badges to group headers
967
- - Apply HTML formatting (bold, colors, etc.)
968
- - Internationalization (i18n) - translate group names
969
- - Add group-specific metadata or counts
970
-
971
- **Notes:**
972
- - Keeps standard `.ms__group-label` wrapper for consistent styling
973
- - Can return HTML string or HTMLElement
974
- - Group name is passed as a string parameter
975
-
976
- #### Custom Badge Styling with CSS Classes
977
-
978
- Add custom CSS classes to badges based on item data for semantic styling:
979
-
980
- ```javascript
981
- const select = document.querySelector('web-multiselect');
982
-
983
- select.options = [
984
- { id: 1, task: 'Fix security bug', priority: 'urgent' },
985
- { id: 2, task: 'Update docs', priority: 'normal' },
986
- { id: 3, task: 'Refactor code', priority: 'low' }
987
- ];
988
-
989
- // Add CSS class based on priority
990
- select.getBadgeClassCallback = (item) => {
991
- return `badge-${item.priority}`; // Returns 'badge-urgent', 'badge-normal', etc.
992
- };
993
-
994
- // Can also return array of classes
995
- select.getBadgeClassCallback = (item) => {
996
- const classes = [`badge-${item.priority}`];
997
- if (item.urgent) classes.push('badge-blink');
998
- return classes;
999
- };
1000
- ```
1001
-
1002
- Then style with CSS:
1003
-
1004
- ```css
1005
- /* Target specific badges with custom classes */
1006
- .badge-urgent {
1007
- --ms-badge-text-bg: #fee2e2;
1008
- --ms-badge-text-color: #dc2626;
1009
- --ms-badge-remove-bg: #dc2626;
1010
- }
1011
-
1012
- .badge-normal {
1013
- --ms-badge-text-bg: #dbeafe;
1014
- --ms-badge-text-color: #2563eb;
1015
- --ms-badge-remove-bg: #2563eb;
1016
- }
1017
-
1018
- .badge-low {
1019
- --ms-badge-text-bg: #d1fae5;
1020
- --ms-badge-text-color: #059669;
1021
- --ms-badge-remove-bg: #059669;
1022
- }
1023
- ```
1024
-
1025
- The callback:
1026
- - Takes the item as a parameter
1027
- - Returns a string (single class) or array of strings (multiple classes)
1028
- - Classes are added to the badge's base `.ml__badge` element
1029
- - Works across all rendering locations (main badges, partial mode, popover)
1030
-
1031
- **Separate Class Callbacks for Badges vs. Popover:**
1032
-
1033
- Similar to rendering callbacks, you can use different class callbacks for badges and selected items:
1034
-
1035
- ```javascript
1036
- // Add classes to badges in main area
1037
- select.getBadgeClassCallback = (item) => {
1038
- return `badge-${item.priority}`;
1039
- };
1040
-
1041
- // Add different/additional classes to selected items in popover
1042
- select.getSelectedItemClassCallback = (item) => {
1043
- // Could add more detailed classes for popover items
1044
- return [`badge-${item.priority}`, 'badge-detailed'];
1045
- };
1046
- ```
1047
-
1048
- - `getBadgeClassCallback` - Adds classes to badges in the main badges area
1049
- - `getSelectedItemClassCallback` - Adds classes to items in the selected items popover
1050
- - If `getSelectedItemClassCallback` is not defined, falls back to `getBadgeClassCallback`
1051
- - Users can assign the same function to both if identical styling is desired
1052
-
1053
- **Shadow DOM CSS Injection:**
1054
-
1055
- Since the component uses Shadow DOM, regular page CSS cannot style shadow elements. Use `customStylesCallback` to inject CSS directly into the Shadow DOM:
1056
-
1057
- ```javascript
1058
- const select = document.querySelector('web-multiselect');
1059
-
1060
- // Add CSS classes to badges based on item data
1061
- select.getBadgeClassCallback = (item) => {
1062
- return `badge-${item.priority}`;
1063
- };
1064
-
1065
- // Inject CSS into Shadow DOM to style those classes
1066
- select.customStylesCallback = () => `
1067
- .badge-urgent {
1068
- --ms-badge-text-bg: #fee2e2;
1069
- --ms-badge-text-color: #dc2626;
1070
- --ms-badge-remove-bg: #dc2626;
1071
- }
1072
-
1073
- .badge-normal {
1074
- --ms-badge-text-bg: #dbeafe;
1075
- --ms-badge-text-color: #2563eb;
1076
- --ms-badge-remove-bg: #2563eb;
1077
- }
1078
-
1079
- .badge-low {
1080
- --ms-badge-text-bg: #d1fae5;
1081
- --ms-badge-text-color: #059669;
1082
- --ms-badge-remove-bg: #059669;
1083
- }
1084
- `;
1085
- ```
1086
-
1087
- The `customStylesCallback`:
1088
- - Returns a CSS string (not HTML)
1089
- - Styles are injected into the Shadow DOM on initialization
1090
- - Can be updated dynamically - new styles replace old ones
1091
- - Works with all custom classes (from `getBadgeClassCallback`, `renderOptionContentCallback`, etc.)
1092
-
1093
- #### Custom Selected Item Rendering (Single-Select)
1094
-
1095
- Customize the text shown in the input field when in single-select mode:
1096
-
1097
- ```javascript
1098
- const select = document.querySelector('web-multiselect[multiple="false"]');
1099
-
1100
- select.options = [
1101
- { id: 1, firstName: 'John', lastName: 'Doe', email: 'john@example.com' },
1102
- { id: 2, firstName: 'Jane', lastName: 'Smith', email: 'jane@example.com' }
1103
- ];
1104
-
1105
- // Show just first name when closed
1106
- select.renderSelectedContentCallback = (item) => {
1107
- return item.firstName; // Returns plain text (not HTML)
1108
- };
1109
-
1110
- // While dropdown shows full details
1111
- select.getDisplayValueCallback = (item) => {
1112
- return `${item.firstName} ${item.lastName} (${item.email})`;
1113
- };
1114
- ```
1115
-
1116
- #### Conditional Rendering Example
1117
-
1118
- Use JavaScript logic for conditional rendering:
1119
-
1120
- ```javascript
1121
- select.renderOptionContentCallback = (item, context) => {
1122
- const classes = [];
1123
- if (context.isSelected) classes.push('selected');
1124
- if (context.isFocused) classes.push('focused');
1125
-
1126
- return `
1127
- <div class="${classes.join(' ')}">
1128
- ${item.isNew ? '<span class="badge-new">NEW</span>' : ''}
1129
- <strong>${item.name}</strong>
1130
- ${item.description ? `<p style="font-size: 0.875rem; color: #666;">${item.description}</p>` : ''}
1131
- ${item.tags ? `<div class="tags">${item.tags.map(tag => `<span class="tag">${tag}</span>`).join('')}</div>` : ''}
1132
- </div>
1133
- `;
1134
- };
1135
- ```
1136
-
1137
- #### Returning HTMLElement
1138
-
1139
- You can also return DOM elements for more complex rendering:
1140
-
1141
- ```javascript
1142
- select.renderOptionContentCallback = (item, context) => {
1143
- const div = document.createElement('div');
1144
- div.style.display = 'flex';
1145
- div.style.alignItems = 'center';
1146
- div.style.gap = '0.5rem';
1147
-
1148
- const img = document.createElement('img');
1149
- img.src = item.avatarUrl;
1150
- img.style.width = '32px';
1151
- img.style.height = '32px';
1152
- img.style.borderRadius = '50%';
1153
-
1154
- const span = document.createElement('span');
1155
- span.textContent = item.name;
1156
-
1157
- div.appendChild(img);
1158
- div.appendChild(span);
1159
-
1160
- return div; // Return HTMLElement instead of string
1161
- };
1162
- ```
1163
-
1164
- #### Virtual Scroll Compatibility
1165
-
1166
- When using `renderOptionContentCallback` with virtual scroll enabled:
1167
-
1168
- ⚠️ **Important**: Custom option content **must fit within** the configured `optionHeight` (default: 50px)
1169
-
1170
- ```html
1171
- <web-multiselect
1172
- id="large-dataset"
1173
- enable-virtual-scroll="true"
1174
- option-height="60">
1175
- </web-multiselect>
1176
-
1177
- <script type="module">
1178
- const select = document.getElementById('large-dataset');
1179
-
1180
- select.renderOptionContentCallback = (item) => {
1181
- // Content must fit in 60px height
1182
- return `
1183
- <div style="height: 60px; display: flex; align-items: center;">
1184
- <strong>${item.name}</strong>
1185
- </div>
1186
- `;
1187
- };
1188
- </script>
1189
- ```
1190
-
1191
- **Virtual scroll requirements:**
1192
- - Content height must be **fixed** and match `optionHeight`
1193
- - Overflow will be clipped
1194
- - Variable-height content only works in non-virtual mode
1195
-
1196
- #### Callback Priority
1197
-
1198
- The component uses a fallback chain when callbacks are not provided:
1199
-
1200
- **For options:**
1201
- 1. `renderOptionContentCallback` (full HTML control)
1202
- 2. Default: icon + `getDisplayValueCallback` + subtitle
1203
-
1204
- **For badges:**
1205
- 1. `renderBadgeContentCallback` (full HTML control)
1206
- 2. `getBadgeDisplayCallback` (text only)
1207
- 3. `getDisplayValueCallback` (text only)
1208
-
1209
- **For selected item (single-select):**
1210
- 1. `renderSelectedContentCallback` (text only)
1211
- 2. `getDisplayValueCallback` (text only)
1212
-
1213
- #### Checkbox Control
1214
-
1215
- Control checkbox appearance and alignment with CSS variables and attributes:
1216
-
1217
- **Checkbox Alignment (via attribute):**
1218
- ```html
1219
- <web-multiselect checkbox-align="top"></web-multiselect> <!-- Default -->
1220
- <web-multiselect checkbox-align="center"></web-multiselect> <!-- Middle aligned -->
1221
- <web-multiselect checkbox-align="bottom"></web-multiselect> <!-- Bottom aligned -->
1222
- ```
1223
-
1224
- **Checkbox Size/Scale (via CSS):**
1225
- ```html
1226
- <style>
1227
- /* Change checkbox size */
1228
- web-multiselect {
1229
- --ms-checkbox-size: 20px; /* Width and height (default: 16px) */
1230
- }
1231
-
1232
- /* Scale checkbox */
1233
- web-multiselect {
1234
- --ms-checkbox-scale: 1.5; /* Scale multiplier (default: 1) */
1235
- }
1236
-
1237
- /* Fine-tune checkbox positioning */
1238
- web-multiselect {
1239
- --ms-checkbox-margin-top: 0.5rem; /* Vertical alignment (default: 0.125rem) */
1240
- --ms-checkbox-margin-right: 0; /* Right spacing (default: 0) */
1241
- --ms-checkbox-margin-bottom: 0; /* Bottom spacing (default: 0) */
1242
- --ms-checkbox-margin-left: 0; /* Left spacing (default: 0) */
1243
- }
1244
- </style>
1245
- ```
1246
-
1247
- **CSS Grid/Flexbox in Custom Content:**
1248
-
1249
- Custom rendering callbacks support full CSS layout control:
1250
-
1251
- ```javascript
1252
- // CSS Grid example
1253
- multiselect.renderOptionContentCallback = (item, context) => {
1254
- return `
1255
- <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;">
1256
- <div><strong>Name:</strong> ${item.name}</div>
1257
- <div><strong>Price:</strong> ${item.price}</div>
1258
- <div><strong>Stock:</strong> ${item.stock}</div>
1259
- <div><strong>Rating:</strong> ${item.rating}</div>
1260
- </div>
1261
- `;
1262
- };
1263
-
1264
- // Flexbox example
1265
- multiselect.renderOptionContentCallback = (item, context) => {
1266
- return `
1267
- <div style="display: flex; justify-content: space-between; align-items: center;">
1268
- <div style="display: flex; flex-direction: column;">
1269
- <strong>${item.name}</strong>
1270
- <span style="font-size: 0.875rem; color: #666;">${item.description}</span>
1271
- </div>
1272
- <div style="text-align: right;">
1273
- <div>${item.price}</div>
1274
- <div style="font-size: 0.875rem;">${item.stock} in stock</div>
1275
- </div>
1276
- </div>
1277
- `;
1278
- };
1279
- ```
1280
-
1281
- **Available CSS Variables:**
1282
- - `--ms-checkbox-size`: Checkbox width/height (default: `16px`)
1283
- - `--ms-checkbox-scale`: Scale multiplier (default: `1`)
1284
- - `--ms-checkbox-margin-top`: Top margin for vertical alignment (default: `0.125rem`)
1285
- - `--ms-checkbox-margin-right`: Right margin (default: `0`)
1286
- - `--ms-checkbox-margin-bottom`: Bottom margin (default: `0`)
1287
- - `--ms-checkbox-margin-left`: Left margin (default: `0`)
1288
- - `--ms-checkbox-align`: Alignment value (default: `flex-start`)
1289
- - `--ms-option-gap`: Gap between checkbox and content (default: `0.5rem`)
1290
-
1291
- **Note:** Horizontal and bottom margins default to `0` since spacing is handled by flexbox gap. Override for custom layouts.
1292
-
1293
- ### Flexible Data Handling
1294
-
1295
- 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.
1296
-
1297
- #### Member Properties (Simple Property Names)
1298
-
1299
- For objects with consistent property names, use member attributes:
1300
-
1301
- ```html
1302
- <web-multiselect
1303
- id="products"
1304
- value-member="productId"
1305
- display-value-member="productName"
1306
- icon-member="icon"
1307
- subtitle-member="description"
1308
- group-member="category">
1309
- </web-multiselect>
1310
-
1311
- <script type="module">
1312
- const select = document.getElementById('products');
1313
- select.options = [
1314
- {
1315
- productId: 'p1',
1316
- productName: 'Laptop',
1317
- icon: '💻',
1318
- description: 'High-performance laptop',
1319
- category: 'Electronics'
1320
- },
1321
- {
1322
- productId: 'p2',
1323
- productName: 'Mouse',
1324
- icon: '🖱️',
1325
- description: 'Wireless mouse',
1326
- category: 'Electronics'
1327
- }
1328
- ];
1329
- </script>
1330
- ```
1331
-
1332
- #### Callback Functions (Complex Logic)
1333
-
1334
- For complex data extraction or conditional logic, use callbacks:
1335
-
1336
- ```javascript
1337
- const select = document.querySelector('web-multiselect');
1338
-
1339
- // Custom value extraction
1340
- select.getValueCallback = (item) => item.id || item.code || item.value;
1341
-
1342
- // Combine multiple fields for display
1343
- select.getDisplayValueCallback = (item) => {
1344
- return `${item.firstName} ${item.lastName}`;
1345
- };
1346
-
1347
- // Include multiple fields in search
1348
- select.getSearchValueCallback = (item) => {
1349
- return `${item.name} ${item.sku} ${item.tags.join(' ')}`;
1350
- };
1351
-
1352
- // Conditional icons
1353
- select.getIconCallback = (item) => {
1354
- return item.inStock ? '✅' : '❌';
1355
- };
1356
-
1357
- // Dynamic subtitles
1358
- select.getSubtitleCallback = (item) => {
1359
- return `$${item.price} - ${item.stock} in stock`;
1360
- };
1361
-
1362
- // Disable based on conditions
1363
- select.getDisabledCallback = (item) => {
1364
- return item.stock === 0 || item.discontinued;
1365
- };
1366
-
1367
- // Customize badge display (show different text in badges vs dropdown)
1368
- select.getBadgeDisplayCallback = (item) => {
1369
- // Badges show just the name for space efficiency
1370
- return item.name;
1371
- // While dropdown can show full details: "Laptop - $999 - Electronics"
1372
- };
1373
- ```
1374
-
1375
- #### Tuple Array Auto-Detection
1376
-
1377
- The component automatically detects `[key, value]` tuple arrays:
1378
-
1379
- ```javascript
1380
- select.options = [
1381
- ['js', 'JavaScript'],
1382
- ['ts', 'TypeScript'],
1383
- ['py', 'Python']
1384
- ];
1385
- // First element becomes value, second becomes display text
1386
- ```
1387
-
1388
- #### Priority Order
1389
-
1390
- When multiple extraction methods are defined, the component uses this priority:
1391
-
1392
- 1. **Callbacks** (highest priority) - `getValueCallback`, `getDisplayValueCallback`, etc.
1393
- 2. **Member properties** - `valueMember`, `displayValueMember`, etc.
1394
- 3. **Default properties** (lowest priority) - Falls back to `value`, `label`, `name`, etc.
1395
-
1396
- #### TypeScript Support
1397
-
1398
- The component is fully typed with generics:
1399
-
1400
- ```typescript
1401
- import type { MultiSelectElement } from '@keenmate/web-multiselect';
1402
-
1403
- interface Product {
1404
- id: string;
1405
- name: string;
1406
- price: number;
1407
- category: string;
1408
- }
1409
-
1410
- const select = document.querySelector<MultiSelectElement<Product>>('web-multiselect');
1411
- select.options = [
1412
- { id: 'p1', name: 'Laptop', price: 999, category: 'Electronics' }
1413
- ];
1414
- ```
1415
-
1416
- ### Form Integration
1417
-
1418
- 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.
1419
-
1420
- #### Basic Form Integration
1421
-
1422
- ```html
1423
- <form id="userForm" action="/submit" method="POST">
1424
- <label>Select Skills:</label>
1425
- <web-multiselect
1426
- name="skills"
1427
- value-format="json"
1428
- multiple="true">
1429
- </web-multiselect>
1430
-
1431
- <button type="submit">Submit</button>
1432
- </form>
1433
-
1434
- <script type="module">
1435
- import '@keenmate/web-multiselect';
1436
-
1437
- const form = document.getElementById('userForm');
1438
- const select = form.querySelector('web-multiselect');
1439
-
1440
- select.options = [
1441
- { value: 'js', label: 'JavaScript' },
1442
- { value: 'ts', label: 'TypeScript' },
1443
- { value: 'py', label: 'Python' }
1444
- ];
1445
-
1446
- form.addEventListener('submit', (e) => {
1447
- e.preventDefault();
1448
- const formData = new FormData(form);
1449
-
1450
- // Access the value
1451
- const skills = formData.get('skills');
1452
- console.log('Selected skills:', skills);
1453
- // Output: ["js","ts"] (JSON string)
77
+ picker.addEventListener('change', (e) => {
78
+ console.log('Selected:', e.detail.selectedValues);
1454
79
  });
1455
80
  </script>
1456
81
  ```
1457
82
 
1458
- #### Value Formats
1459
-
1460
- Choose how selected values are serialized in forms:
1461
-
1462
- **JSON Format** (default):
1463
- ```html
1464
- <web-multiselect name="items" value-format="json"></web-multiselect>
1465
- <!-- FormData result: items = ["item1","item2","item3"] -->
1466
- ```
1467
-
1468
- **CSV Format**:
1469
- ```html
1470
- <web-multiselect name="items" value-format="csv"></web-multiselect>
1471
- <!-- FormData result: items = "item1,item2,item3" -->
1472
- ```
1473
-
1474
- **Array Format** (multiple inputs):
1475
- ```html
1476
- <web-multiselect name="items" value-format="array"></web-multiselect>
1477
- <!-- FormData result:
1478
- items[] = "item1"
1479
- items[] = "item2"
1480
- items[] = "item3"
1481
- -->
1482
- ```
1483
-
1484
- #### Custom Value Formatting
1485
-
1486
- For advanced use cases, provide a custom formatting function:
1487
-
1488
- ```javascript
1489
- const select = document.querySelector('web-multiselect');
1490
-
1491
- select.name = 'product_ids';
1492
- select.getValueFormatCallback = (values) => {
1493
- // Custom format: pipe-separated with prefix
1494
- return values.map(v => `ID:${v}`).join('|');
1495
- };
1496
-
1497
- // When submitted, FormData will have:
1498
- // product_ids = "ID:123|ID:456|ID:789"
1499
- ```
1500
-
1501
- #### Using getValue() for JavaScript Submissions
1502
-
1503
- For JavaScript-based form submissions (AJAX, fetch), use `getValue()`:
1504
-
1505
- ```javascript
1506
- // Single-select mode
1507
- const select = document.querySelector('multi-select[multiple="false"]');
1508
- const selectedId = select.getValue();
1509
- // Returns: "js" or null
1510
-
1511
- // Multi-select mode
1512
- const multiSelect = document.querySelector('multi-select[multiple="true"]');
1513
- const selectedIds = multiSelect.getValue();
1514
- // Returns: ["js", "ts", "py"] or []
1515
-
1516
- // Submit with fetch
1517
- const response = await fetch('/api/update', {
1518
- method: 'POST',
1519
- headers: { 'Content-Type': 'application/json' },
1520
- body: JSON.stringify({
1521
- skills: multiSelect.getValue()
1522
- })
1523
- });
1524
- ```
1525
-
1526
- #### Working with Numeric Values
1527
-
1528
- The component handles both string and numeric values correctly:
1529
-
1530
- ```javascript
1531
- select.options = [
1532
- { value: 1, label: 'Option 1' },
1533
- { value: 2, label: 'Option 2' },
1534
- { value: 3, label: 'Option 3' }
1535
- ];
1536
-
1537
- // getValue() preserves types
1538
- const values = select.getValue();
1539
- // Returns: [1, 2, 3] (numbers, not strings)
1540
-
1541
- // FormData serialization
1542
- // JSON format: [1,2,3]
1543
- // CSV format: 1,2,3
1544
- // Array format: items[]=1, items[]=2, items[]=3
1545
- ```
1546
-
1547
- ### Disabled Options
1548
-
1549
- ```javascript
1550
- select.options = [
1551
- { value: 'basic', label: 'Basic License', subtitle: 'Free forever' },
1552
- { value: 'pro', label: 'Pro License', subtitle: 'Available for purchase' },
1553
- {
1554
- value: 'enterprise',
1555
- label: 'Enterprise License',
1556
- subtitle: 'Contact sales',
1557
- disabled: true
1558
- }
1559
- ];
1560
- ```
1561
-
1562
- ## Option Structure
1563
-
1564
- ```typescript
1565
- interface MultiSelectOption {
1566
- value: string; // Required: Unique identifier
1567
- label: string; // Required: Display text
1568
- icon?: string; // Optional: Icon or emoji
1569
- subtitle?: string; // Optional: Subtitle/description
1570
- group?: string; // Optional: Group name
1571
- disabled?: boolean; // Optional: Disable selection
1572
- }
1573
- ```
1574
-
1575
- ## Styling
1576
-
1577
- The component uses Shadow DOM for style encapsulation, but exposes CSS custom properties (CSS variables) that you can override to customize the appearance.
1578
-
1579
- ### Sizing
1580
-
1581
- The component uses `--ms-rem` as a base unit for proportional scaling. Default is `10px`, meaning `calc(1.4 * var(--ms-rem))` equals `14px`.
1582
-
1583
- **Global Scaling:**
1584
- ```html
1585
- <!-- Compact (80%) -->
1586
- <web-multiselect style="--ms-rem: 8px;"></web-multiselect>
1587
-
1588
- <!-- Default (100%) -->
1589
- <web-multiselect></web-multiselect>
1590
-
1591
- <!-- Large (120%) -->
1592
- <web-multiselect style="--ms-rem: 12px;"></web-multiselect>
1593
-
1594
- <!-- Pure Admin integration (inherits from html { font-size: 10px }) -->
1595
- <web-multiselect style="--ms-rem: 1rem;"></web-multiselect>
1596
- ```
1597
-
1598
- **Via CSS class:**
1599
- ```css
1600
- web-multiselect.compact { --ms-rem: 8px; }
1601
- web-multiselect.large { --ms-rem: 12px; }
1602
- ```
1603
-
1604
- **Shadow DOM Note:** CSS variables must be set on the `<web-multiselect>` element itself, not on wrapper divs.
1605
-
1606
- **Fine-grained Control:**
1607
- Override individual sizing variables for specific adjustments:
1608
- - `--ms-input-height` - Input field height (default: 35px)
1609
- - `--ms-input-font-size` - Input font size
1610
- - `--ms-input-padding` - Input padding
1611
- - `--ms-badge-height` - Badge height
1612
- - `--ms-option-height` - Option height in dropdown
1613
-
1614
- **Input Size Variants:**
1615
- Five size variants for consistent input sizing across KeenMate components:
1616
-
1617
- | Size | Variable | Height | Base Variable |
1618
- |------|----------|--------|---------------|
1619
- | XS | `--ms-input-size-xs-height` | 31px | `--base-input-size-xs-height` |
1620
- | SM | `--ms-input-size-sm-height` | 33px | `--base-input-size-sm-height` |
1621
- | MD | `--ms-input-size-md-height` | 35px | `--base-input-size-md-height` |
1622
- | LG | `--ms-input-size-lg-height` | 38px | `--base-input-size-lg-height` |
1623
- | XL | `--ms-input-size-xl-height` | 41px | `--base-input-size-xl-height` |
1624
-
1625
- Heights reference `--base-input-size-*-height` from the [Theme Designer](https://theme-designer.keenmate.dev), ensuring consistent input heights across all KeenMate components.
1626
-
1627
- ```css
1628
- /* Set consistent input heights across all components */
1629
- :root {
1630
- --base-input-size-md-height: 4.0; /* All components: 40px at 10px rem */
1631
- }
1632
- ```
1633
-
1634
- ### Theme Designer
1635
-
1636
- The easiest way to customize the appearance of this component is using the **KeenMate Theme Designer** at:
1637
-
1638
- **[theme-designer.keenmate.dev](https://theme-designer.keenmate.dev)**
1639
-
1640
- #### How It Works
1641
-
1642
- 1. **Choose 3 base colors** - background, text, and accent
1643
- 2. **Preview changes live** - see your theme applied instantly
1644
- 3. **Fine-tune individual variables** - lock specific values while adjusting others
1645
- 4. **Export your theme** - copy CSS, JSON, or SCSS to your project
1646
-
1647
- #### CSS Variable Layers
1648
-
1649
- KeenMate components support a **two-layer theming architecture**:
1650
-
1651
- **Standalone Mode (Simple)** - Just override the component-specific variables you need:
1652
-
1653
- ```css
1654
- :root {
1655
- --ms-accent-color: #your-brand-color;
1656
- --ms-primary-bg: #your-background;
1657
- --ms-text-primary: #your-text-color;
1658
- }
1659
- ```
1660
-
1661
- **Cascading Mode (Multi-Component)** - When using multiple KeenMate components, you can define a shared base layer:
1662
-
1663
- ```css
1664
- :root {
1665
- /* Base layer - single source of truth */
1666
- --base-accent-color: #3b82f6;
1667
- --base-main-bg: #ffffff;
1668
- --base-hover-bg: #f3f4f6;
1669
- --base-text-color-1: #111827;
1670
-
1671
- /* Components reference base layer */
1672
- --ms-accent-color: var(--base-accent-color);
1673
- --drp-accent-color: var(--base-accent-color);
1674
- }
1675
- ```
1676
-
1677
- Change `--base-accent-color` once → all components update automatically.
1678
-
1679
- #### Unified Variable Naming
1680
-
1681
- All KeenMate components follow a consistent naming convention for **Tier 1 variables** (core theming):
1682
-
1683
- | Purpose | web-multiselect | web-daterangepicker |
1684
- |---------|-----------------|---------------------|
1685
- | Brand color | `--ms-accent-color` | `--drp-accent-color` |
1686
- | Background | `--ms-primary-bg` | `--drp-primary-bg` |
1687
- | Text color | `--ms-text-primary` | `--drp-text-primary` |
1688
- | Text on accent | `--ms-text-color-on-accent` | `--drp-text-on-accent` |
1689
- | Border color | `--ms-border-color` | `--drp-border-color` |
1690
-
1691
- Learn the pattern once, apply it across all components.
1692
-
1693
- #### Component Variables Manifest
1694
-
1695
- This package exports a `component-variables.manifest.json` file that documents all supported CSS variables for tooling integration (e.g., Theme Designer, IDE autocomplete):
1696
-
1697
- ```javascript
1698
- import manifest from '@keenmate/web-multiselect/component-variables.manifest.json';
1699
- // manifest.baseVariables - list of --base-* variables the component responds to
1700
- // manifest.componentVariables - list of --ms-* component-specific variables
1701
- ```
1702
-
1703
- ### CSS Variables (No Build System Required)
1704
-
1705
- You can customize the component using CSS variables even with just a `<script>` tag:
1706
-
1707
- ```html
1708
- <style>
1709
- /* Override tooltip appearance */
1710
- web-multiselect {
1711
- --ms-tooltip-bg: #1f2937;
1712
- --ms-tooltip-color: #f9fafb;
1713
- --ms-tooltip-padding: 0.625rem 0.875rem;
1714
- --ms-tooltip-border-radius: 0.5rem;
1715
- --ms-tooltip-font-size: 0.8125rem;
1716
- --ms-tooltip-max-width: 24rem;
1717
- --ms-tooltip-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
1718
- --ms-tooltip-z-index: 10000;
1719
- }
1720
-
1721
- /* Override "+X more" badge colors */
1722
- web-multiselect {
1723
- --ms-more-badge-bg: #dbeafe;
1724
- --ms-more-badge-hover-bg: #bfdbfe;
1725
- --ms-more-badge-active-bg: #93c5fd;
1726
- }
1727
-
1728
- /* Size the component */
1729
- web-multiselect {
1730
- width: 100%;
1731
- max-width: 400px;
1732
- }
1733
- </style>
1734
- ```
1735
-
1736
- ### Available CSS Variables
1737
-
1738
- The component exposes **150+ CSS custom properties** defined at the `:host` level, making them inspectable and overridable. Below are the **50+ most commonly customized variables** organized by category.
1739
-
1740
- #### Inspecting Variables in DevTools
1741
-
1742
- All CSS custom properties are now defined at the `:host` level in the compiled CSS, making them visible in browser DevTools:
1743
-
1744
- 1. Open DevTools (F12) and select the `<web-multiselect>` element
1745
- 2. In the **Styles** panel, look for the `:host` selector
1746
- 3. You'll see all 150+ variables with their default values
1747
- 4. Edit values live to preview changes instantly
1748
-
1749
- **CSS variables work with Shadow DOM** because they inherit through the shadow boundary. This means you can customize the component from outside:
1750
-
1751
- ```html
1752
- <style>
1753
- /* These variables will penetrate into the Shadow DOM */
1754
- web-multiselect {
1755
- --ms-accent-color: #10b981; /* Changes primary color */
1756
- --ms-input-border-radius: 0.5rem; /* Rounds input corners */
1757
- }
1758
- </style>
1759
- ```
1760
-
1761
- For the complete list of all available CSS variables, see:
1762
- - [_variables.css](./src/css/_variables.css) - All 150+ CSS custom properties at `:host` level
1763
-
1764
- #### Colors
1765
-
1766
- | Variable | Default | Description |
1767
- |----------|---------|-------------|
1768
- | `--ms-accent-color` | `#3b82f6` | Primary accent color (blue) |
1769
- | `--ms-accent-color-hover` | `#2563eb` | Accent color on hover |
1770
- | `--ms-accent-color-active` | `#1d4ed8` | Accent color when active |
1771
- | `--ms-text-primary` | `#111827` | Primary text color |
1772
- | `--ms-text-secondary` | `#6b7280` | Secondary/muted text color |
1773
- | `--ms-border-color` | `#e5e7eb` | Default border color |
1774
- | `--ms-border` | `var(--base-border, 1px solid var(--ms-border-color))` | Default full border (inherits from theme-designer) |
1775
-
1776
- #### Input Component
1777
-
1778
- | Variable | Default | Description |
1779
- |----------|---------|-------------|
1780
- | `--ms-input-bg` | `var(--base-input-bg, #ffffff)` | Input background |
1781
- | `--ms-input-text` | `#111827` | Input text color |
1782
- | `--ms-input-border` | `#d1d5db` | Input border color |
1783
- | `--ms-input-focus-border-color` | `#3b82f6` | Border color when focused |
1784
- | `--ms-input-padding-v` | `0.5rem` | Input vertical padding |
1785
- | `--ms-input-padding-h` | `0.75rem` | Input horizontal padding |
1786
- | `--ms-input-font-size` | `0.875rem` | Input font size |
1787
- | `--ms-input-border-radius` | `0.375rem` | Input border radius |
1788
- | `--ms-input-placeholder-color` | `#6b7280` | Placeholder text color |
1789
-
1790
- #### Dropdown & Options
1791
-
1792
- | Variable | Default | Description |
1793
- |----------|---------|-------------|
1794
- | `--ms-dropdown-bg` | `var(--base-dropdown-bg, var(--base-elevated-bg, light-dark(#ffffff, #1a1a1a)))` | Dropdown background (auto-adapts to OS dark mode) |
1795
- | `--ms-dropdown-border` | `var(--ms-border-color)` | Dropdown border color |
1796
- | `--ms-dropdown-shadow` | (box shadow) | Dropdown shadow |
1797
- | `--ms-dropdown-max-height` | `20rem` | Max height of dropdown |
1798
- | `--ms-option-padding-v` | `0.5rem` | Option vertical padding |
1799
- | `--ms-option-padding-h` | `0.75rem` | Option horizontal padding |
1800
- | `--ms-option-hover-bg` | `#f9fafb` | Option background on hover |
1801
- | `--ms-option-color-hover` | `inherit` | Option text color on hover |
1802
- | `--ms-option-bg-selected` | (rgba accent) | Selected option background |
1803
- | `--ms-option-bg-focused` | `#f9fafb` | Focused option background (keyboard) |
1804
- | `--ms-option-color-focused` | `inherit` | Focused option text color |
1805
- | `--ms-option-bg-matched` | (accent 8%) | Matched option background (navigate mode) |
1806
- | `--ms-option-color-matched` | `inherit` | Matched option text color |
1807
-
1808
- #### Badges
1809
-
1810
- | Variable | Default | Description |
1811
- |----------|---------|-------------|
1812
- | `--ms-badge-text-bg` | `var(--ms-accent-color-light)` | Badge background color |
1813
- | `--ms-badge-text-color` | `var(--ms-accent-color)` | Badge text color |
1814
- | `--ms-badge-gap` | `0.5rem` | Gap between badges |
1815
- | `--ms-badge-height` | `1.5rem` | Height of badges |
1816
- | `--ms-badge-font-size` | `0.75rem` | Badge font size |
1817
- | `--ms-badge-border-radius` | `0.375rem` | Badge border radius |
1818
- | `--ms-badge-remove-bg` | `var(--ms-accent-color)` | Remove button background |
1819
- | `--ms-badge-remove-color` | `var(--ms-text-color-on-accent)` | Remove button (X) color — applied to the SVG via `currentColor` |
1820
- | `--ms-badge-remove-icon-size` | `calc(1.0 * var(--ms-rem))` | Size of the X glyph inside the remove button |
1821
- | `--ms-icon-remove` | (inline SVG `url(...)`) | The X mask SVG; override to swap the glyph shape (alpha-only — color comes from `--ms-badge-remove-color`) |
1822
- | `--ms-badge-counter-text-bg` | `var(--ms-primary-bg)` | BadgeCounter text background ("+X more") |
1823
- | `--ms-badge-counter-text-color` | `var(--ms-text-color-3)` | BadgeCounter text color |
1824
- | `--ms-badge-counter-remove-bg` | `var(--ms-text-color-3)` | BadgeCounter remove button background |
1825
- | `--ms-badge-counter-remove-color` | `var(--ms-text-color-on-accent)` | BadgeCounter remove button color |
1826
- | `--ms-badge-counter-border` | `1px solid var(--ms-border-color)` | BadgeCounter border |
1827
- | `--ms-badge-text-border` | `none` | Badge text border (e.g., `1px solid #3b82f6`) |
1828
-
1829
- #### Checkboxes
1830
-
1831
- | Variable | Default | Description |
1832
- |----------|---------|-------------|
1833
- | `--ms-checkbox-bg` | `var(--ms-input-bg)` | Checkbox background |
1834
- | `--ms-checkbox-border` | `1px solid var(--ms-border-color)` | Checkbox border |
1835
- | `--ms-checkbox-border-radius` | `0.3rem` | Checkbox border radius |
1836
- | `--ms-checkbox-checked-bg` | `var(--ms-accent-color)` | Background when checked |
1837
- | `--ms-checkbox-checked-border` | `1px solid var(--ms-accent-color)` | Border when checked |
1838
- | `--ms-checkbox-checkmark-color` | `var(--ms-text-color-on-accent)` | Checkmark color |
1839
- | `--ms-checkbox-hover-border-color` | `var(--ms-accent-color)` | Border on hover |
1840
- | `--ms-checkbox-disabled-bg` | `var(--ms-primary-bg)` | Disabled background |
1841
- | `--ms-checkbox-disabled-border` | `1px solid var(--ms-border-color)` | Disabled border |
1842
-
1843
- #### Scrollbar
1844
-
1845
- | Variable | Default | Description |
1846
- |----------|---------|-------------|
1847
- | `--ms-scrollbar-width` | `8px` | Scrollbar width |
1848
- | `--ms-scrollbar-track-bg` | `transparent` | Track background |
1849
- | `--ms-scrollbar-thumb-bg` | `var(--ms-border-color)` | Thumb color |
1850
- | `--ms-scrollbar-thumb-bg-hover` | `var(--ms-text-color-3)` | Thumb hover color |
1851
- | `--ms-scrollbar-thumb-border-radius` | `4px` | Thumb border radius |
1852
-
1853
- #### Counter (in input)
1854
-
1855
- | Variable | Default | Description |
1856
- |----------|---------|-------------|
1857
- | `--ms-counter-bg` | `#3b82f6` | Counter background |
1858
- | `--ms-counter-color` | `#ffffff` | Counter text color |
1859
- | `--ms-counter-font-size` | `0.75rem` | Counter font size |
1860
- | `--ms-counter-bg-hover` | `#2563eb` | Hover background color |
1861
-
1862
- #### Tooltips
1863
-
1864
- | Variable | Default | Description |
1865
- |----------|---------|-------------|
1866
- | `--ms-tooltip-bg` | `var(--base-tooltip-bg, var(--base-inverse-bg, light-dark(#333333, #f5f5f5)))` | Tooltip background (auto-adapts to OS dark mode) |
1867
- | `--ms-tooltip-color` | `var(--ms-tooltip-text-color)` | Tooltip text color |
1868
- | `--ms-tooltip-padding` | `0.5rem 0.75rem` | Tooltip padding |
1869
- | `--ms-tooltip-border-radius` | `0.375rem` | Tooltip border radius |
1870
- | `--ms-tooltip-font-size` | `0.875rem` | Tooltip font size |
1871
- | `--ms-tooltip-max-width` | `20rem` | Tooltip maximum width |
1872
- | `--ms-tooltip-shadow` | (box shadow) | Tooltip box shadow |
1873
- | `--ms-tooltip-z-index` | `10000` | Tooltip z-index |
1874
-
1875
- #### Typography
1876
-
1877
- | Variable | Default | Description |
1878
- |----------|---------|-------------|
1879
- | `--ms-font-size-xs` | `0.75rem` | Extra small font size |
1880
- | `--ms-font-size-sm` | `0.875rem` | Small font size |
1881
- | `--ms-font-size-base` | `1rem` | Base font size |
1882
- | `--ms-font-weight-medium` | `500` | Medium font weight |
1883
- | `--ms-font-weight-semibold` | `600` | Semibold font weight |
1884
-
1885
- #### Effects & Transitions
1886
-
1887
- | Variable | Default | Description |
1888
- |----------|---------|-------------|
1889
- | `--ms-transition-fast` | `150ms` | Fast transition duration |
1890
- | `--ms-transition-normal` | `200ms` | Normal transition duration |
1891
- | `--ms-easing-snappy` | (cubic-bezier) | Snappy easing function |
1892
- | `--ms-shadow-md` | (box shadow) | Medium shadow |
1893
- | `--ms-shadow-xl` | (box shadow) | Extra large shadow |
1894
- | `--ms-disabled-opacity` | `0.5` | Opacity for disabled state |
1895
-
1896
- ### Advanced: Direct CSS Import
1897
-
1898
- For users who want to import the raw CSS source files:
1899
-
1900
- ```css
1901
- /* Import component CSS directly */
1902
- @import '@keenmate/web-multiselect/css';
1903
-
1904
- /* Or import individual partials */
1905
- @import '@keenmate/web-multiselect/src/css/_variables.css';
1906
- @import '@keenmate/web-multiselect/src/css/_base.css';
1907
- /* ... etc */
1908
- ```
1909
-
1910
- ## Browser Support
1911
-
1912
- - Modern browsers with Web Components support
1913
- - Chrome/Edge 67+
1914
- - Firefox 63+
1915
- - Safari 10.1+
83
+ See [docs/usage.md](./docs/usage.md) for the full API and [docs/examples.md](./docs/examples.md) for advanced patterns (async data, virtual scrolling, custom rendering, form integration).
1916
84
 
1917
- ## SSR Compatibility
85
+ ## Browser support
1918
86
 
1919
- ⚠️ **Important for SSR frameworks (SvelteKit, Next.js, Nuxt, etc.):**
87
+ Modern evergreen browsers anything with native `customElements`, Shadow DOM, and CSS `@layer` support:
1920
88
 
1921
- This is a **client-side only** web component that uses Shadow DOM and browser APIs. While the module is safe to import during Server-Side Rendering (it won't crash), the component will only work in the browser.
89
+ - Chrome / Edge 99+
90
+ - Firefox 97+
91
+ - Safari 15.4+
1922
92
 
1923
- **The component automatically handles SSR compatibility** - no special configuration needed. However, be aware that:
1924
- - The component will not render during SSR
1925
- - It will only become interactive after hydration in the browser
1926
- - No special client-side import wrappers are required
93
+ No polyfills are shipped. SSR-safe: the module imports without crashing in Node, but renders only after hydration in the browser.
1927
94
 
1928
95
  ## Development
1929
96
 
@@ -1931,28 +98,23 @@ This is a **client-side only** web component that uses Shadow DOM and browser AP
1931
98
  # Install dependencies
1932
99
  npm install
1933
100
 
1934
- # Start dev server
101
+ # Start dev server (HMR)
1935
102
  npm run dev
1936
103
 
1937
104
  # Build for production
1938
105
  npm run build
1939
106
 
1940
- # Create package
107
+ # Create package tarball
1941
108
  npm run package
1942
109
  ```
1943
110
 
1944
111
  ## License
1945
112
 
1946
- Copyright (c) 2025 Keenmate
113
+ MIT — see [LICENSE](./LICENSE).
1947
114
 
1948
- This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
115
+ ## Built with BlissFramework
1949
116
 
1950
- **What this means:**
1951
- - ✅ Free to use in commercial products
1952
- - ✅ Free to modify and distribute
1953
- - ✅ No licensing fees or restrictions
1954
- - ⚠️ Provided "as is" without warranty
1955
- - 📝 Must include copyright notice in copies
117
+ Follows the [BlissFramework component guidelines](https://blissframework.dev/) for structure, theming, color-scheme, and accessibility. Per-check verifications run via `/validate-web-component`.
1956
118
 
1957
119
  ## Credits
1958
120