@keenmate/web-multiselect 2.0.0-rc12 → 2.0.0

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/dist/index.d.ts CHANGED
@@ -332,8 +332,32 @@ declare interface MultiSelectConfig<T = any> {
332
332
  fullscreenAutofocus?: boolean;
333
333
  /** Lock dropdown placement after first open (internal: isPlacementLocked) */
334
334
  isPlacementLocked?: boolean;
335
- /** Allow adding new options not in the list (internal: isAddNewAllowed) */
335
+ /**
336
+ * Allow adding new options not in the list (internal: isAddNewAllowed).
337
+ * When on and a search yields no matches, the empty dropdown shows a clickable
338
+ * "add new" prompt (text from `addNewText` / `getAddNewTextCallback`) instead of
339
+ * the `emptyMessage`; choosing it (click or Enter) fires the `add` event and, if
340
+ * `addNewCallback` is set, materializes + selects the created option.
341
+ */
336
342
  isAddNewAllowed?: boolean;
343
+ /**
344
+ * Template for the clickable "add new" prompt (see `isAddNewAllowed`). The substring
345
+ * `{value}` is replaced with the (HTML-escaped) typed text. Default: `Add "{value}"`.
346
+ * `getAddNewTextCallback` takes precedence. (internal: addNewText)
347
+ */
348
+ addNewText?: string;
349
+ /**
350
+ * Dynamically compute the "add new" prompt label from the typed text. Takes precedence
351
+ * over `addNewText`. Returns plain text (inserted as text, not HTML). Use it for i18n or
352
+ * context-aware wording, e.g. `(v) => \`Add new member: ${v}\``.
353
+ */
354
+ getAddNewTextCallback?: ((value: string) => string) | null;
355
+ /**
356
+ * Template for the pending prompt shown (spinner + this text) while an async `addNewCallback`
357
+ * is in flight. `{value}` is replaced with the (HTML-escaped) typed text. Default:
358
+ * `Adding "{value}"…`. (internal: addNewPendingText)
359
+ */
360
+ addNewPendingText?: string;
337
361
  /** Show count badge next to toggle icon (internal: isCounterShown) */
338
362
  isCounterShown?: boolean;
339
363
  /**
@@ -530,8 +554,32 @@ declare interface MultiSelectConfig<T = any> {
530
554
  * to cancel the in-flight request; ignoring it is fine — stale results are discarded.
531
555
  */
532
556
  searchCallback?: ((searchTerm: string, signal?: AbortSignal) => Promise<T[]>) | null;
533
- /** Callback to add a new option when isAddNewAllowed is true */
534
- addNewCallback?: ((value: string) => T | Promise<T>) | null;
557
+ /**
558
+ * Callback to create the new option object from the typed text when `isAddNewAllowed` is on.
559
+ * Return (or resolve to) the new option — it is appended to the list and auto-selected. The
560
+ * returned `T` can be a rich option object (icon/subtitle/custom-render fields and all): it flows
561
+ * through the same `get*` / `render*` callbacks as any other option, so the created row and its
562
+ * badge render exactly like the rest.
563
+ *
564
+ * **Cancelable (async):** return `null` or `undefined` (or a Promise of either) to abort — nothing
565
+ * is added or selected, the search is left intact, and the `add` event does NOT fire. Use it for
566
+ * async validation, a confirm dialog, or a server round-trip that may say no. The cancel sentinel
567
+ * is strictly `null`/`undefined` (checked with `== null`), so a falsy-but-valid option in
568
+ * primitive mode (`0`, `false`, `""`) still creates normally.
569
+ *
570
+ * Omit the callback entirely to handle creation yourself via the `add` event / `onAddNew` (e.g.
571
+ * open a modal, POST to a server, then add the option imperatively).
572
+ */
573
+ addNewCallback?: ((value: string) => T | null | undefined | Promise<T | null | undefined>) | null;
574
+ /**
575
+ * Event handler: the user chose to create a new option from the typed text (via the "add new"
576
+ * row or Enter). `value` is the typed text; `option` is the created item when `addNewCallback`
577
+ * produced one (absent otherwise). Mirrors the bubbling `add` CustomEvent on the element.
578
+ */
579
+ onAddNew?: ((detail: {
580
+ value: string;
581
+ option?: T;
582
+ }) => void) | null;
535
583
  /**
536
584
  * Intercept keyboard input before the built-in handling. Runs on every keydown (open or
537
585
  * closed) with a {@link MultiSelectKeydownContext} carrying the event, current state, and a
@@ -606,10 +654,14 @@ export declare class MultiSelectElement<T = any> extends BlissElement<MultiSelec
606
654
  }, {
607
655
  readonly name: "change";
608
656
  readonly description: "The selection changed. `detail.selectedOptions`/`detail.selectedValues` are the full selection.";
657
+ }, {
658
+ readonly name: "add";
659
+ readonly description: "The user chose to create a new option from the typed text (via the \"add new\" prompt or Enter) — requires `allow-add-new`. `detail.value` is the typed text; `detail.option` is the created item when `addNewCallback` produced one.";
609
660
  }];
610
661
  onSelect: ((e: CustomEvent<MultiSelectEventDetail<T>>) => void) | null;
611
662
  onDeselect: ((e: CustomEvent<MultiSelectEventDetail<T>>) => void) | null;
612
663
  onChange: ((e: CustomEvent<MultiSelectEventDetail<T>>) => void) | null;
664
+ onAdd: ((e: CustomEvent<MultiSelectEventDetail<T>>) => void) | null;
613
665
  constructor();
614
666
  /**
615
667
  * Called by the browser when the surrounding <form> is reset. Clears the
@@ -667,6 +719,43 @@ export declare class MultiSelectElement<T = any> extends BlissElement<MultiSelec
667
719
  showMessage(content: string | HTMLElement, opts?: MessageOptions): void;
668
720
  /** Dismiss the transient message shown by {@link showMessage}, if any. */
669
721
  hideMessage(): void;
722
+ /**
723
+ * Clear the search box and restore the full option list (does not touch the selection — use
724
+ * {@link clearAll} for that). Pair with {@link scrollToValue} to reveal then scroll to an option
725
+ * a search had filtered out: `el.clearSearch(); el.scrollToValue(v)`.
726
+ */
727
+ clearSearch(): void;
728
+ /** The current search box text (empty string when nothing is typed). Read via this getter, write with {@link search}. */
729
+ get searchText(): string;
730
+ /**
731
+ * Programmatically set the search text and filter, as if the user typed it (runs
732
+ * `beforeSearchCallback` / `minSearchLength` / async `searchCallback`). Does not open the dropdown
733
+ * — call {@link open} if you want it visible. Pass `''` to clear (same as {@link clearSearch}).
734
+ */
735
+ search(term: string): void;
736
+ /**
737
+ * Scroll the open dropdown to the option at `index` (into the current filtered list). Returns
738
+ * false if closed or out of range. Deferred internally so `el.open(); el.scrollToIndex(i)` works.
739
+ */
740
+ scrollToIndex(index: number, opts?: {
741
+ block?: ScrollLogicalPosition;
742
+ }): boolean;
743
+ /**
744
+ * Scroll the open dropdown to the option with this `value`. Returns false if it isn't in the
745
+ * currently visible list (filtered out by search, or under a collapsed tree branch) — call
746
+ * {@link clearSearch} / expand first.
747
+ */
748
+ scrollToValue(value: string | number, opts?: {
749
+ block?: ScrollLogicalPosition;
750
+ }): boolean;
751
+ /**
752
+ * Scroll to a group: its header in standard rendering, or the group's first option in
753
+ * virtual-scroll mode (no headers there). Returns false in tree mode or if the group is empty
754
+ * in the current filtered list.
755
+ */
756
+ scrollToGroup(name: string, opts?: {
757
+ block?: ScrollLogicalPosition;
758
+ }): boolean;
670
759
  /** Open the dropdown. */
671
760
  open(): void;
672
761
  /** Close the dropdown. */
@@ -688,14 +777,17 @@ export declare interface MultiSelectEventDetail<T = any> {
688
777
  selectedOptions: T[];
689
778
  /** Selected values array */
690
779
  selectedValues: (string | number)[];
691
- /** The option that triggered the event (for select/deselect) */
780
+ /** The option that triggered the event (for select/deselect/add) */
692
781
  option?: T;
782
+ /** The typed text that triggered the `add` event (add only) */
783
+ value?: string;
693
784
  }
694
785
 
695
786
  declare type MultiSelectEvents = {
696
787
  select: MultiSelectEventDetail;
697
788
  deselect: MultiSelectEventDetail;
698
789
  change: MultiSelectEventDetail;
790
+ add: MultiSelectEventDetail;
699
791
  };
700
792
 
701
793
  /**
@@ -943,6 +1035,10 @@ export declare class WebMultiSelect<T = any> {
943
1035
  private keyboardController;
944
1036
  private matchingIndices;
945
1037
  private searchTerm;
1038
+ /** Keyboard focus sits on the empty-state "add new" prompt (arrow-navigated). */
1039
+ private addNewFocused;
1040
+ /** An async addNewCallback is in flight — the prompt shows a spinner + pending text. */
1041
+ private addNewPending;
946
1042
  private isLoading;
947
1043
  private searchDebounceTimer?;
948
1044
  private searchAbortController?;
@@ -1192,6 +1288,25 @@ export declare class WebMultiSelect<T = any> {
1192
1288
  * chevron/toggle — every node is just a normal, selectable option.
1193
1289
  */
1194
1290
  private renderTreeNode;
1291
+ /**
1292
+ * Empty-dropdown content. When "add new" is enabled (isAddNewAllowed) AND the user has typed
1293
+ * a non-empty search term, show a clickable "add new" prompt instead of the plain emptyMessage —
1294
+ * choosing it (click via handleDropdownClick, or Enter via the keydown handler) runs handleAddNew.
1295
+ * Otherwise fall back to the emptyMessage.
1296
+ */
1297
+ private renderEmptyStateHTML;
1298
+ /** True when the empty dropdown is currently showing the clickable "add new" prompt. */
1299
+ private isAddNewPromptShown;
1300
+ /**
1301
+ * Resolve the "add new" prompt label for the typed text. Priority: getAddNewTextCallback
1302
+ * (returns plain text — fully escaped here) → addNewText template (trusted config string;
1303
+ * only the `{value}` substitution is escaped) → the default `Add "{value}"`.
1304
+ */
1305
+ private getAddNewText;
1306
+ /** Pending-prompt label shown (with a spinner) while an async addNewCallback runs. */
1307
+ private getAddNewPendingText;
1308
+ /** Minimal HTML entity escape for untrusted text spliced into an innerHTML string. */
1309
+ private escapeHtml;
1195
1310
  private highlightMatch;
1196
1311
  private groupOptions;
1197
1312
  /** Whether the input currently functions as a usable search field (drives placeholder wording). */
@@ -1251,6 +1366,8 @@ export declare class WebMultiSelect<T = any> {
1251
1366
  private focusLast;
1252
1367
  private focusPageUp;
1253
1368
  private focusPageDown;
1369
+ /** Move keyboard focus onto the empty-state "add new" prompt (the only actionable row). */
1370
+ private focusAddNewPrompt;
1254
1371
  private focusNextMatch;
1255
1372
  private focusPreviousMatch;
1256
1373
  /** Lazily build (and cache) the imperative facade passed to `keydownCallback`. Bound to the
@@ -1258,8 +1375,64 @@ export declare class WebMultiSelect<T = any> {
1258
1375
  private getKeyboardController;
1259
1376
  /** Clear the search box (both the main input and the fullscreen search) and reset the visible
1260
1377
  * list. Shared by Escape and the keyboard controller. */
1261
- private clearSearch;
1378
+ /**
1379
+ * Clear the search box and restore the full option list (resets the visible/matched sets and
1380
+ * drops keyboard focus). Public building block: pair it with `scrollToValue()` to reveal then
1381
+ * scroll to an option the current search had filtered out — `el.clearSearch(); el.scrollToValue(v)`.
1382
+ * Does not touch the selection (use `clearAll()` for that).
1383
+ */
1384
+ clearSearch(): void;
1385
+ /**
1386
+ * Programmatically set the search text and filter — exactly as if the user typed it, so
1387
+ * `beforeSearchCallback`, `minSearchLength` and async `searchCallback` all apply the same way.
1388
+ * Reflects into the search box (and the fullscreen sheet's field). Does NOT open the dropdown —
1389
+ * call `open()` if you want it visible. Passing `''` clears (equivalent to `clearSearch()`).
1390
+ */
1391
+ search(term: string): void;
1262
1392
  private scrollToFocused;
1393
+ /**
1394
+ * scrollIntoView with the fullscreen-safe default. In the fullscreen sheet the soft keyboard
1395
+ * covers the lower viewport, so `block:'nearest'` can bottom-align a match BEHIND the keyboard;
1396
+ * centre it instead and scroll INSTANTLY (a smooth animation kicked off per-keystroke is torn
1397
+ * down by the next re-render and never settles — the "list jumps every letter" bug). Floating
1398
+ * scrolls the nearest edge smoothly. The caller may override `block`.
1399
+ */
1400
+ private applyScrollIntoView;
1401
+ /**
1402
+ * Scroll the open dropdown so the option at `index` (into the current `filteredOptions`) is
1403
+ * visible. Works in floating, fullscreen (mobile), virtual-scroll and tree modes. Returns
1404
+ * false if the dropdown is closed or the index is out of range. The scroll is deferred a frame
1405
+ * when the list isn't rendered yet (e.g. right after `open()` in virtual mode / the fullscreen
1406
+ * sheet build), so `el.open(); el.scrollToIndex(i)` works.
1407
+ */
1408
+ scrollToIndex(index: number, opts?: {
1409
+ block?: ScrollLogicalPosition;
1410
+ }): boolean;
1411
+ /**
1412
+ * Scroll to the option whose value matches `value` (resolved within the current
1413
+ * `filteredOptions`). Returns false if it isn't in the currently visible list — e.g. filtered
1414
+ * out by a search, or (tree) under a collapsed ancestor. Call `clearSearch()` (or expand the
1415
+ * branch) first to reveal it, then scroll.
1416
+ */
1417
+ scrollToValue(value: string | number, opts?: {
1418
+ block?: ScrollLogicalPosition;
1419
+ }): boolean;
1420
+ /**
1421
+ * Scroll to a group. In standard rendering the group's header (`.ms__group-label`) is brought
1422
+ * into view; in virtual-scroll mode (which renders no headers) it scrolls to the group's FIRST
1423
+ * option instead. Returns false in tree mode (groups don't apply) or if the group has no
1424
+ * options in the current filtered list.
1425
+ */
1426
+ scrollToGroup(name: string, opts?: {
1427
+ block?: ScrollLogicalPosition;
1428
+ }): boolean;
1429
+ /**
1430
+ * Shared scroll worker for the public scrollTo* methods. Virtual mode uses the fixed-height
1431
+ * math (works even if the row isn't currently rendered); otherwise scrolls the
1432
+ * `.ms__option[data-index]` element into view. Defers one frame if the list isn't ready yet
1433
+ * (post-open virtual init / fullscreen sheet build), then retries once.
1434
+ */
1435
+ private scrollToRenderedIndex;
1263
1436
  private toggleOption;
1264
1437
  /**
1265
1438
  * The single funnel for an interactive (user-initiated) selection. Consults
@@ -1278,6 +1451,13 @@ export declare class WebMultiSelect<T = any> {
1278
1451
  * Returns true if the option was deselected, false if the veto blocked it.
1279
1452
  */
1280
1453
  private interactiveDeselect;
1454
+ /**
1455
+ * Commit the "add new" affordance for the typed text. Two modes:
1456
+ * - `addNewCallback` set → create the option, append it, select it, clear the search.
1457
+ * - no callback → the consumer owns creation; we only notify (via the `add` event) so they
1458
+ * can open a modal / POST / add the option imperatively.
1459
+ * The `add` event fires in BOTH modes (with `option` present only when one was created).
1460
+ */
1281
1461
  private handleAddNew;
1282
1462
  private selectOption;
1283
1463
  private deselectOption;
@@ -1563,6 +1743,8 @@ export declare class WebMultiSelect<T = any> {
1563
1743
  */
1564
1744
  updateOptions(partial: Partial<MultiSelectConfig<T>>): boolean;
1565
1745
  get selectedItem(): T | null;
1746
+ /** The current search box text (empty string when nothing is typed). Read-only; clear it with `clearSearch()`. */
1747
+ get searchText(): string;
1566
1748
  get selectedValue(): string | number | (string | number)[] | null;
1567
1749
  getValue(): string | number | (string | number)[] | null;
1568
1750
  /**