@keenmate/web-multiselect 1.12.0-rc02 → 1.12.0-rc04

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/usage.md ADDED
@@ -0,0 +1,311 @@
1
+ # Usage & API reference
2
+
3
+ This document covers the public surface of `<web-multiselect>` — declarative HTML usage, the full attribute / property / method / event tables, and the data shape.
4
+
5
+ ## Declarative (no JavaScript)
6
+
7
+ Perfect for simple forms — just use standard HTML `<option>` and `<optgroup>` elements:
8
+
9
+ ```html
10
+ <!-- Simple choice -->
11
+ <web-multiselect multiple="false">
12
+ <option value="yes">Yes</option>
13
+ <option value="no">No</option>
14
+ <option value="maybe" selected>Maybe</option>
15
+ </web-multiselect>
16
+
17
+ <!-- With icons -->
18
+ <web-multiselect>
19
+ <option value="apple" data-icon="🍎">Apple</option>
20
+ <option value="banana" data-icon="🍌" selected>Banana</option>
21
+ <option value="orange" data-icon="🍊">Orange</option>
22
+ </web-multiselect>
23
+
24
+ <!-- With groups -->
25
+ <web-multiselect>
26
+ <optgroup label="Frontend">
27
+ <option value="js" data-icon="🟨">JavaScript</option>
28
+ <option value="ts" data-icon="🔷">TypeScript</option>
29
+ </optgroup>
30
+ <optgroup label="Backend">
31
+ <option value="python" data-icon="🐍" selected>Python</option>
32
+ <option value="java" data-icon="☕">Java</option>
33
+ </optgroup>
34
+ </web-multiselect>
35
+ ```
36
+
37
+ ## Programmatic (with JavaScript)
38
+
39
+ For dynamic data and advanced features:
40
+
41
+ ```html
42
+ <!-- Multi-select -->
43
+ <web-multiselect
44
+ id="my-select"
45
+ search-placeholder="Search options..."
46
+ initial-values='["js","ts"]'>
47
+ </web-multiselect>
48
+ ```
49
+
50
+ ```typescript
51
+ // Import the component (includes styles)
52
+ import '@keenmate/web-multiselect';
53
+
54
+ // Or import styles separately if needed
55
+ import '@keenmate/web-multiselect/style.css';
56
+
57
+ const multiselect = document.querySelector('web-multiselect');
58
+
59
+ // Set options programmatically
60
+ multiselect.options = [
61
+ { value: 'js', label: 'JavaScript', icon: '🟨' },
62
+ { value: 'ts', label: 'TypeScript', icon: '🔷' },
63
+ { value: 'py', label: 'Python', icon: '🐍' }
64
+ ];
65
+
66
+ // Listen for events
67
+ multiselect.addEventListener('change', (e) => {
68
+ console.log('Selected:', e.detail.selectedOptions);
69
+ console.log('Values:', e.detail.selectedValues);
70
+ });
71
+
72
+ // Public API
73
+ const selected = multiselect.getSelected();
74
+ multiselect.setSelected(['js', 'ts']);
75
+ ```
76
+
77
+ ## Attributes
78
+
79
+ | Attribute | Type | Default | Description |
80
+ |-----------|------|---------|-------------|
81
+ | `multiple` | `boolean` | `true` | Allow multiple selections |
82
+ | `search-placeholder` | `string` | `'Search...'` | Placeholder text shown while search is usable |
83
+ | `select-placeholder` | `string` | `'Pick an option...'` | Placeholder shown when search is disabled (`enable-search="false"`, or `search-input-mode` `readonly`/`hidden`) — the input acts as a picker, not a search box |
84
+ | `no-data-placeholder` | `string` | - | Opt-in placeholder shown when the option list is empty, so users see there's no data without opening. Highest priority when the list is empty. Useful for cascade multiselects (a child whose parent isn't resolved yet) |
85
+ | `search-hint` | `string` | - | Hint text shown above input when focused |
86
+ | `allow-groups` | `boolean` | `true` | Enable option grouping |
87
+ | `show-checkboxes` | `boolean` | `true` | Show checkboxes next to options |
88
+ | `close-on-select` | `boolean` | `false` | Close dropdown after selecting |
89
+ | `dropdown-min-width` | `string` | - | Min width for dropdown (e.g., `'20rem'`) |
90
+ | `badges-display-mode` | `'pills' \| 'count' \| 'compact' \| 'partial' \| 'none'` | `'pills'` | How to display selected items. `compact`: first item + count. `none`: no display |
91
+ | `badges-threshold` | `number` | - | Auto-switch mode when exceeded (see `badges-threshold-mode`) |
92
+ | `badges-threshold-mode` | `'count' \| 'partial'` | `'count'` | Mode after threshold: `count` shows badge, `partial` shows limited badges + more badge |
93
+ | `badges-max-visible` | `number` | `3` | Max badges shown in partial mode |
94
+ | `badges-position` | `'top' \| 'bottom' \| 'left' \| 'right'` | `'bottom'` | Position of badges container |
95
+ | `show-counter` | `boolean` | `false` | Show `[3]` badge next to toggle icon |
96
+ | `enable-badge-tooltips` | `boolean` | `false` | Enable tooltips on selected badges |
97
+ | `badge-tooltip-placement` | `'top' \| 'bottom' \| 'left' \| 'right'` | `'top'` | Tooltip placement relative to badge |
98
+ | `badge-tooltip-delay` | `number` | `100` | Delay in ms before showing tooltip |
99
+ | `badge-tooltip-offset` | `number` | `8` | Distance in pixels between badge and tooltip |
100
+ | `max-height` | `string` | `'20rem'` | Maximum height of dropdown |
101
+ | `empty-message` | `string` | `'No results found'` | Message when no options found |
102
+ | `loading-message` | `string` | `'Loading...'` | Message while loading async data |
103
+ | `min-search-length` | `number` | `0` | Minimum search length for async |
104
+ | `search-debounce` | `number` | `0` | Debounce (ms) before the async `searchCallback` fires; coalesces keystroke bursts into one request. Resets on each keystroke. Async callback only — local filtering stays instant. `0` = no debounce |
105
+ | `keep-options-on-search` | `boolean` | `true` | Keep initial options visible when `searchCallback` is active (hybrid search) |
106
+ | `should-keep-search-on-close` | `boolean` | `true` | Preserve search text and filtered results when dropdown closes |
107
+ | `sticky-actions` | `boolean` | `true` | Keep action buttons fixed at top while scrolling |
108
+ | `actions-layout` | `'nowrap' \| 'wrap'` | `'nowrap'` | Layout mode for action buttons: `nowrap` (single row) or `wrap` (multi-row) |
109
+ | `lock-placement` | `boolean` | `true` | Lock dropdown placement after first open to prevent flipping |
110
+ | `enable-search` | `boolean` | `true` | Enable/disable search functionality |
111
+ | `search-input-mode` | `'normal' \| 'readonly' \| 'hidden'` | `'normal'` | Search input display mode |
112
+ | `search-mode` | `'filter' \| 'navigate'` | `'filter'` | Search behavior: `filter` hides non-matches, `navigate` jumps to matches |
113
+ | `allow-add-new` | `boolean` | `false` | Allow adding new options not in the list |
114
+ | `value-member` | `string` | - | Property name for value/ID extraction from custom objects |
115
+ | `display-value-member` | `string` | - | Property name for display text extraction from custom objects |
116
+ | `search-value-member` | `string` | - | Property name for search text extraction from custom objects |
117
+ | `icon-member` | `string` | - | Property name for icon extraction from custom objects |
118
+ | `subtitle-member` | `string` | - | Property name for subtitle extraction from custom objects |
119
+ | `group-member` | `string` | - | Property name for group extraction from custom objects |
120
+ | `disabled-member` | `string` | - | Property name for disabled state extraction from custom objects |
121
+ | `name` | `string` | - | HTML form field name for form integration (creates hidden input) |
122
+ | `value-format` | `'json' \| 'csv' \| 'array'` | `'json'` | Format for form value serialization |
123
+ | `initial-values` | `string` (JSON array) | - | Pre-selected values |
124
+ | `enable-virtual-scroll` | `boolean` | `false` | Enable virtual scrolling for large datasets |
125
+ | `virtual-scroll-threshold` | `number` | `100` | Minimum items before virtual scroll activates |
126
+ | `option-height` | `number` | `50` | Fixed height for each option in pixels (required for virtual scroll) |
127
+ | `virtual-scroll-buffer` | `number` | `10` | Buffer size — extra items rendered above/below viewport |
128
+
129
+ ## Properties
130
+
131
+ ```typescript
132
+ // Get/set options
133
+ multiselect.options = [
134
+ { value: 'js', label: 'JavaScript' },
135
+ { value: 'ts', label: 'TypeScript' }
136
+ ];
137
+
138
+ // Async data loading
139
+ multiselect.onSearch = async (searchTerm) => {
140
+ const response = await fetch(`/api/search?q=${searchTerm}`);
141
+ return await response.json();
142
+ };
143
+
144
+ // Pre-process search terms before calling searchCallback
145
+ multiselect.beforeSearchCallback = (searchTerm) => {
146
+ // Remove accents: "café" → "cafe"
147
+ const normalized = searchTerm.normalize('NFD').replace(/[̀-ͯ]/g, '');
148
+
149
+ // Block search if too short (return null to prevent search)
150
+ if (normalized.length < 2) return null;
151
+
152
+ return normalized; // Return transformed term
153
+ };
154
+
155
+ // Event callbacks
156
+ multiselect.onSelect = (option) => {
157
+ console.log('Selected:', option);
158
+ };
159
+
160
+ multiselect.onDeselect = (option) => {
161
+ console.log('Deselected:', option);
162
+ };
163
+
164
+ multiselect.onChange = (selectedOptions) => {
165
+ console.log('Changed:', selectedOptions);
166
+ };
167
+
168
+ // Badge display customization (show different text in badges vs dropdown)
169
+ multiselect.getBadgeDisplayCallback = (item) => {
170
+ // Show shorter text in badges (e.g., just name instead of "name (email)")
171
+ return item.name; // Dropdown might show "John Doe (john@example.com)"
172
+ };
173
+
174
+ // Badge tooltip customization
175
+ multiselect.getBadgeTooltipCallback = (item) => {
176
+ return `${item.label} - ${item.subtitle}`;
177
+ };
178
+
179
+ // Action buttons (Select All, Clear All, custom actions)
180
+ multiselect.actionButtons = [
181
+ {
182
+ action: 'select-all',
183
+ text: 'Select All',
184
+ tooltip: 'Select all items',
185
+ cssClass: 'my-custom-class',
186
+ isVisibleCallback: (multiselect) => multiselect.getSelected().length < 5 // Hide if 5+ selected
187
+ },
188
+ {
189
+ action: 'clear-all',
190
+ text: 'Clear All',
191
+ tooltip: 'Clear selection',
192
+ isVisible: true, // Static visibility
193
+ isDisabled: false // Static disabled state
194
+ },
195
+ {
196
+ action: 'custom',
197
+ text: 'Invert',
198
+ tooltip: 'Invert selection',
199
+ onClick: (multiselect) => {
200
+ // Custom action - invert selection
201
+ const allValues = multiselect.options.map(opt => opt.value);
202
+ const selectedValues = multiselect.getValue();
203
+ const inverted = allValues.filter(v => !selectedValues.includes(v));
204
+ multiselect.setSelected(inverted);
205
+ },
206
+ // Dynamic callbacks (take priority over static properties)
207
+ isDisabledCallback: (multiselect) => multiselect.getSelected().length === 0,
208
+ getTextCallback: (multiselect) => multiselect.getSelected().length > 0 ? 'Invert' : 'Select Items First',
209
+ getClassCallback: (multiselect) => multiselect.getSelected().length > 0 ? 'active' : 'inactive'
210
+ }
211
+ ];
212
+
213
+ // Counter i18n/pluralization
214
+ multiselect.getCounterCallback = (count, moreCount) => {
215
+ if (moreCount !== undefined) {
216
+ return `+${moreCount} more`; // Partial mode badge
217
+ }
218
+ return `${count} selected`; // Count mode display
219
+ };
220
+
221
+ // Data extraction - Member properties (for simple property names)
222
+ multiselect.valueMember = 'id';
223
+ multiselect.displayValueMember = 'name';
224
+ multiselect.iconMember = 'icon';
225
+ multiselect.subtitleMember = 'description';
226
+ multiselect.groupMember = 'category';
227
+ multiselect.disabledMember = 'isDisabled';
228
+
229
+ // Data extraction - Callback functions (for complex logic)
230
+ multiselect.getValueCallback = (item) => item.id || item.value;
231
+ multiselect.getDisplayValueCallback = (item) => item.label || item.name;
232
+ multiselect.getSearchValueCallback = (item) => `${item.name} ${item.tags.join(' ')}`;
233
+ multiselect.getIconCallback = (item) => item.icon || '📄';
234
+ multiselect.getSubtitleCallback = (item) => `${item.price} - ${item.stock} in stock`;
235
+ multiselect.getGroupCallback = (item) => item.category;
236
+ multiselect.getDisabledCallback = (item) => item.stock === 0;
237
+
238
+ // Custom rendering - Full HTML control
239
+ multiselect.renderGroupLabelContentCallback = (groupName) => {
240
+ return `<strong>📦 ${groupName.toUpperCase()}</strong>`;
241
+ };
242
+
243
+ multiselect.renderOptionContentCallback = (item, context) => {
244
+ return `<strong>${item.name}</strong> <span class="badge">${item.status}</span>`;
245
+ };
246
+
247
+ multiselect.renderBadgeContentCallback = (item, context) => {
248
+ return context.isInPopover
249
+ ? `${item.icon} ${item.name} - ${item.description}`
250
+ : `${item.icon} ${item.name}`;
251
+ };
252
+
253
+ multiselect.renderSelectedContentCallback = (item) => {
254
+ // Customize selected item text in single-select mode (plain text only)
255
+ return item.firstName;
256
+ };
257
+
258
+ // Form integration
259
+ multiselect.name = 'selected_items';
260
+ multiselect.valueFormat = 'json'; // 'json' | 'csv' | 'array'
261
+ multiselect.getValueFormatCallback = (values) => values.join('|'); // Custom format
262
+
263
+ // Read-only properties
264
+ const selectedValue = multiselect.selectedValue; // string | number | array | null
265
+ const selectedItem = multiselect.selectedItem; // First selected item object
266
+
267
+ // Add new option callback
268
+ multiselect.addNewCallback = async (value) => {
269
+ const newOption = await fetch('/api/options', {
270
+ method: 'POST',
271
+ body: JSON.stringify({ name: value })
272
+ }).then(r => r.json());
273
+ return newOption;
274
+ };
275
+ ```
276
+
277
+ ## Methods
278
+
279
+ | Method | Description |
280
+ |--------|-------------|
281
+ | `getSelected()` | Get currently selected options as array of option objects |
282
+ | `setSelected(values: (string \| number)[])` | Set selected values by ID/value |
283
+ | `getValue()` | Get selected value(s) — returns single value in single-select mode, array in multi-select mode |
284
+ | `setAttributes(attrs: Record<string, string \| number \| boolean \| null>)` | Set several attributes in a single in-place update (one re-render instead of one per attribute). Keys are kebab-case attribute names, as `setAttribute`; `null`/`undefined`/`false` removes the attribute, `true` sets it to `""`. Handy for i18n switches that change multiple strings at once |
285
+ | `destroy()` | Clean up and destroy instance |
286
+
287
+ ## Events
288
+
289
+ | Event | Detail | Description |
290
+ |-------|--------|-------------|
291
+ | `select` | `{ option, selectedOptions }` | Fired when an option is selected |
292
+ | `deselect` | `{ option, selectedOptions }` | Fired when an option is deselected |
293
+ | `change` | `{ selectedOptions, selectedValues }` | Fired when selection changes |
294
+
295
+ ## Option structure
296
+
297
+ ```typescript
298
+ interface MultiSelectOption {
299
+ value: string; // Required: Unique identifier
300
+ label: string; // Required: Display text
301
+ icon?: string; // Optional: Icon or emoji
302
+ subtitle?: string; // Optional: Subtitle/description
303
+ group?: string; // Optional: Group name
304
+ disabled?: boolean; // Optional: Disable selection
305
+ }
306
+ ```
307
+
308
+ The component also accepts:
309
+
310
+ - **Tuple arrays** like `[['js', 'JavaScript'], ['ts', 'TypeScript']]` — first element becomes value, second becomes display text.
311
+ - **Arbitrary custom objects** via member attributes (`value-member`, `display-value-member`, etc.) or callbacks (`getValueCallback`, `getDisplayValueCallback`, etc.). See [examples.md → Flexible Data Handling](./examples.md#flexible-data-handling).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keenmate/web-multiselect",
3
- "version": "1.12.0-rc02",
3
+ "version": "1.12.0-rc04",
4
4
  "description": "Lightweight multiselect web component with typeahead search, rich content support, and excellent keyboard navigation",
5
5
  "type": "module",
6
6
  "main": "./dist/multiselect.umd.js",
@@ -30,6 +30,10 @@
30
30
  "files": [
31
31
  "dist",
32
32
  "src/css",
33
+ "docs/usage.md",
34
+ "docs/theming.md",
35
+ "docs/examples.md",
36
+ "docs/accessibility.md",
33
37
  "component-variables.manifest.json"
34
38
  ],
35
39
  "scripts": {
@@ -41,6 +45,9 @@
41
45
  "package": "npm run build && npm pack",
42
46
  "clean": "rimraf dist --glob \"*.tgz\"",
43
47
  "clean:dist": "rimraf dist",
48
+ "test": "npm run test:unit && npm run test:e2e",
49
+ "test:unit": "vitest run",
50
+ "test:unit:watch": "vitest",
44
51
  "test:e2e": "playwright test",
45
52
  "test:e2e:ui": "playwright test --ui",
46
53
  "test:e2e:headed": "playwright test --headed",
@@ -71,10 +78,12 @@
71
78
  },
72
79
  "devDependencies": {
73
80
  "@playwright/test": "^1.60.0",
81
+ "happy-dom": "^15.11.7",
74
82
  "rimraf": "^5.0.5",
75
83
  "typescript": "^5.3.3",
76
84
  "vite": "^5.0.8",
77
- "vite-plugin-dts": "^4.5.4"
85
+ "vite-plugin-dts": "^4.5.4",
86
+ "vitest": "^2.1.9"
78
87
  },
79
88
  "dependencies": {
80
89
  "@floating-ui/dom": "^1.5.3"