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

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,25 @@ 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-rc05
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
+ > ⚠️ **Breaking change event-handler rename, no aliases.** The fire-and-forget notification callbacks are renamed to `on*` with **no backward-compatible aliases**: `selectCallback` `onSelect`, `deselectCallback` `onDeselect`, `changeCallback` `onChange`, and on `ActionButton`, `isVisibleCallback` / `isDisabledCallback` `getIsVisibleCallback` / `getIsDisabledCallback`. If you assign these as element properties or config keys, **rename them or your handlers silently stop firing**. The `select` / `deselect` / `change` **DOM events are unchanged**, so `addEventListener(...)` consumers are unaffected. Full rationale in the first bullet below.
27
27
 
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.
28
+ - **Events — fire-and-forget callbacks renamed to `on*` (breaking)** — `selectCallback` / `deselectCallback` / `changeCallback` are now `onSelect` / `onDeselect` / `onChange`, and on `ActionButton` the predicate callbacks become `getIsVisibleCallback` / `getIsDisabledCallback` while `onClick` keeps its name. The rule is simple: a function whose return value the component ignores is an *event* and takes the `on*` shape; functions whose return value is consumed stay `get*Callback` / `before*Callback`. The bubbling `select` / `deselect` / `change` DOM events are unchanged — the `on*` properties are just their JS-property twins. There are no deprecated aliases, so update `el.selectCallback` `el.onSelect` (and the matching config keys); `addEventListener('select' | 'deselect' | 'change', …)` is unaffected.
29
+ - **Selection — new `beforeSelectCallback` / `beforeDeselectCallback` interceptors** — veto a selection or deselection before it happens by returning `false`; each receives the option being toggled plus the current selection. The veto is silent — a blocked action mutates no state and fires no event, so your handler owns any feedback. The deselect veto is enforced across *every* interactive removal path (dropdown toggle, badge `×`, selected-items popover, and the "remove hidden" badge) by routing them through a single funnel, so no affordance can bypass it; programmatic `setSelected()` and the Select-All / Clear-All buttons deliberately skip it. Typical uses: mutually-exclusive options, or a required item that can't be removed.
30
+ - **Dropdown options — hover tooltips, independently configurable** — option rows can now show a hover tooltip via `enable-option-tooltips`, built on the same Floating-UI `Tooltip` engine as the badge and action tooltips (show/hide delays, plus re-attachment as virtual-scrolled rows recycle into view). Default content is the option's display value with its subtitle on a second line; override per option with `getOptionTooltipCallback`. Unlike the badge tooltips they share an engine with, option tooltips are fully independent: `option-tooltip-placement` defaults to `top-start` so the tooltip anchors to a wide row's start edge instead of centering in the middle of a 1920px-wide row, `option-tooltip-follow-cursor` makes it track the pointer, and a dedicated `.ms__option-tooltip` class plus `--ms-option-tooltip-*` variables (each defaulting to the shared `--ms-tooltip-*` surface) let you restyle them separately. Demonstrated on the new `examples-tooltips.html`.
31
+ - **Action buttons — position, rows, and alignment** — the action-buttons block can sit at the `bottom` of the dropdown as a sticky footer (`actions-position`), buttons can be arranged across multiple stacked rows via a per-button `row` property (row 1 always at the panel's outer edge, higher rows stacking inward toward the options list — so `column-reverse` keeps row 1 at the bottom for the footer case), and `actions-align` controls horizontal arrangement (`stretch` default, plus `left` / `right` / `center` / `space-between`). Internally `.ms__actions` became a vertical stack of `.ms__actions-row` lines. All additive and backward compatible — no `row` means a single row, and `stretch` preserves the previous full-width buttons.
32
+ - **Action buttons — smarter built-in defaults** — the built-in `select-all` and `clear-all` actions now manage their own disabled state: Select All disables once every selectable option is chosen, and Clear All disables while nothing is selected. This applies only when you haven't set an explicit static `isDisabled` or a `getIsDisabledCallback` (precedence: dynamic callback › static `isDisabled` › built-in default), so existing custom action configs are untouched.
33
+ - **Single-select — dropdown no longer reopens with a stale filter** — the single-select input doubles as label display and search box; reopening used to blank the visible text while leaving the list filtered by the previous search, so the box and the list silently disagreed. `open()` now mirrors the kept search term back into the box, keeping the visible input and the filtered results in sync (honoring `should-keep-search-on-close`).
34
+ - **Async search — dropdown stops jumping between empty and loading states** — the "No data" empty state and the loading spinner now share a min-height (`--ms-state-min-height`, a new themable variable), so the panel keeps a stable footprint instead of resizing as an async search swaps one block for the other.
35
+ - **Docs — new live demo page for events & interceptors** — `examples-events-callbacks.html` shows the DOM events, the `on*` property twin firing in parallel, and interactive veto demos for the new interceptors.
36
+
37
+ ## What's New in v1.12.0-rc04
38
+
39
+ - **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.
40
+ - **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.
41
+ - **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.
42
+ - **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.
29
43
 
30
44
  > ⚠️ **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
45
 
@@ -106,8 +120,29 @@ npm run build
106
120
 
107
121
  # Create package tarball
108
122
  npm run package
123
+
124
+ # Run tests
125
+ npm run test:unit # Vitest (happy-dom) — fast logic checks
126
+ npm run test:e2e # Playwright (browser) — interaction/visual
127
+ npm test # both
109
128
  ```
110
129
 
130
+ ## Code structure
131
+
132
+ Follows the BlissFramework four-layer web-component layout:
133
+
134
+ | Layer | File | Role |
135
+ |-------|------|------|
136
+ | Element | `src/web-component.ts` | `MultiSelectElement` — custom-element I/O wrapper, `ATTRIBUTE_TABLE`-driven |
137
+ | Logic | `src/multiselect.ts` | `WebMultiSelect<T>` — framework-agnostic core |
138
+ | Service | `src/tooltip.ts`, `src/virtual-scroll.ts` | single-purpose helpers (`Tooltip`, `VirtualScroll`) |
139
+ | Side | `src/types.ts`, `src/logger.ts`, `src/vendor/` | types, logging, vendored deps |
140
+
141
+ Two deviations from the canonical shape, both intentional:
142
+
143
+ - **`MultiSelectElement extends BaseElement`, not `HTMLElement` directly.** `BaseElement` is a local `const` resolving to `HTMLElement` in the browser and to a stub class under SSR (`typeof HTMLElement === 'undefined'`), so importing the module in Node doesn't throw. A literal `grep "extends HTMLElement"` structure check will not match here by design.
144
+ - **`src/vite-env.d.ts`** is a standard Vite ambient-types file, not part of the four-layer model.
145
+
111
146
  ## License
112
147
 
113
148
  MIT — see [LICENSE](./LICENSE).
@@ -241,6 +241,7 @@
241
241
  { "name": "ms-checkbox-checked-bg-hover", "category": "checkbox", "usage": "Checked checkbox hover background" },
242
242
  { "name": "ms-checkbox-checked-border-color-hover", "category": "checkbox", "usage": "Checked checkbox hover border" },
243
243
 
244
+ { "name": "ms-state-min-height", "category": "empty", "usage": "Shared min-height for the empty and loading states so the dropdown doesn't resize when swapping between them" },
244
245
  { "name": "ms-empty-padding", "category": "empty", "usage": "Empty state padding" },
245
246
  { "name": "ms-empty-font-size", "category": "empty", "usage": "Empty state font size" },
246
247
  { "name": "ms-empty-color", "category": "empty", "usage": "Empty state text color" },
package/dist/index.d.ts CHANGED
@@ -11,6 +11,13 @@ declare interface ActionButton<T = any> {
11
11
  text: string;
12
12
  /** Optional CSS class(es) to add to the button */
13
13
  cssClass?: string;
14
+ /**
15
+ * 1-based row this button belongs to (default `1`). Buttons sharing a `row` render on the same
16
+ * horizontal line; different values stack into multiple rows. Row 1 sits at the panel's outer edge
17
+ * and higher rows stack inward toward the options list — so with `actions-position="top"` row 1 is
18
+ * the topmost line, and with `actions-position="bottom"` row 1 is the bottommost line.
19
+ */
20
+ row?: number;
14
21
  /** Optional tooltip text */
15
22
  tooltip?: string;
16
23
  /** Static visibility - set to false to hide button */
@@ -20,9 +27,9 @@ declare interface ActionButton<T = any> {
20
27
  /** Custom click handler (required for 'custom' action) */
21
28
  onClick?: (multiselect: any) => void | Promise<void>;
22
29
  /** Dynamic visibility callback - return false to hide button (takes priority over isVisible) */
23
- isVisibleCallback?: (multiselect: any) => boolean;
30
+ getIsVisibleCallback?: (multiselect: any) => boolean;
24
31
  /** Dynamic disabled state callback - return true to disable button (takes priority over isDisabled) */
25
- isDisabledCallback?: (multiselect: any) => boolean;
32
+ getIsDisabledCallback?: (multiselect: any) => boolean;
26
33
  /** Dynamic text callback - return button text (takes priority over text) */
27
34
  getTextCallback?: (multiselect: any) => string;
28
35
  /** Dynamic CSS class callback - return class name(s) (takes priority over cssClass) */
@@ -31,6 +38,9 @@ declare interface ActionButton<T = any> {
31
38
  getTooltipCallback?: (multiselect: any) => string;
32
39
  }
33
40
 
41
+ /** Horizontal arrangement of buttons within an action row. `stretch` = full-width (default). */
42
+ declare type ActionsAlign = 'stretch' | 'left' | 'right' | 'center' | 'space-between';
43
+
34
44
  /**
35
45
  * Layout mode for action buttons container
36
46
  * - 'nowrap': Buttons stay in single row (default)
@@ -38,6 +48,9 @@ declare interface ActionButton<T = any> {
38
48
  */
39
49
  declare type ActionsLayout = 'nowrap' | 'wrap';
40
50
 
51
+ /** Where the action-buttons block sits in the dropdown panel. */
52
+ declare type ActionsPosition = 'top' | 'bottom';
53
+
41
54
  /**
42
55
  * Context provided to renderBadgeContentCallback
43
56
  */
@@ -196,8 +209,20 @@ declare interface MultiSelectConfig<T = any> {
196
209
  checkboxAlign?: 'top' | 'center' | 'bottom';
197
210
  /** Hint text shown above the input while the dropdown is open. */
198
211
  searchHint?: string;
199
- /** Placeholder text for the search input */
212
+ /** Placeholder text for the search input (shown while search is usable) */
200
213
  searchPlaceholder?: string;
214
+ /**
215
+ * Placeholder shown when search is disabled (input acts as a picker rather than a search box).
216
+ * Applies when `isSearchEnabled` is false or `searchInputMode` is 'readonly'/'hidden'.
217
+ * Default: "Pick an option..."
218
+ */
219
+ selectPlaceholder?: string;
220
+ /**
221
+ * Placeholder shown when there are no options to choose from (e.g. an unresolved cascade parent).
222
+ * Opt-in: when unset, the normal search/select placeholder is used even with an empty list.
223
+ * Lets users see there is no data without opening the dropdown.
224
+ */
225
+ noDataPlaceholder?: string;
201
226
  /** Minimum width for the dropdown (e.g., '20rem', '300px') */
202
227
  dropdownMinWidth?: string | null;
203
228
  /** Maximum width for the dropdown (e.g., '40rem', '500px') */
@@ -220,12 +245,23 @@ declare interface MultiSelectConfig<T = any> {
220
245
  searchMode?: SearchMode;
221
246
  /** Layout mode for action buttons: 'nowrap' (default) or 'wrap' for multi-row */
222
247
  actionsLayout?: ActionsLayout;
248
+ /** Where the action-buttons block sits in the dropdown: 'top' (default) or 'bottom' (sticky footer). */
249
+ actionsPosition?: ActionsPosition;
250
+ /** Horizontal arrangement of buttons within a row: 'stretch' (default, full-width), 'left', 'right', 'center', or 'space-between'. */
251
+ actionsAlign?: ActionsAlign;
223
252
  /** Auto-switch from badges to count when threshold is exceeded */
224
253
  badgesThreshold?: number | null;
225
254
  /** Maximum number of badges to show in partial mode (used with thresholdMode='partial') */
226
255
  badgesMaxVisible?: number | null;
227
256
  /** Minimum search length before loading data */
228
257
  minSearchLength?: number;
258
+ /**
259
+ * Debounce delay in milliseconds before the async `searchCallback` is invoked.
260
+ * Each keystroke resets the timer, so only the last input in a burst fires a request.
261
+ * Applies to the async `searchCallback` path only — local in-memory filtering stays instant.
262
+ * Default: 0 (no debounce — callback runs on every keystroke).
263
+ */
264
+ searchDebounce?: number;
229
265
  /** Minimum items before virtual scroll activates (default: 100) */
230
266
  virtualScrollThreshold?: number;
231
267
  /** Fixed height for each option in pixels (required for virtual scroll, default: 50) */
@@ -236,16 +272,38 @@ declare interface MultiSelectConfig<T = any> {
236
272
  virtualScrollBuffer?: number;
237
273
  /** Pre-process search term before calling searchCallback. Return null to prevent search. Use for accent removal, validation, etc. */
238
274
  beforeSearchCallback?: ((searchTerm: string) => string | null) | null;
239
- /** Async function to load data: (searchTerm) => Promise<options[]> */
240
- searchCallback?: ((searchTerm: string) => Promise<T[]>) | null;
275
+ /**
276
+ * Interceptor: runs before an option is selected via user interaction.
277
+ * Receives the option about to be added and the current selection (before the change).
278
+ * Return `false` to block the selection; return `true`/`undefined` to allow.
279
+ * Silent — a blocked action fires no event. Bypassed by programmatic `setSelected`
280
+ * and the Select-All action button.
281
+ */
282
+ beforeSelectCallback?: ((option: T, selectedOptions: T[]) => boolean | void) | null;
283
+ /**
284
+ * Interceptor: runs before an option is deselected via user interaction — the dropdown
285
+ * option toggle, a badge's remove (×) button, the selected-items popover's remove button,
286
+ * and the "remove hidden" badge (checked per item). Receives the option about to be
287
+ * removed and the current selection (before the change). Return `false` to block the
288
+ * deselection; return `true`/`undefined` to allow. Silent — a blocked action fires no
289
+ * event. Bypassed by programmatic `setSelected` and the Clear-All action button.
290
+ */
291
+ beforeDeselectCallback?: ((option: T, selectedOptions: T[]) => boolean | void) | null;
292
+ /**
293
+ * Async function to load data: `(searchTerm, signal) => Promise<options[]>`.
294
+ * The optional second argument is an `AbortSignal` that fires when a newer search
295
+ * supersedes this one (or the component is destroyed). Wire it into your `fetch`
296
+ * to cancel the in-flight request; ignoring it is fine — stale results are discarded.
297
+ */
298
+ searchCallback?: ((searchTerm: string, signal?: AbortSignal) => Promise<T[]>) | null;
241
299
  /** Callback to add a new option when isAddNewAllowed is true */
242
300
  addNewCallback?: ((value: string) => T | Promise<T>) | null;
243
- /** Callback when an option is selected */
244
- selectCallback?: ((option: T) => void) | null;
245
- /** Callback when an option is deselected */
246
- deselectCallback?: ((option: T) => void) | null;
247
- /** Callback when selection changes */
248
- changeCallback?: ((selectedOptions: T[]) => void) | null;
301
+ /** Event handler: an option was selected (fire-and-forget; return value ignored). Mirrors the bubbling `select` CustomEvent on the element. */
302
+ onSelect?: ((option: T) => void) | null;
303
+ /** Event handler: an option was deselected (fire-and-forget). Mirrors the bubbling `deselect` CustomEvent on the element. */
304
+ onDeselect?: ((option: T) => void) | null;
305
+ /** Event handler: the selection set changed (fire-and-forget). Mirrors the bubbling `change` CustomEvent on the element. */
306
+ onChange?: ((selectedOptions: T[]) => void) | null;
249
307
  /** Callback to format count badge text (for i18n/pluralization). When moreCount is provided, it's for the "+X more" badge in partial mode. */
250
308
  getCounterCallback?: ((count: number, moreCount?: number) => string) | null;
251
309
  /** Enable tooltips on selected item badges (internal: isBadgeTooltipsEnabled) */
@@ -262,6 +320,18 @@ declare interface MultiSelectConfig<T = any> {
262
320
  badgeTooltipDelay?: number;
263
321
  /** Offset distance for tooltip in pixels */
264
322
  badgeTooltipOffset?: number;
323
+ /** Enable tooltips on dropdown options (internal: isOptionTooltipsEnabled) */
324
+ isOptionTooltipsEnabled?: boolean;
325
+ /** Callback to generate custom tooltip content for a dropdown option. Default: display value, plus subtitle on the next line when present. */
326
+ getOptionTooltipCallback?: ((item: T) => string | HTMLElement) | null;
327
+ /** Option tooltip placement. Default `'top-start'` (anchored to the row's start edge, so it doesn't center on a full-width row). Use `'left'`/`'right'` (start/end side) for a narrow multiselect. */
328
+ optionTooltipPlacement?: Placement;
329
+ /** Delay before showing an option tooltip (ms). Falls back to `badgeTooltipDelay`, then `100`. */
330
+ optionTooltipDelay?: number;
331
+ /** Offset distance for an option tooltip (px). Falls back to `badgeTooltipOffset`, then `8`. */
332
+ optionTooltipOffset?: number;
333
+ /** Anchor the option tooltip to the mouse pointer and follow it across the row (best for full-width rows). Default `false`. */
334
+ isOptionTooltipFollowCursor?: boolean;
265
335
  /** Container element for dropdown/hint/popover (for Shadow DOM support) */
266
336
  container?: HTMLElement | null;
267
337
  /** Host element for appending hidden inputs (for form integration with shadow DOM) */
@@ -296,6 +366,7 @@ export declare class MultiSelectElement<T = any> extends BaseElement {
296
366
  private _getDisabledCallback?;
297
367
  private _getValueFormatCallback?;
298
368
  private _getBadgeTooltipCallback?;
369
+ private _getOptionTooltipCallback?;
299
370
  private _getRemoveButtonTooltipCallback?;
300
371
  private _renderOptionContentCallback?;
301
372
  private _renderBadgeContentCallback?;
@@ -304,12 +375,17 @@ export declare class MultiSelectElement<T = any> extends BaseElement {
304
375
  private _renderSelectedContentCallback?;
305
376
  private _getCounterCallback?;
306
377
  private _actionButtons?;
378
+ private _batchDepth;
379
+ private _batchPartial;
380
+ private _batchNeedsReinit;
307
381
  private _beforeSearchCallback?;
382
+ private _beforeSelectCallback?;
383
+ private _beforeDeselectCallback?;
308
384
  private _searchCallback?;
309
385
  private _addNewCallback?;
310
- private _selectCallback?;
311
- private _deselectCallback?;
312
- private _changeCallback?;
386
+ private _onSelect?;
387
+ private _onDeselect?;
388
+ private _onChange?;
313
389
  static get observedAttributes(): string[];
314
390
  constructor();
315
391
  /**
@@ -323,6 +399,20 @@ export declare class MultiSelectElement<T = any> extends BaseElement {
323
399
  connectedCallback(): void;
324
400
  disconnectedCallback(): void;
325
401
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
402
+ /**
403
+ * Set several attributes in one in-place update — a single re-render instead of one per
404
+ * attribute (and a single reinit at most if any change is structural). Keys are attribute
405
+ * names in kebab-case, exactly as `setAttribute`. A value of `null`/`undefined`/`false`
406
+ * removes the attribute; `true` sets it to an empty string; anything else is stringified.
407
+ *
408
+ * @example
409
+ * el.setAttributes({
410
+ * 'search-placeholder': t('search'),
411
+ * 'select-placeholder': t('pick'),
412
+ * 'no-data-placeholder': t('noData'),
413
+ * });
414
+ */
415
+ setAttributes(attrs: Record<string, string | number | boolean | null | undefined>): void;
326
416
  private render;
327
417
  private renderDebugInfo;
328
418
  private updateDebugInfo;
@@ -407,6 +497,18 @@ export declare class MultiSelectElement<T = any> extends BaseElement {
407
497
  get checkboxAlign(): string | null;
408
498
  set enableBadgeTooltips(value: boolean);
409
499
  get enableBadgeTooltips(): boolean;
500
+ set enableOptionTooltips(value: boolean);
501
+ get enableOptionTooltips(): boolean;
502
+ set getOptionTooltipCallback(callback: ((item: T) => string | HTMLElement) | undefined);
503
+ get getOptionTooltipCallback(): ((item: T) => string | HTMLElement) | undefined;
504
+ set optionTooltipPlacement(value: string | null);
505
+ get optionTooltipPlacement(): string | null;
506
+ set optionTooltipFollowCursor(value: boolean);
507
+ get optionTooltipFollowCursor(): boolean;
508
+ set actionsPosition(value: string | null);
509
+ get actionsPosition(): string | null;
510
+ set actionsAlign(value: string | null);
511
+ get actionsAlign(): string | null;
410
512
  set badgeTooltipPlacement(value: string | null);
411
513
  get badgeTooltipPlacement(): string | null;
412
514
  set getBadgeTooltipCallback(callback: ((item: T) => string | HTMLElement) | undefined);
@@ -419,16 +521,20 @@ export declare class MultiSelectElement<T = any> extends BaseElement {
419
521
  get getCounterCallback(): ((count: number, moreCount?: number) => string) | undefined;
420
522
  get beforeSearchCallback(): ((searchTerm: string) => string | null) | undefined;
421
523
  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);
524
+ get beforeSelectCallback(): ((option: T, selectedOptions: T[]) => boolean | void) | undefined;
525
+ set beforeSelectCallback(callback: ((option: T, selectedOptions: T[]) => boolean | void) | undefined);
526
+ get beforeDeselectCallback(): ((option: T, selectedOptions: T[]) => boolean | void) | undefined;
527
+ set beforeDeselectCallback(callback: ((option: T, selectedOptions: T[]) => boolean | void) | undefined);
528
+ get searchCallback(): ((searchTerm: string, signal?: AbortSignal) => Promise<T[]>) | undefined;
529
+ set searchCallback(callback: ((searchTerm: string, signal?: AbortSignal) => Promise<T[]>) | undefined);
424
530
  get addNewCallback(): ((value: string) => T | Promise<T>) | undefined;
425
531
  set addNewCallback(callback: ((value: string) => T | Promise<T>) | undefined);
426
- get selectCallback(): ((option: T) => void) | undefined;
427
- set selectCallback(callback: ((option: T) => void) | undefined);
428
- get deselectCallback(): ((option: T) => void) | undefined;
429
- set deselectCallback(callback: ((option: T) => void) | undefined);
430
- get changeCallback(): ((selectedOptions: T[]) => void) | undefined;
431
- set changeCallback(callback: ((selectedOptions: T[]) => void) | undefined);
532
+ get onSelect(): ((option: T) => void) | undefined;
533
+ set onSelect(callback: ((option: T) => void) | undefined);
534
+ get onDeselect(): ((option: T) => void) | undefined;
535
+ set onDeselect(callback: ((option: T) => void) | undefined);
536
+ get onChange(): ((selectedOptions: T[]) => void) | undefined;
537
+ set onChange(callback: ((selectedOptions: T[]) => void) | undefined);
432
538
  get actionButtons(): any[] | undefined;
433
539
  set actionButtons(value: any[] | undefined);
434
540
  get selectedValue(): string | number | (string | number)[] | null;
@@ -478,11 +584,11 @@ export declare interface MultiSelectOption {
478
584
  */
479
585
  export declare interface MultiSelectOptions extends MultiSelectConfig<MultiSelectOption> {
480
586
  options?: MultiSelectOption[];
481
- searchCallback?: ((searchTerm: string) => Promise<MultiSelectOption[]>) | null;
587
+ searchCallback?: ((searchTerm: string, signal?: AbortSignal) => Promise<MultiSelectOption[]>) | null;
482
588
  addNewCallback?: ((value: string) => MultiSelectOption | Promise<MultiSelectOption>) | null;
483
- selectCallback?: ((option: MultiSelectOption) => void) | null;
484
- deselectCallback?: ((option: MultiSelectOption) => void) | null;
485
- changeCallback?: ((selectedOptions: MultiSelectOption[]) => void) | null;
589
+ onSelect?: ((option: MultiSelectOption) => void) | null;
590
+ onDeselect?: ((option: MultiSelectOption) => void) | null;
591
+ onChange?: ((selectedOptions: MultiSelectOption[]) => void) | null;
486
592
  }
487
593
 
488
594
  /**
@@ -548,6 +654,8 @@ export declare class WebMultiSelect<T = any> {
548
654
  private matchingIndices;
549
655
  private searchTerm;
550
656
  private isLoading;
657
+ private searchDebounceTimer?;
658
+ private searchAbortController?;
551
659
  private showSelectedPopover;
552
660
  private selectedPopoverPlacement;
553
661
  private dropdownPlacement;
@@ -620,13 +728,43 @@ export declare class WebMultiSelect<T = any> {
620
728
  * Render the Select All / Clear All / custom action buttons row.
621
729
  * Returns the empty string if multiple-select is off or no buttons are configured.
622
730
  */
731
+ /**
732
+ * Default enabled/disabled state for the built-in actions, applied only when the consumer hasn't
733
+ * set an explicit `isDisabled` / `getIsDisabledCallback`:
734
+ * - `select-all` is disabled when it would add nothing (every selectable, non-disabled filtered
735
+ * option is already selected — this also covers an empty list).
736
+ * - `clear-all` is disabled when nothing is selected.
737
+ */
738
+ private getBuiltInActionDisabled;
623
739
  private renderActionsHTML;
624
740
  private renderOption;
625
741
  private highlightMatch;
626
742
  private groupOptions;
743
+ /** Whether the input currently functions as a usable search field (drives placeholder wording). */
744
+ private get isSearchUsable();
745
+ /**
746
+ * Resolve the closed-state input placeholder for the current data/search state.
747
+ * Priority: explicit no-data placeholder (when the list is empty) → "pick" prompt when
748
+ * search is unusable → the search placeholder.
749
+ */
750
+ private getPlaceholderText;
627
751
  private renderBadges;
628
752
  private attachEvents;
629
753
  private handleSearch;
754
+ /** Abort the search request currently in flight, if any. The aborted request's results
755
+ * are then ignored (and the consumer's `searchCallback` can short-circuit its fetch via
756
+ * the `AbortSignal` it was handed). */
757
+ private abortInFlightSearch;
758
+ /**
759
+ * Invoke the async `searchCallback` and apply its results. Split out of `handleSearch`
760
+ * so it can be called immediately or after the debounce timer.
761
+ *
762
+ * Any request still in flight is aborted before a new one starts, so a slow earlier
763
+ * request can't overwrite a newer one — and consumers that wire the passed `AbortSignal`
764
+ * into their fetch get the request actually cancelled, not just ignored. The
765
+ * `aborted` / `searchTerm === value` guards drop superseded or out-of-order responses.
766
+ */
767
+ private performAsyncSearch;
630
768
  private handleKeydown;
631
769
  private handleDropdownClick;
632
770
  private handleBadgeClick;
@@ -646,6 +784,23 @@ export declare class WebMultiSelect<T = any> {
646
784
  private focusPreviousMatch;
647
785
  private scrollToFocused;
648
786
  private toggleOption;
787
+ /**
788
+ * The single funnel for an interactive (user-initiated) selection. Consults
789
+ * `beforeSelectCallback` and only mutates state if allowed, so the veto can
790
+ * never be bypassed by a new UI entry point. Programmatic `setSelected` and
791
+ * the Select-All button deliberately do not route through here.
792
+ * Returns true if the option was selected, false if the veto blocked it.
793
+ */
794
+ private interactiveSelect;
795
+ /**
796
+ * The single funnel for an interactive (user-initiated) deselection. Every
797
+ * removal affordance — dropdown toggle, badge × button, selected-items
798
+ * popover × button, and the "remove hidden" badge — routes through here so
799
+ * the `beforeDeselectCallback` veto applies uniformly. Programmatic
800
+ * `setSelected` and the Clear-All button deliberately bypass it.
801
+ * Returns true if the option was deselected, false if the veto blocked it.
802
+ */
803
+ private interactiveDeselect;
649
804
  private handleAddNew;
650
805
  private selectOption;
651
806
  private deselectOption;
@@ -654,7 +809,7 @@ export declare class WebMultiSelect<T = any> {
654
809
  /**
655
810
  * Re-render and fire callbacks after a selection state change.
656
811
  * `added` / `removed` drive per-item select/deselect callbacks.
657
- * `changeCallback` fires once if anything actually changed.
812
+ * `onChange` fires once if anything actually changed.
658
813
  */
659
814
  private commit;
660
815
  private open;
@@ -738,6 +893,20 @@ export declare class WebMultiSelect<T = any> {
738
893
  /** Build the remove-button tooltip text (callback > format string with {0} > "Remove {name}"). */
739
894
  private buildRemoveButtonTooltipText;
740
895
  private attachBadgeTooltips;
896
+ /** Build the option tooltip content (callback overrides; default = displayValue + optional subtitle on next line). */
897
+ private buildOptionTooltipContent;
898
+ /**
899
+ * Attach hover tooltips to the currently rendered dropdown options. Prunes existing option
900
+ * tooltips first, so it's safe to call on every render and on every virtual-scroll range change
901
+ * (where option DOM is recycled). Each option resolves its source object via `data-index` into
902
+ * `filteredOptions`, the same global index `renderOption` was given.
903
+ */
904
+ private attachOptionTooltips;
905
+ /**
906
+ * Destroy only the option tooltips (prefixed `option-`). Called before re-rendering or
907
+ * recycling the options list so per-option tooltip state doesn't leak.
908
+ */
909
+ private destroyAllOptionTooltips;
741
910
  private attachActionButtonTooltips;
742
911
  /**
743
912
  * Destroy only the action-button tooltips. Called from `renderDropdown`/`renderDropdownVirtual`