@keenmate/web-multiselect 1.12.0-rc03 → 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-rc03** — Two real bug fixes. (1) Declarative `<option>` / `<optgroup>` children now render correctly without needing `value-member` / `display-value-member` / `group-member` attributes previously every row rendered as `[N/A]`. (2) `select`, `deselect`, and `change` events are now dispatched with `bubbles: true, composed: true`, so Svelte 5 `onchange={fn}`, React `onChange`, Vue `@change`, and any other framework using delegated event routing work without reaching for the legacy `on:` directive. **Behavior change:** delegated form-level `change` listeners will now also receive our event filter by `e.target.tagName === 'WEB-MULTISELECT'` if your form library extracts state from every `change`. 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-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.
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 */
@@ -304,6 +328,9 @@ export declare class MultiSelectElement<T = any> extends BaseElement {
304
328
  private _renderSelectedContentCallback?;
305
329
  private _getCounterCallback?;
306
330
  private _actionButtons?;
331
+ private _batchDepth;
332
+ private _batchPartial;
333
+ private _batchNeedsReinit;
307
334
  private _beforeSearchCallback?;
308
335
  private _searchCallback?;
309
336
  private _addNewCallback?;
@@ -323,6 +350,20 @@ export declare class MultiSelectElement<T = any> extends BaseElement {
323
350
  connectedCallback(): void;
324
351
  disconnectedCallback(): void;
325
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;
326
367
  private render;
327
368
  private renderDebugInfo;
328
369
  private updateDebugInfo;
@@ -419,8 +460,8 @@ export declare class MultiSelectElement<T = any> extends BaseElement {
419
460
  get getCounterCallback(): ((count: number, moreCount?: number) => string) | undefined;
420
461
  get beforeSearchCallback(): ((searchTerm: string) => string | null) | undefined;
421
462
  set beforeSearchCallback(callback: ((searchTerm: string) => string | null) | undefined);
422
- get searchCallback(): ((searchTerm: string) => Promise<T[]>) | undefined;
423
- 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);
424
465
  get addNewCallback(): ((value: string) => T | Promise<T>) | undefined;
425
466
  set addNewCallback(callback: ((value: string) => T | Promise<T>) | undefined);
426
467
  get selectCallback(): ((option: T) => void) | undefined;
@@ -478,7 +519,7 @@ export declare interface MultiSelectOption {
478
519
  */
479
520
  export declare interface MultiSelectOptions extends MultiSelectConfig<MultiSelectOption> {
480
521
  options?: MultiSelectOption[];
481
- searchCallback?: ((searchTerm: string) => Promise<MultiSelectOption[]>) | null;
522
+ searchCallback?: ((searchTerm: string, signal?: AbortSignal) => Promise<MultiSelectOption[]>) | null;
482
523
  addNewCallback?: ((value: string) => MultiSelectOption | Promise<MultiSelectOption>) | null;
483
524
  selectCallback?: ((option: MultiSelectOption) => void) | null;
484
525
  deselectCallback?: ((option: MultiSelectOption) => void) | null;
@@ -548,6 +589,8 @@ export declare class WebMultiSelect<T = any> {
548
589
  private matchingIndices;
549
590
  private searchTerm;
550
591
  private isLoading;
592
+ private searchDebounceTimer?;
593
+ private searchAbortController?;
551
594
  private showSelectedPopover;
552
595
  private selectedPopoverPlacement;
553
596
  private dropdownPlacement;
@@ -624,9 +667,31 @@ export declare class WebMultiSelect<T = any> {
624
667
  private renderOption;
625
668
  private highlightMatch;
626
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;
627
678
  private renderBadges;
628
679
  private attachEvents;
629
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;
630
695
  private handleKeydown;
631
696
  private handleDropdownClick;
632
697
  private handleBadgeClick;