@keenmate/web-multiselect 1.8.6 → 1.10.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/README.md CHANGED
@@ -7,6 +7,28 @@ A lightweight, accessible multiselect web component with typeahead search, RTL l
7
7
 
8
8
  > **⚠️ 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 [HTML Injection (XSS) Notice](#html-injection-xss-notice) for the complete list of affected callbacks.
9
9
 
10
+ ## What's New in v1.10.0
11
+
12
+ - **`data-options` attribute on `<web-multiselect>`** — set options declaratively from HTML, no JS bootstrap required (works alongside `initial-values` for pure-HTML / server-rendered / SharePoint workbench scenarios).
13
+ - **`form.reset()` now clears the selection** — the element is now form-associated (`static formAssociated = true` + `ElementInternals` + `formResetCallback()`).
14
+ - **Dropdown / hint / selected popover no longer clipped inside scrollable ancestors** — Floating UI now uses `strategy: 'fixed'` for all three panels, so they escape `overflow: hidden|auto|scroll` containers (e.g. SharePoint Framework workbenches).
15
+ - **`initial-values` now works when options arrive after init** — values are reconciled on every `options` mutation, not just at construction.
16
+ - **Remove / close (×) buttons render as SVG masks** — pixel-centered regardless of font; color still flows through the existing `--ms-*-color` variables via `currentColor`; three new `--ms-*-icon-size` variables for theming.
17
+ - **Keyboard navigation now keeps working after a mouse click on an option** — previously, clicking an option moved focus from the search input to the option's checkbox (knocking the `keydown` listener offline) *and* left `focusedIndex` at its pre-click value, so subsequent ArrowDown / ArrowUp / Enter went nowhere visible. Click now anchors `focusedIndex` to the clicked option and refocuses the search input, so arrow keys continue from where you clicked and Enter toggles the option under the cursor.
18
+ - **Count-clear / popover-close hover backdrop now matches the rest of the component** — was a circle (`border-radius: 50%`), now a small rounded rectangle (`--ms-border-radius-sm`) consistent with every other interactive element. Themes that prefer the circle can set `--ms-count-clear-border-radius` and `--ms-selected-popover-close-border-radius` back to `50%`.
19
+ - **Keyboard `Enter` respects disabled options** — previously only the click handler did.
20
+ - **End-to-end test suite** — 114 Playwright specs across 19 fixture pages (`npm run test:e2e`).
21
+ - **`THEMING.md`** — new reference cataloguing every theme-able component state and the CSS variables that drive it.
22
+
23
+ ## What's New in v1.9.0
24
+
25
+ - **Live attribute / callback updates no longer rebuild the DOM** — `updateOptions(partial)` merges in place; selection state, scroll position, focus, and tooltips are preserved across attribute changes.
26
+ - **9 previously-dead per-component CSS override hooks are now wired** — `--ms-hint-border-color`, `--ms-dropdown-border-color`, `--ms-actions-border-color`, `--ms-group-border-color`, `--ms-badge-counter-border-color`, `--ms-selected-popover-border-color`, `--ms-selected-popover-header-border-color`, `--ms-option-outline-color-focused`, `--ms-option-border-matched-color`.
27
+ - **`selectAll` / `clearAll` now fire per-item `selectCallback` / `deselectCallback`** — consumers wiring per-item analytics or side effects no longer silently miss bulk operations.
28
+ - **New `Tooltip` class** consolidating three previous tooltip implementations; fixes a handle leak and a popover-vs-main-container collision.
29
+ - **`--base-primary-bg` theming variable** — `--ms-primary-bg` reads it first, then `--base-main-bg`, then a hardcoded default.
30
+ - Plus many fixes across custom action buttons, grouped-option focus, badge cursors, focus rings, and logging.
31
+
10
32
  ## Features
11
33
 
12
34
  - 📝 **Declarative HTML** - Use standard `<option>` and `<optgroup>` elements - no JavaScript required for simple cases!
@@ -327,11 +349,15 @@ multiselect.addNewCallback = async (value) => {
327
349
 
328
350
  - **↑ ↓** - Navigate up/down through options
329
351
  - **Ctrl+↑ Ctrl+↓** - Jump between matched items (navigate mode only)
330
- - **Enter** - Select focused option
352
+ - **Page Up / Page Down** - Move focus by 10 options at a time
353
+ - **Home / End** - Jump to first / last option
354
+ - **Enter** - Select focused option (or add new entry when `allow-add-new="true"` and the search has text)
331
355
  - **Escape** - Close popover → Clear search → Close dropdown (priority order)
332
356
  - **Tab** - Close dropdown and move to next field
333
357
  - **Type** - Filter options by search term
334
358
 
359
+ > 💡 To surface these shortcuts to your users, set the `search-hint` attribute — the hint floats above the input when focused. Example: `<web-multiselect search-mode="navigate" search-hint="Ctrl/Cmd + ↓ / ↑ to jump between matches">`.
360
+
335
361
  ## Advanced Features
336
362
 
337
363
  ### Rich Content with Icons
@@ -1787,7 +1813,9 @@ For the complete list of all available CSS variables, see:
1787
1813
  | `--ms-badge-font-size` | `0.75rem` | Badge font size |
1788
1814
  | `--ms-badge-border-radius` | `0.375rem` | Badge border radius |
1789
1815
  | `--ms-badge-remove-bg` | `var(--ms-accent-color)` | Remove button background |
1790
- | `--ms-badge-remove-color` | `var(--ms-text-color-on-accent)` | Remove button color |
1816
+ | `--ms-badge-remove-color` | `var(--ms-text-color-on-accent)` | Remove button (X) color — applied to the SVG via `currentColor` |
1817
+ | `--ms-badge-remove-icon-size` | `calc(1.0 * var(--ms-rem))` | Size of the X glyph inside the remove button |
1818
+ | `--ms-icon-remove` | (inline SVG `url(...)`) | The X mask SVG; override to swap the glyph shape (alpha-only — color comes from `--ms-badge-remove-color`) |
1791
1819
  | `--ms-badge-counter-text-bg` | `var(--ms-primary-bg)` | BadgeCounter text background ("+X more") |
1792
1820
  | `--ms-badge-counter-text-color` | `var(--ms-text-color-3)` | BadgeCounter text color |
1793
1821
  | `--ms-badge-counter-remove-bg` | `var(--ms-text-color-3)` | BadgeCounter remove button background |
@@ -270,10 +270,11 @@
270
270
  { "name": "ms-badge-remove-bg", "category": "badge", "usage": "Badge remove button background" },
271
271
  { "name": "ms-badge-remove-color", "category": "badge", "usage": "Badge remove button color" },
272
272
  { "name": "ms-badge-remove-border", "category": "badge", "usage": "Badge remove button border" },
273
- { "name": "ms-badge-remove-font-size", "category": "badge", "usage": "Badge remove button font size" },
273
+ { "name": "ms-badge-remove-font-size", "category": "badge", "usage": "Badge remove button font size (unused since 1.10.0 — kept for backward-compat)" },
274
+ { "name": "ms-badge-remove-icon-size", "category": "badge", "usage": "Badge remove X icon size (mask SVG)" },
274
275
  { "name": "ms-badge-remove-bg-hover", "category": "badge", "usage": "Badge remove button hover background" },
275
276
  { "name": "ms-badge-remove-box-shadow-focus", "category": "badge", "usage": "Badge remove button focus shadow" },
276
- { "name": "ms-icon-remove", "category": "badge", "usage": "Remove icon character" },
277
+ { "name": "ms-icon-remove", "category": "badge", "usage": "Remove icon as CSS url() to a mask-friendly SVG; color comes from currentColor" },
277
278
  { "name": "ms-badge-counter-bg", "category": "badge", "usage": "Counter badge background" },
278
279
  { "name": "ms-badge-counter-border", "category": "badge", "usage": "Counter badge border" },
279
280
  { "name": "ms-badge-counter-border-color", "category": "badge", "usage": "Counter badge border color" },
@@ -306,11 +307,12 @@
306
307
  { "name": "ms-count-clear-size", "category": "count", "usage": "Count clear button size" },
307
308
  { "name": "ms-count-clear-bg", "category": "count", "usage": "Count clear button background" },
308
309
  { "name": "ms-count-clear-color", "category": "count", "usage": "Count clear button color" },
309
- { "name": "ms-count-clear-font-size", "category": "count", "usage": "Count clear button font size" },
310
+ { "name": "ms-count-clear-font-size", "category": "count", "usage": "Count clear button font size (unused since 1.10.0 — kept for backward-compat)" },
311
+ { "name": "ms-count-clear-icon-size", "category": "count", "usage": "Count clear X icon size (mask SVG)" },
310
312
  { "name": "ms-count-clear-border-radius", "category": "count", "usage": "Count clear button border radius" },
311
313
  { "name": "ms-count-clear-bg-hover", "category": "count", "usage": "Count clear button hover background" },
312
314
  { "name": "ms-count-clear-color-hover", "category": "count", "usage": "Count clear button hover color" },
313
- { "name": "ms-icon-clear", "category": "count", "usage": "Clear icon character" },
315
+ { "name": "ms-icon-clear", "category": "count", "usage": "Clear icon as CSS url() to a mask-friendly SVG; defaults to var(--ms-icon-remove)" },
314
316
 
315
317
  { "name": "ms-tooltip-bg", "category": "tooltip", "usage": "Tooltip background" },
316
318
  { "name": "ms-tooltip-text-color", "category": "tooltip", "usage": "Tooltip text color" },
@@ -340,7 +342,8 @@
340
342
  { "name": "ms-popover-close-size", "category": "popover", "usage": "Popover close button size" },
341
343
  { "name": "ms-selected-popover-close-bg", "category": "popover", "usage": "Popover close button background" },
342
344
  { "name": "ms-selected-popover-close-color", "category": "popover", "usage": "Popover close button color" },
343
- { "name": "ms-selected-popover-close-font-size", "category": "popover", "usage": "Popover close button font size" },
345
+ { "name": "ms-selected-popover-close-font-size", "category": "popover", "usage": "Popover close button font size (unused since 1.10.0 — kept for backward-compat)" },
346
+ { "name": "ms-selected-popover-close-icon-size", "category": "popover", "usage": "Popover close X icon size (mask SVG)" },
344
347
  { "name": "ms-selected-popover-close-border-radius", "category": "popover", "usage": "Popover close button border radius" },
345
348
  { "name": "ms-selected-popover-close-bg-hover", "category": "popover", "usage": "Popover close button hover background" },
346
349
  { "name": "ms-selected-popover-close-color-hover", "category": "popover", "usage": "Popover close button hover color" },
package/dist/index.d.ts CHANGED
@@ -194,7 +194,7 @@ declare interface MultiSelectConfig<T = any> {
194
194
  isVirtualScrollEnabled?: boolean;
195
195
  /** Vertical alignment of checkboxes relative to option content */
196
196
  checkboxAlign?: 'top' | 'center' | 'bottom';
197
- /** Hint text shown above the input when focused */
197
+ /** Hint text shown above the input while the dropdown is open. */
198
198
  searchHint?: string;
199
199
  /** Placeholder text for the search input */
200
200
  searchPlaceholder?: string;
@@ -269,9 +269,11 @@ declare interface MultiSelectConfig<T = any> {
269
269
  }
270
270
 
271
271
  export declare class MultiSelectElement<T = any> extends BaseElement {
272
+ static formAssociated: boolean;
272
273
  private picker?;
273
274
  private containerElement?;
274
275
  private shadow;
276
+ private internals?;
275
277
  private _options?;
276
278
  private _valueMember?;
277
279
  private _getValueCallback?;
@@ -309,6 +311,14 @@ export declare class MultiSelectElement<T = any> extends BaseElement {
309
311
  private _changeCallback?;
310
312
  static get observedAttributes(): string[];
311
313
  constructor();
314
+ /**
315
+ * Called by the browser when the surrounding <form> is reset. Clears the
316
+ * picker's selection so the multiselect actually participates in the
317
+ * standard reset lifecycle. (Before form-association, reset was a no-op
318
+ * because the hidden inputs were re-stamped from internal state on every
319
+ * render.)
320
+ */
321
+ formResetCallback(): void;
312
322
  connectedCallback(): void;
313
323
  disconnectedCallback(): void;
314
324
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
@@ -321,8 +331,19 @@ export declare class MultiSelectElement<T = any> extends BaseElement {
321
331
  */
322
332
  private parseDeclarativeOptions;
323
333
  private _declarativeSelectedValues?;
334
+ /** Parse all observed attributes via ATTRIBUTE_TABLE into a partial config object. */
335
+ private parseAttributesFromTable;
324
336
  private initializePicker;
325
337
  private reinitialize;
338
+ /**
339
+ * Apply a partial config update to the live picker. Falls back to a full reinit if the
340
+ * picker can't apply the change in place (e.g. adding/removing the `searchHint` element).
341
+ * No-op if the picker hasn't been initialized yet — the next `initializePicker` will pick
342
+ * up the new programmatic state.
343
+ */
344
+ private updatePicker;
345
+ /** Normalize the picker's getValue() return into the array form expected by event detail. */
346
+ private collectSelectedValues;
326
347
  get options(): T[] | undefined;
327
348
  set options(value: T[] | undefined);
328
349
  set valueMember(value: string | null);
@@ -492,9 +513,10 @@ export declare type SearchInputMode = 'normal' | 'readonly' | 'hidden';
492
513
  export declare type SearchMode = 'filter' | 'navigate';
493
514
 
494
515
  /**
495
- * Set log level for a specific category
496
- * @param category Category logger to configure (e.g., 'MULTISELECT:UI')
497
- * @param level Log level to set ('trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent')
516
+ * Set log level for a specific category. Accepts either the full prefixed name
517
+ * (e.g. `MULTISELECT:UI`) or the bare suffix (`UI`) for convenience.
518
+ * @param category Category logger name; bare names (UI/DATA/INIT/INTERACTION) are normalized to the prefixed form.
519
+ * @param level Log level ('trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent')
498
520
  */
499
521
  export declare function setCategoryLevel(category: string, level: string): void;
500
522
 
@@ -534,12 +556,7 @@ export declare class WebMultiSelect<T = any> {
534
556
  private dropdownCleanup;
535
557
  private hintCleanup;
536
558
  private selectedPopoverCleanup;
537
- private badgeTooltips;
538
- private badgeTooltipCleanups;
539
- private badgeTooltipShowTimeouts;
540
- private badgeTooltipHideTimeouts;
541
- private actionButtonTooltips;
542
- private actionButtonTooltipCleanups;
559
+ private tooltips;
543
560
  private virtualScroll;
544
561
  private optionsContainer;
545
562
  private selectedPopoverVirtualScroll;
@@ -554,41 +571,31 @@ export declare class WebMultiSelect<T = any> {
554
571
  private documentKeydownHandler;
555
572
  private documentClickHandler;
556
573
  /**
557
- * Extract value/ID from item
558
- * Precedence: tuple[0] -> valueMember -> getValueCallback -> '[N/A]'
574
+ * Generic field extractor with the precedence:
575
+ * tuple short-circuit -> member property -> callback -> fallback
576
+ *
577
+ * Tuple handling:
578
+ * - `tupleIndex` (0 | 1): for `[key, value]` items, return that slot.
579
+ * - `tupleSkip: true`: for any tuple, skip directly to fallback (used for icon/subtitle/group/disabled —
580
+ * fields that don't make sense on a 2-element array).
581
+ * - neither: tuples flow through the member/callback/fallback chain as if they were objects.
582
+ *
583
+ * `transform` is applied to tuple-slot and member-property reads (not to callback returns or the fallback),
584
+ * so e.g. you can pass `String` to coerce numeric members to strings while letting a typed callback return its
585
+ * own type unchanged.
559
586
  */
587
+ private extractField;
560
588
  private getItemValue;
561
- /**
562
- * Extract display value from item
563
- * Precedence: tuple[1] -> displayValueMember -> getDisplayValueCallback -> '[N/A]'
564
- */
565
589
  private getItemDisplayValue;
566
590
  /**
567
- * Extract badge display value from item
568
- * Precedence: getBadgeDisplayCallback -> getItemDisplayValue()
569
- * This allows customizing badge text separately from dropdown display text
591
+ * Badge display falls back to the regular display value rather than '[N/A]', so consumers can override badge
592
+ * text independently. Doesn't fit the extractField shape (no tuple/member layer of its own).
570
593
  */
571
594
  private getItemBadgeDisplayValue;
572
- /**
573
- * Extract search value from item
574
- * Precedence: searchValueMember -> getSearchValueCallback -> displayValue
575
- */
576
595
  private getItemSearchValue;
577
- /**
578
- * Extract icon from item
579
- */
580
596
  private getItemIcon;
581
- /**
582
- * Extract subtitle from item
583
- */
584
597
  private getItemSubtitle;
585
- /**
586
- * Extract group from item
587
- */
588
598
  private getItemGroup;
589
- /**
590
- * Extract disabled state from item
591
- */
592
599
  private getItemDisabled;
593
600
  constructor(element: HTMLElement, options?: Partial<MultiSelectConfig<T>>);
594
601
  private init;
@@ -607,6 +614,11 @@ export declare class WebMultiSelect<T = any> {
607
614
  * Render dropdown with virtual scrolling
608
615
  */
609
616
  private renderDropdownVirtual;
617
+ /**
618
+ * Render the Select All / Clear All / custom action buttons row.
619
+ * Returns the empty string if multiple-select is off or no buttons are configured.
620
+ */
621
+ private renderActionsHTML;
610
622
  private renderOption;
611
623
  private highlightMatch;
612
624
  private groupOptions;
@@ -617,52 +629,113 @@ export declare class WebMultiSelect<T = any> {
617
629
  private handleDropdownClick;
618
630
  private handleBadgeClick;
619
631
  private handleClickOutside;
632
+ /**
633
+ * Move focus by computing a new index from (current, total).
634
+ * Returning -1 from `compute` is a no-op (used for empty list / no match).
635
+ */
636
+ private focusBy;
620
637
  private focusNext;
621
638
  private focusPrevious;
622
639
  private focusFirst;
623
640
  private focusLast;
624
- private focusNextMatch;
625
- private focusPreviousMatch;
626
641
  private focusPageUp;
627
642
  private focusPageDown;
643
+ private focusNextMatch;
644
+ private focusPreviousMatch;
628
645
  private scrollToFocused;
629
646
  private toggleOption;
630
647
  private handleAddNew;
631
648
  private selectOption;
632
649
  private deselectOption;
633
650
  private selectAll;
634
- private clearAll;
651
+ clearAll(): void;
652
+ /**
653
+ * Re-render and fire callbacks after a selection state change.
654
+ * `added` / `removed` drive per-item select/deselect callbacks.
655
+ * `changeCallback` fires once if anything actually changed.
656
+ */
657
+ private commit;
635
658
  private open;
636
659
  private close;
660
+ /**
661
+ * Anchor a floating panel (dropdown or selected-items popover) below/above the input with
662
+ * placement-locking and width-syncing. Returns the `autoUpdate` cleanup.
663
+ *
664
+ * Both panels share: anchor on input, sync width, default to 'bottom-start', flip on first
665
+ * compute then lock the resulting placement, optionally clamp by dropdownMin/MaxWidth.
666
+ */
667
+ private anchorFloatingPanel;
637
668
  private positionDropdown;
638
669
  private positionHint;
639
670
  private parseInitialSelection;
671
+ /**
672
+ * Resolve any `selectedValues` entries that don't yet have a matching
673
+ * `selectedOptions` object by looking them up in the current `allOptions`.
674
+ * Idempotent; safe to call after init *and* after `options` is replaced
675
+ * (e.g., async fetch, `searchCallback` result, or late `element.options =`
676
+ * assignment). Without this, `initial-values` declared before options
677
+ * arrive ends up with phantom values that `getValue()` can never report.
678
+ */
679
+ private reconcileSelectedOptions;
640
680
  private toggleSelectedPopover;
641
681
  private showPopover;
642
682
  private hideSelectedPopover;
643
683
  private renderSelectedPopover;
644
684
  private renderSelectedPopoverVirtual;
645
- private renderBadgeForPopover;
685
+ /**
686
+ * Render a removable badge for a selected option (used by the badges/partial display modes
687
+ * and by the selected-items popover).
688
+ *
689
+ * - In the popover, `renderSelectedItemContentCallback` and `getSelectedItemClassCallback` win
690
+ * over the regular badge callbacks; that's how consumers customize popover items independently.
691
+ * - The `data-value` and aria-label both go through `getItemBadgeDisplayValue` so badge text and
692
+ * accessible name stay in sync.
693
+ */
694
+ private renderBadgeHTML;
646
695
  private handleSelectedPopoverClick;
647
696
  private positionSelectedPopover;
648
697
  private updateHiddenInput;
649
698
  private getFormValue;
650
699
  getSelected(): T[];
651
700
  setSelected(values: (string | number)[]): void;
701
+ /**
702
+ * Merge a partial config update into the live picker without tearing down the DOM.
703
+ *
704
+ * Handles the cheap structural toggles inline (no-checkboxes class, badges-position class,
705
+ * input placeholder, search-input mode) and re-renders dropdown + badges + hidden inputs.
706
+ *
707
+ * Returns `true` if the change could be applied in place. Returns `false` for changes that
708
+ * truly require rebuilding the DOM scaffolding (currently: adding/removing the `searchHint`
709
+ * element, since it's only created in `buildHTML` if a hint string was provided). The caller
710
+ * should fall back to destroy + re-init in that case.
711
+ */
712
+ updateOptions(partial: Partial<MultiSelectConfig<T>>): boolean;
652
713
  get selectedItem(): T | null;
653
714
  get selectedValue(): string | number | (string | number)[] | null;
654
715
  getValue(): string | number | (string | number)[] | null;
716
+ /**
717
+ * Create or replace a tracked tooltip with the given id. Replacing destroys the old one,
718
+ * which is the normal flow when re-rendering badges/actions.
719
+ */
720
+ private spawnTooltip;
721
+ private destroyAllTooltips;
722
+ /** Build the badge-text tooltip content (callback overrides; default = displayValue + optional subtitle on next line). */
723
+ private buildBadgeTooltipContent;
724
+ /** Build the remove-button tooltip text (callback > format string with {0} > "Remove {name}"). */
725
+ private buildRemoveButtonTooltipText;
655
726
  private attachBadgeTooltips;
656
- private createTooltipForElement;
657
- private createRemoveButtonTooltip;
658
- private positionBadgeTooltip;
659
- private cleanupBadgeTooltip;
660
- private destroyAllBadgeTooltips;
661
727
  private attachActionButtonTooltips;
662
- private createActionButtonTooltip;
663
- private positionActionButtonTooltip;
664
- private cleanupActionButtonTooltip;
728
+ /**
729
+ * Destroy only the action-button tooltips. Called from `renderDropdown`/`renderDropdownVirtual`
730
+ * before rebuilding the actions row, so per-button tooltip state doesn't leak.
731
+ */
665
732
  private destroyAllActionButtonTooltips;
733
+ /**
734
+ * Destroy main-badges-container tooltips. Called before re-rendering the badges container.
735
+ * Popover tooltips (prefixed `popover-`) survive — they're owned by the popover lifecycle and
736
+ * cleaned up in `hideSelectedPopover`. Action-button tooltips (prefixed `action-`) survive too.
737
+ */
738
+ private destroyAllBadgeTooltips;
666
739
  destroy(): void;
667
740
  }
668
741