@keenmate/web-multiselect 2.0.0-rc11 → 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
  /**
@@ -343,6 +367,13 @@ declare interface MultiSelectConfig<T = any> {
343
367
  * Default `false`. (internal: isClearShown)
344
368
  */
345
369
  isClearShown?: boolean;
370
+ /**
371
+ * Scope the "one overlay open at a time" coordination to a named group. Overlays
372
+ * (multiselects, datepickers, external popovers) sharing a group dismiss each other
373
+ * when one opens; different groups are independent. Unset = the default (ungrouped)
374
+ * group, in which every ungrouped overlay coordinates. (internal: overlayGroup)
375
+ */
376
+ overlayGroup?: string;
346
377
  /**
347
378
  * Allow the selected-items popover to open. Defaults to `true`. The popover is triggered by
348
379
  * the count / compact / "+X more" badge and by the in-input counter (`isCounterShown`). Set
@@ -523,8 +554,32 @@ declare interface MultiSelectConfig<T = any> {
523
554
  * to cancel the in-flight request; ignoring it is fine — stale results are discarded.
524
555
  */
525
556
  searchCallback?: ((searchTerm: string, signal?: AbortSignal) => Promise<T[]>) | null;
526
- /** Callback to add a new option when isAddNewAllowed is true */
527
- 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;
528
583
  /**
529
584
  * Intercept keyboard input before the built-in handling. Runs on every keydown (open or
530
585
  * closed) with a {@link MultiSelectKeydownContext} carrying the event, current state, and a
@@ -599,10 +654,14 @@ export declare class MultiSelectElement<T = any> extends BlissElement<MultiSelec
599
654
  }, {
600
655
  readonly name: "change";
601
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.";
602
660
  }];
603
661
  onSelect: ((e: CustomEvent<MultiSelectEventDetail<T>>) => void) | null;
604
662
  onDeselect: ((e: CustomEvent<MultiSelectEventDetail<T>>) => void) | null;
605
663
  onChange: ((e: CustomEvent<MultiSelectEventDetail<T>>) => void) | null;
664
+ onAdd: ((e: CustomEvent<MultiSelectEventDetail<T>>) => void) | null;
606
665
  constructor();
607
666
  /**
608
667
  * Called by the browser when the surrounding <form> is reset. Clears the
@@ -660,6 +719,43 @@ export declare class MultiSelectElement<T = any> extends BlissElement<MultiSelec
660
719
  showMessage(content: string | HTMLElement, opts?: MessageOptions): void;
661
720
  /** Dismiss the transient message shown by {@link showMessage}, if any. */
662
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;
663
759
  /** Open the dropdown. */
664
760
  open(): void;
665
761
  /** Close the dropdown. */
@@ -681,14 +777,17 @@ export declare interface MultiSelectEventDetail<T = any> {
681
777
  selectedOptions: T[];
682
778
  /** Selected values array */
683
779
  selectedValues: (string | number)[];
684
- /** The option that triggered the event (for select/deselect) */
780
+ /** The option that triggered the event (for select/deselect/add) */
685
781
  option?: T;
782
+ /** The typed text that triggered the `add` event (add only) */
783
+ value?: string;
686
784
  }
687
785
 
688
786
  declare type MultiSelectEvents = {
689
787
  select: MultiSelectEventDetail;
690
788
  deselect: MultiSelectEventDetail;
691
789
  change: MultiSelectEventDetail;
790
+ add: MultiSelectEventDetail;
692
791
  };
693
792
 
694
793
  /**
@@ -936,6 +1035,10 @@ export declare class WebMultiSelect<T = any> {
936
1035
  private keyboardController;
937
1036
  private matchingIndices;
938
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;
939
1042
  private isLoading;
940
1043
  private searchDebounceTimer?;
941
1044
  private searchAbortController?;
@@ -988,6 +1091,7 @@ export declare class WebMultiSelect<T = any> {
988
1091
  private selectedPopover;
989
1092
  private documentKeydownHandler;
990
1093
  private documentClickHandler;
1094
+ private overlayCoord;
991
1095
  /**
992
1096
  * Generic field extractor with the precedence:
993
1097
  * tuple short-circuit -> member property -> callback -> fallback
@@ -1184,6 +1288,25 @@ export declare class WebMultiSelect<T = any> {
1184
1288
  * chevron/toggle — every node is just a normal, selectable option.
1185
1289
  */
1186
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;
1187
1310
  private highlightMatch;
1188
1311
  private groupOptions;
1189
1312
  /** Whether the input currently functions as a usable search field (drives placeholder wording). */
@@ -1243,6 +1366,8 @@ export declare class WebMultiSelect<T = any> {
1243
1366
  private focusLast;
1244
1367
  private focusPageUp;
1245
1368
  private focusPageDown;
1369
+ /** Move keyboard focus onto the empty-state "add new" prompt (the only actionable row). */
1370
+ private focusAddNewPrompt;
1246
1371
  private focusNextMatch;
1247
1372
  private focusPreviousMatch;
1248
1373
  /** Lazily build (and cache) the imperative facade passed to `keydownCallback`. Bound to the
@@ -1250,8 +1375,64 @@ export declare class WebMultiSelect<T = any> {
1250
1375
  private getKeyboardController;
1251
1376
  /** Clear the search box (both the main input and the fullscreen search) and reset the visible
1252
1377
  * list. Shared by Escape and the keyboard controller. */
1253
- 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;
1254
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;
1255
1436
  private toggleOption;
1256
1437
  /**
1257
1438
  * The single funnel for an interactive (user-initiated) selection. Consults
@@ -1270,6 +1451,13 @@ export declare class WebMultiSelect<T = any> {
1270
1451
  * Returns true if the option was deselected, false if the veto blocked it.
1271
1452
  */
1272
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
+ */
1273
1461
  private handleAddNew;
1274
1462
  private selectOption;
1275
1463
  private deselectOption;
@@ -1555,6 +1743,8 @@ export declare class WebMultiSelect<T = any> {
1555
1743
  */
1556
1744
  updateOptions(partial: Partial<MultiSelectConfig<T>>): boolean;
1557
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;
1558
1748
  get selectedValue(): string | number | (string | number)[] | null;
1559
1749
  getValue(): string | number | (string | number)[] | null;
1560
1750
  /**