@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/README.md CHANGED
@@ -21,11 +21,17 @@ Reads `--base-*` variables from the page if [`@keenmate/theme-designer`](https:/
21
21
  - Custom rendering callbacks for options, badges, and group headers.
22
22
  - Form integration via standard hidden inputs (FormData-compatible).
23
23
 
24
- ## What's new
24
+ ## What's New in v1.12.0-rc04
25
25
 
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.
26
+ - **Placeholders — context-aware wording for pickers and empty cascades** — The input used to show `"Search..."` unconditionally, even when search was off or there was nothing to pick. Two new attributes fix that. `select-placeholder` (default `"Pick an option..."`) is shown when the input isn't a usable search box — `enable-search="false"`, or `search-input-mode="readonly"`/`"hidden"` so a non-searchable picker stops mislabeling itself. `no-data-placeholder` is an opt-in string shown when the option list is empty, letting users see there's nothing to choose without opening the dropdown; it targets cascade multiselects whose parent isn't resolved yet, and stays unset by default so async-loaded selects don't flash an empty-state before their data lands. Placeholder resolution is now recomputed on live updates too, so changing these attributes, the search mode, or the option list updates the visible text on the fly. **Behavior change:** search-disabled instances without a custom placeholder now read `"Pick an option..."` — set `select-placeholder="Search..."` to keep the old text.
27
+ - **setAttributes() — batch attribute updates in a single render** — Setting attributes one at a time triggered a full re-render per call. The new `setAttributes(attrs)` method on `<web-multiselect>` applies a whole map of attributes in one in-place update (or a single reinit at most, if any change is structural). Keys are kebab-case attribute names, exactly like `setAttribute`; a value of `null`/`undefined`/`false` removes the attribute and `true` sets it to `""`. It's particularly handy for i18n language switches that swap several placeholder strings at once without flicker.
28
+ - **search-debounce — coalesce async search into one request** — A new `search-debounce` attribute (milliseconds, default `0`) debounces the async `searchCallback` so a burst of keystrokes collapses into a single request instead of one per character. Each keystroke resets the timer, and it applies to the async `searchCallback` path only — local in-memory filtering stays instant. The existing stale-result guard (results applied only if the term still matches) remains as a second line of defense against out-of-order responses. The examples page §8 now ships a "Debounced Search" demo with a live keystrokes-vs-API-calls counter that makes the coalescing obvious.
29
+ - **searchCallback AbortSignal — cancel superseded in-flight requests** — `searchCallback` now receives an `AbortSignal` as its second argument: `(searchTerm, signal) => Promise<options[]>`. When a newer search supersedes an in-flight one — or the term drops below `min-search-length`, or the component is destroyed — the previous request's signal is aborted; forward it into `fetch(url, { signal })` to actually cancel the network call. It's backward compatible: the argument is optional, and existing callbacks that ignore it keep working with their stale results discarded as before. Aborted or superseded responses never overwrite the live filtered options.
27
30
 
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.
31
+ ## What's New in v1.12.0-rc03
32
+
33
+ - **Declarative options — `<option>` children render without member attributes** — Declarative `<option>` / `<optgroup>` markup previously required `value-member` / `display-value-member` / `group-member` to be set, or every row rendered as `[N/A]`. The element now auto-wires those member properties (plus `icon`/`subtitle`/`disabled`) when declarative children are detected — only when the consumer hasn't already configured them — so markup-only usage works out of the box while every override path still wins.
34
+ - **Framework events — bubbling + composed dispatch** — `select`, `deselect`, and `change` are now dispatched with `bubbles: true, composed: true`, so Svelte 5 `onchange={fn}`, React `onChange`, Vue `@change`, and any framework using delegated event routing work without the legacy `on:` directive. **Behavior change:** delegated form-level `change` listeners now also receive the component's event — filter by `e.target.tagName === 'WEB-MULTISELECT'` if your form library reacts to every `change`.
29
35
 
30
36
  > ⚠️ **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.
31
37
 
@@ -106,6 +112,11 @@ npm run build
106
112
 
107
113
  # Create package tarball
108
114
  npm run package
115
+
116
+ # Run tests
117
+ npm run test:unit # Vitest (happy-dom) — fast logic checks
118
+ npm run test:e2e # Playwright (browser) — interaction/visual
119
+ npm test # both
109
120
  ```
110
121
 
111
122
  ## License
package/dist/index.d.ts CHANGED
@@ -196,8 +196,20 @@ declare interface MultiSelectConfig<T = any> {
196
196
  checkboxAlign?: 'top' | 'center' | 'bottom';
197
197
  /** Hint text shown above the input while the dropdown is open. */
198
198
  searchHint?: string;
199
- /** Placeholder text for the search input */
199
+ /** Placeholder text for the search input (shown while search is usable) */
200
200
  searchPlaceholder?: string;
201
+ /**
202
+ * Placeholder shown when search is disabled (input acts as a picker rather than a search box).
203
+ * Applies when `isSearchEnabled` is false or `searchInputMode` is 'readonly'/'hidden'.
204
+ * Default: "Pick an option..."
205
+ */
206
+ selectPlaceholder?: string;
207
+ /**
208
+ * Placeholder shown when there are no options to choose from (e.g. an unresolved cascade parent).
209
+ * Opt-in: when unset, the normal search/select placeholder is used even with an empty list.
210
+ * Lets users see there is no data without opening the dropdown.
211
+ */
212
+ noDataPlaceholder?: string;
201
213
  /** Minimum width for the dropdown (e.g., '20rem', '300px') */
202
214
  dropdownMinWidth?: string | null;
203
215
  /** Maximum width for the dropdown (e.g., '40rem', '500px') */
@@ -226,6 +238,13 @@ declare interface MultiSelectConfig<T = any> {
226
238
  badgesMaxVisible?: number | null;
227
239
  /** Minimum search length before loading data */
228
240
  minSearchLength?: number;
241
+ /**
242
+ * Debounce delay in milliseconds before the async `searchCallback` is invoked.
243
+ * Each keystroke resets the timer, so only the last input in a burst fires a request.
244
+ * Applies to the async `searchCallback` path only — local in-memory filtering stays instant.
245
+ * Default: 0 (no debounce — callback runs on every keystroke).
246
+ */
247
+ searchDebounce?: number;
229
248
  /** Minimum items before virtual scroll activates (default: 100) */
230
249
  virtualScrollThreshold?: number;
231
250
  /** Fixed height for each option in pixels (required for virtual scroll, default: 50) */
@@ -236,8 +255,13 @@ declare interface MultiSelectConfig<T = any> {
236
255
  virtualScrollBuffer?: number;
237
256
  /** Pre-process search term before calling searchCallback. Return null to prevent search. Use for accent removal, validation, etc. */
238
257
  beforeSearchCallback?: ((searchTerm: string) => string | null) | null;
239
- /** Async function to load data: (searchTerm) => Promise<options[]> */
240
- searchCallback?: ((searchTerm: string) => Promise<T[]>) | null;
258
+ /**
259
+ * Async function to load data: `(searchTerm, signal) => Promise<options[]>`.
260
+ * The optional second argument is an `AbortSignal` that fires when a newer search
261
+ * supersedes this one (or the component is destroyed). Wire it into your `fetch`
262
+ * to cancel the in-flight request; ignoring it is fine — stale results are discarded.
263
+ */
264
+ searchCallback?: ((searchTerm: string, signal?: AbortSignal) => Promise<T[]>) | null;
241
265
  /** Callback to add a new option when isAddNewAllowed is true */
242
266
  addNewCallback?: ((value: string) => T | Promise<T>) | null;
243
267
  /** Callback when an option is selected */
@@ -275,6 +299,7 @@ export declare class MultiSelectElement<T = any> extends BaseElement {
275
299
  private shadow;
276
300
  private internals?;
277
301
  private _options?;
302
+ private _hasDeclarativeOptions;
278
303
  private _valueMember?;
279
304
  private _getValueCallback?;
280
305
  private _displayValueMember?;
@@ -303,6 +328,9 @@ export declare class MultiSelectElement<T = any> extends BaseElement {
303
328
  private _renderSelectedContentCallback?;
304
329
  private _getCounterCallback?;
305
330
  private _actionButtons?;
331
+ private _batchDepth;
332
+ private _batchPartial;
333
+ private _batchNeedsReinit;
306
334
  private _beforeSearchCallback?;
307
335
  private _searchCallback?;
308
336
  private _addNewCallback?;
@@ -322,6 +350,20 @@ export declare class MultiSelectElement<T = any> extends BaseElement {
322
350
  connectedCallback(): void;
323
351
  disconnectedCallback(): void;
324
352
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
353
+ /**
354
+ * Set several attributes in one in-place update — a single re-render instead of one per
355
+ * attribute (and a single reinit at most if any change is structural). Keys are attribute
356
+ * names in kebab-case, exactly as `setAttribute`. A value of `null`/`undefined`/`false`
357
+ * removes the attribute; `true` sets it to an empty string; anything else is stringified.
358
+ *
359
+ * @example
360
+ * el.setAttributes({
361
+ * 'search-placeholder': t('search'),
362
+ * 'select-placeholder': t('pick'),
363
+ * 'no-data-placeholder': t('noData'),
364
+ * });
365
+ */
366
+ setAttributes(attrs: Record<string, string | number | boolean | null | undefined>): void;
325
367
  private render;
326
368
  private renderDebugInfo;
327
369
  private updateDebugInfo;
@@ -418,8 +460,8 @@ export declare class MultiSelectElement<T = any> extends BaseElement {
418
460
  get getCounterCallback(): ((count: number, moreCount?: number) => string) | undefined;
419
461
  get beforeSearchCallback(): ((searchTerm: string) => string | null) | undefined;
420
462
  set beforeSearchCallback(callback: ((searchTerm: string) => string | null) | undefined);
421
- get searchCallback(): ((searchTerm: string) => Promise<T[]>) | undefined;
422
- set searchCallback(callback: ((searchTerm: string) => Promise<T[]>) | undefined);
463
+ get searchCallback(): ((searchTerm: string, signal?: AbortSignal) => Promise<T[]>) | undefined;
464
+ set searchCallback(callback: ((searchTerm: string, signal?: AbortSignal) => Promise<T[]>) | undefined);
423
465
  get addNewCallback(): ((value: string) => T | Promise<T>) | undefined;
424
466
  set addNewCallback(callback: ((value: string) => T | Promise<T>) | undefined);
425
467
  get selectCallback(): ((option: T) => void) | undefined;
@@ -477,7 +519,7 @@ export declare interface MultiSelectOption {
477
519
  */
478
520
  export declare interface MultiSelectOptions extends MultiSelectConfig<MultiSelectOption> {
479
521
  options?: MultiSelectOption[];
480
- searchCallback?: ((searchTerm: string) => Promise<MultiSelectOption[]>) | null;
522
+ searchCallback?: ((searchTerm: string, signal?: AbortSignal) => Promise<MultiSelectOption[]>) | null;
481
523
  addNewCallback?: ((value: string) => MultiSelectOption | Promise<MultiSelectOption>) | null;
482
524
  selectCallback?: ((option: MultiSelectOption) => void) | null;
483
525
  deselectCallback?: ((option: MultiSelectOption) => void) | null;
@@ -547,6 +589,8 @@ export declare class WebMultiSelect<T = any> {
547
589
  private matchingIndices;
548
590
  private searchTerm;
549
591
  private isLoading;
592
+ private searchDebounceTimer?;
593
+ private searchAbortController?;
550
594
  private showSelectedPopover;
551
595
  private selectedPopoverPlacement;
552
596
  private dropdownPlacement;
@@ -623,9 +667,31 @@ export declare class WebMultiSelect<T = any> {
623
667
  private renderOption;
624
668
  private highlightMatch;
625
669
  private groupOptions;
670
+ /** Whether the input currently functions as a usable search field (drives placeholder wording). */
671
+ private get isSearchUsable();
672
+ /**
673
+ * Resolve the closed-state input placeholder for the current data/search state.
674
+ * Priority: explicit no-data placeholder (when the list is empty) → "pick" prompt when
675
+ * search is unusable → the search placeholder.
676
+ */
677
+ private getPlaceholderText;
626
678
  private renderBadges;
627
679
  private attachEvents;
628
680
  private handleSearch;
681
+ /** Abort the search request currently in flight, if any. The aborted request's results
682
+ * are then ignored (and the consumer's `searchCallback` can short-circuit its fetch via
683
+ * the `AbortSignal` it was handed). */
684
+ private abortInFlightSearch;
685
+ /**
686
+ * Invoke the async `searchCallback` and apply its results. Split out of `handleSearch`
687
+ * so it can be called immediately or after the debounce timer.
688
+ *
689
+ * Any request still in flight is aborted before a new one starts, so a slow earlier
690
+ * request can't overwrite a newer one — and consumers that wire the passed `AbortSignal`
691
+ * into their fetch get the request actually cancelled, not just ignored. The
692
+ * `aborted` / `searchTerm === value` guards drop superseded or out-of-order responses.
693
+ */
694
+ private performAsyncSearch;
629
695
  private handleKeydown;
630
696
  private handleDropdownClick;
631
697
  private handleBadgeClick;