Snapshot taken from the working tree on main at v1.8.6. The library has matured well; this is a triage for simplification, deduplication, and a handful of real bugs uncovered along the way. Findings are ordered by impact, not by file.
Total source: ~6.5k lines (TS: 4.4k, CSS: 2.0k).
Done:
- ✅ §3.2 / §4 — Single
ATTRIBUTE_TABLEinweb-component.tsis now the source of truth. Each entry maps{ attr, key, parser, default?, enumValues? }. DrivesobservedAttributes, the initial parse ininitializePicker, andattributeChangedCallback. Eliminated the hand-coded ~80-line attribute → option block ininitializePickerand the parallel hand-listedobservedAttributesarray. - ✅ §3.1 / bug 1.7 — New
picker.updateOptions(partial)onWebMultiSelect. Merges intothis.options, applies cheap structural toggles in place (ms--no-checkboxesclass,ms-wrapper--inlinetoggle for badges position, input.placeholder/readOnly/style.display, hint textContent), refreshesallOptionsifoptionschanged, and re-renders. Returnstrueon success,falsefor genuinely structural changes (the only one currently is adding/removing the hint element). Caller falls back to full reinit when it seesfalse. - ✅
attributeChangedCallbacknow goes through the table →updateOptionsinstead of always doingdestroy() + initializePicker(). Changingplaceholder,max-height,badges-display-modeetc. no longer tears down the dropdown / popover / virtual scroll state. - ✅ §3.3 — All 24 callback/data setters (e.g.
getValueCallback,renderBadgeContentCallback,actionButtons,options,searchCallback) now route throughupdatePicker(partial)instead ofreinitialize(). The full destroy + rebuild path is reserved for thesearchHint-add-or-remove case via theupdateOptionsreturn value. - ✅ Bonus:
show-debug-infoattribute now toggles the debug panel without rebuilding the picker. - ✅ Build clean throughout. Dev server starts cleanly. No public API surface changed.
Honest line-count impact for Phase 4:
This phase added lines net (+189) — the architectural win is the point, not LOC. What grew:
multiselect.ts: 2125 → 2207 (+82) —updateOptionsmethod (~75 lines)web-component.ts: 1006 → 1113 (+107) — ATTRIBUTE_TABLE (~60 lines),parseAttrValue(~25),MEMBER_PROPERTY_FALLBACKS(~10),parseAttributesFromTable(~10),updatePickerhelper (~10). ExistinginitializePickershrank ~30 lines but the new infrastructure outweighs that.
The setter boilerplate (24 × ~5 lines) wasn't reduced — each setter still needs to assign its _field and call updatePicker. A Proxy-based meta-pattern could collapse them, but that's much riskier and not worth it here. The architectural fix (live updates instead of destroy+rebuild) is what mattered.
Cumulative across Phase 1 + 2 + 3 + 4:
multiselect.ts: 2567 → 2207 (−360 lines, −14%)web-component.ts: 1011 → 1113 (+102 lines — table + helpers)- new
tooltip.ts: 158 lines types.ts: 371 → 366 (−5)- TS total: 4519 → 4415 (−104 lines net, with new infrastructure layered in)
- UMD bundle: 136.71 → 134.53 kB (−2.2 kB net)
Behavioral changes worth knowing about:
- Changing an attribute via JS (
el.setAttribute('placeholder', ...)) or via reactive framework binding no longer destroys and rebuilds the dropdown. Selection state, scroll position, focused index, virtual-scroll buffer all survive. - Setting a callback via JS property (
el.getValueCallback = fn) likewise no longer rebuilds. - The two attributes that still trigger full reinit: any structural change involving the
searchHintelement (going from undefined to defined or vice versa), andinitial-valuescontinues to be init-time only. - The two parallel attribute parsers issue (§4) is half-resolved: the web-component layer now uses the table, but the
multiselect.tsconstructor still readsdataset.*directly when the core class is instantiated without the web component. Defaults could still drift between the two paths, just less so. Worth a follow-up to share a defaults table, but not urgent.
Done:
- ✅ §2.5 — Three tooltip implementations (badge text, badge-remove button, action button) collapsed into a single
Tooltipclass in new filesrc/tooltip.ts(158 lines). One callsite, one cleanup, one mental model. The action-button-tooltip leak (was using localletvars instead of tracked timeouts) is fixed as a side effect — the new class always tracks its own timers. - ✅ §2.6 —
positionDropdownandpositionSelectedPopovercollapsed into a sharedanchorFloatingPanel(panel, opts)helper. Both panels now share placement-locking, width-syncing, and Floating UI middleware in one place.positionHintstays separate (its anti-flip / opposite-of-dropdown logic doesn't fit the shared shape). - ✅ Bonus fix — main-container badge tooltips and popover badge tooltips used to share the same map keys (option
value), so opening the popover overwrote the main container's tracked tooltip handles and they never got cleaned up. Fixed by prefixing popover tooltip IDs withpopover-and adding cleanup inhideSelectedPopover. - ✅ Bonus fix — action-button tooltip IDs no longer use
Date.now(), which previously made every render generate fresh IDs and accumulated handles in the map. Now keyed bydata-button-indexfor stable replace-on-re-render. - ✅ Build clean. Bundle dropped further (UMD: 133.65 → 131.54 kB).
Net code reduction (Phase 1 + 2 + 3):
multiselect.ts: 2567 → 2125 (−442 lines, −17%)tooltip.ts: new, 158 lines (extracted from above)web-component.ts: 1011 → 1006 (−5)types.ts: 371 → 366 (−5)- TS total: 4519 → 4226 (−293 lines net, with cleaner separation of concerns)
- UMD bundle: 136.71 → 131.54 kB (−5.2 kB, ~3.8% smaller)
Not started (deferred):
- Phase 4 — Attribute pipeline rewrite (§3, §4, bug 1.7) — biggest leverage, biggest risk.
- Cosmetic items in §6.7 (decisions on remaining unwired CSS vars).
- §6.4 / §6.5 (CSS scrollbar dedup, debug.css hardcoded colors).
Done:
- ✅ §2.1 — Seven near-identical
getItem*methods replaced with a singleextractField<R>(item, opts)helper. EachgetItem*is now a 5-line config call. Net: ~95 lines removed from data-extraction. - ✅ §2.2 — Action-button rendering extracted to
renderActionsHTML();renderDropdownandrenderDropdownVirtualno longer carry the 33-line duplicated forEach block. - ✅ §2.3 — Three badge HTML render sites (
badgesmode,partialmode, popover) collapsed into onerenderBadgeHTML(option, ctx)helper that takes aBadgeContentRenderContext. The popover-specific callbacks (renderSelectedItemContentCallback,getSelectedItemClassCallback) are selected based onctx.isInPopover. The standalonerenderBadgeForPopovermethod is gone. - ✅ §2.4 — Six focus methods (
focusNext/Previous/First/Last/PageUp/PageDown) now one-line wrappers over a singlefocusBy(compute)helper.focusNextMatch/focusPreviousMatchalso use it. - ✅ §2.7 —
selectOption/deselectOption/selectAll/clearAllall delegate to a singlecommit({ added?, removed? })helper that handles therenderDropdown+renderBadges+updateHiddenInput+ per-item callbacks + once-onlychangeCallbackpattern. - ✅ §2.8 — Three identical
selectedValuesIIFEs inweb-component.ts's event-dispatch chain replaced with onecollectSelectedValues()helper. - ✅ Build clean after every refactor; bundle shrunk slightly (UMD: 136.71 → 133.65 kB).
Net code reduction so far (Phase 1 + 2):
multiselect.ts: 2567 → 2372 (−195 lines)web-component.ts: 1011 → 1006 (−5 lines)types.ts: 371 → 366 (−5 lines)- TS total: 4519 → 4315 (−204 lines, ~4.5% smaller, with new helpers added)
Not started (deferred to later phases):
- Phase 3 — Tooltip class consolidation (§2.5) and Floating-UI position helpers (§2.6).
- Phase 4 — Attribute pipeline rewrite (§3, §4, bug 1.7).
Done:
- ✅ All bugs in §1 (1.1–1.6).
- ✅ Noisy emoji-prefixed
info/warnlogging inrenderBadges/close/toggleOptionand per-framegetComputedStylein tooltip positioning (§5). - ✅ CSS — fixed self-reference (
--ms-badge-bg-hover: var(--ms-badge-bg-hover)) and the duplicate-block declarations (§6.2, §6.3). - ✅ CSS — wired previously-unused per-component override hooks into the actual rules so they do something when overridden:
--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. (Wired through their existing*-bordershorthands in_variables.css.) - ✅ Dead exports — removed unused
MultiSelectOption/MultiSelectOptionsimport inmultiselect.ts; deleted unusedMultiSelectDataItemtype. - ✅ CLAUDE.md drift — replaced SCSS architecture description with current CSS structure; removed the
$ms-*SCSS-variables claim and the Sass build-tool entry; updated "Adding New CSS Variables" guidance. - ✅ Build passes clean (no TS errors, no warnings).
Decision changes from the original plan:
- Did not delete unused per-component override variables. They're public theming surface; the right fix is to wire them, not delete them. Wired the obvious ones (border colors above). Some declared vars remain unwired (e.g.
--ms-text-color-2,--ms-accent-color-active,--ms-more-badge-*, the unused typography scale,--ms-input-size-md-*, the--ms-action-btn-*naming-mismatch siblings) — see §6.7 below for the remaining list and how to handle each.
Not started (deferred to later phases):
- §1.7 (full reinit on every attribute change) — moved to Phase 4.
- §6.4 (scrollbar pattern triplicated across three partials) — small but visual-impact, defer.
- §6.5 (hardcoded debug colors in
_debug.css) — defer. - §8 —
MultiSelectOptionandMultiSelectOptionsinterfaces still exported (deprecated). Removing is a breaking change; defer to a major bump.
src/multiselect.ts:1908 writes the inline style:
... --ml-badge-height-virtual: ${badgeHeight}px; ...but src/css/_tooltips-popover.css:160-162 reads --ms-badge-height-virtual. The override never lands, so the configured badgeHeight option is silently ignored and the 36px fallback is always used in the virtual popover. Fix: rename --ml- → --ms-.
src/virtual-scroll.ts:209:
const itemsChanged = items !== this.items && items.length !== this.items.length;The comment one line above says "different array OR different length". When a search returns a fresh array with the same length as the current one (very common — e.g., a different search term producing the same result count), itemsChanged is false and the scroll position is not reset. Fix: change && to ||.
src/multiselect.ts:2422-2427:
const actionConfig = this.options.actionButtons?.find(btn => {
if (btn.action === 'custom') {
return buttonElement.dataset.customAction === buttonElement.dataset.action;
}
return btn.action === action;
});buttonElement.dataset.customAction is never set anywhere in the codebase (search confirms it). The btn.action === 'custom' branch is therefore always undefined === 'custom' → false, so no custom action button ever gets a tooltip attached. Additionally, even if it worked, multiple custom buttons share action="custom" and would collide. Fix: stamp a unique data-button-index (or similar) on the rendered button and look up by that.
1.4 handleDropdownClick finds custom buttons by text content — ✅ FIXED (both sites now use data-button-index)
src/multiselect.ts:1276-1278:
const button = this.options.actionButtons?.find(btn =>
btn.action === 'custom' && btn.text === actionBtn.textContent?.trim()
);Two issues: (a) two custom buttons with the same text label collide; (b) when getTextCallback provides the text dynamically, btn.text may be empty/stale and the lookup fails. Same fix as 1.3 — index by data-button-index.
src/multiselect.ts:1845, 1878 hardcode const threshold = 100; for the popover's virtual-scroll cutoff while the dropdown reads it from options.virtualScrollThreshold. Inconsistent and undocumented — large selection counts pop into virtual mode even when the user has tuned the option for the dropdown.
selectOption / deselectOption fire selectCallback / deselectCallback per item, but selectAll (line 1598) and clearAll (line 1616) fire only changeCallback once. Consumers wiring per-item analytics or side effects via selectCallback will silently miss bulk actions. Decide on the contract and make it consistent.
Resolution: selectAll now fires selectCallback for each newly-added option (skipping items already selected and items disabled). clearAll fires deselectCallback for each removed option. changeCallback still fires once at the end (and is skipped entirely if nothing changed).
src/web-component.ts:143-151 calls picker.destroy() + initializePicker() for any observed attribute except initial-values. Changing placeholder or max-height tears down all DOM, event listeners, virtual scroll state, the dropdown, and the popover. Selection state is preserved only because _options is held on the element — but _declarativeSelectedValues is not re-applied on rebuild (it's consumed in connectedCallback only), so reactive frameworks toggling attributes will lose initial selection.
multiselect.ts is 2,567 lines. A large fraction is mechanical duplication. Rough estimate: a focused refactor pass could remove 400-600 lines without changing behavior.
getItemValue, getItemDisplayValue, getItemBadgeDisplayValue, getItemSearchValue, getItemIcon, getItemSubtitle, getItemGroup, getItemDisabled (lines 75-219) all follow the same pattern: tuple short-circuit → member lookup → callback → fallback. Collapse into:
private extract<R>(item: T, opts: {
member?: string;
callback?: (item: T) => R;
tuple?: 0 | 1; // which tuple slot to pull
fallback: R;
transform?: (v: any) => R;
}): R { ... }This deletes ~150 lines and makes adding new extractable fields a one-liner.
renderDropdown (lines 472-504) and renderDropdownVirtual (lines 565-597) contain a 33-line forEach loop that is byte-for-byte identical. Extract to private renderActionsHTML(): string. Same for the empty-state HTML.
renderBadges lines 808-842 (badges mode), 851-882 (partial mode), and renderBadgeForPopover lines 1951-1986 share the same shape: pick content (callback vs default), pick classes (callback vs default), wrap in <div class="ms__badge"><span class="ms__badge-text">…</span><button class="ms__badge-remove" …></button></div>. Extract a renderBadgeHTML(option, { mode, isInPopover }) helper.
focusNext, focusPrevious, focusFirst, focusLast, focusPageUp, focusPageDown (lines 1406-1480) are each 5 lines of guard + one expression for the new index + render + scroll. Collapse to:
private focusBy(compute: (current: number, total: number) => number): void { ... }
focusNext() { this.focusBy((i, n) => Math.min(n - 1, i + 1)); }
focusPageUp() { this.focusBy((i) => Math.max(0, i - 10)); }
// ...createTooltipForElement, createRemoveButtonTooltip, createActionButtonTooltip (and their position* / cleanup* / destroyAll* siblings) all do:
- create
<div class="ms__badge-tooltip"> - show/hide handlers with timeout pairing
autoUpdate(ref, tooltip, computePosition(...))- tracked cleanup map
There are essentially three copies of the same ~80-line tooltip lifecycle. Worse: the action-button variant doesn't even use the tracked-timeout maps (it uses local let vars), so it leaks differently from the badge variants. A single Tooltip class (or factory) keyed by uniqueId would eliminate ~250 lines and unify cleanup.
positionDropdown (lines 1714-1763) and positionSelectedPopover (lines 2013-2047). Same autoUpdate + computePosition + lock-on-first-placement pattern. Different only in which element + which placement state field. Extract.
this.renderDropdown();
this.renderBadges();
this.updateHiddenInput();
if (this.options.changeCallback) this.options.changeCallback(this.getSelected());A private commit() method would centralize this and make adding (e.g.) a debounced change event trivial later.
Lines 472-510 contain three identical inline IIFEs computing selectedValues:
selectedValues: (() => {
const val = this.picker?.getValue();
if (val == null) return [];
return Array.isArray(val) ? val : [val];
})()Extract to a private collectSelectedValues(): (string|number)[] helper.
The file is 1,011 lines. About 700 of those are getter/setter pairs (~50 properties × ~14 lines each). Each callback-style setter calls this.reinitialize() (full destroy + rebuild).
Recommendation, in priority order:
- Stop reinitializing on every callback assignment. The picker holds its
optionsobject by reference; apicker.updateOptions(partial)method on the core class would let setters mutate just what changed. This is also whatattributeChangedCallbackshould do (see bug 1.7). - Batch attribute → option mapping.
initializePicker(lines 367-515) hand-maps ~50 attributes by name. Drive it from a single declarative table ({ attr: 'badges-display-mode', key: 'badgesDisplayMode', type: 'enum', default: 'badges' }) and parse generically. Same table should driveobservedAttributes. Today the two lists drift independently — e.g.,'checkbox-align'and'badge-tooltip-delay'/'badge-tooltip-offset'are read ininitializePickerbut not inobservedAttributes(lines 76-101), so changing them at runtime does nothing. Real drift, real bug. ✅ The three missing entries are now inobservedAttributesas a stopgap; the table-driven approach is still the right architectural fix and is deferred to Phase 4. - Consider a generic
defineProperty-style helper for the string-attribute getter/setters (e.g.,valueMember,displayValueMember, etc., lines 560-628 — eight near-identical pairs).
web-component.ts parses getAttribute('xyz') in initializePicker. multiselect.ts constructor (lines 226-264) ALSO parses element.dataset.xyz for nearly the same set of options. The web component then puts the resolved options into the constructor, which re-defaults them against the empty dataset. Two parsers, two default tables, two opportunities for drift.
The dataset-based parsing in multiselect.ts exists because the core class is also exported for direct use (no web component). Reasonable, but the defaults should live in one place — e.g., a DEFAULT_OPTIONS const that both layers reference. Today they're hand-copied and already out of sync (e.g., actionsLayout defaults exist only in the web-component layer; badgesMaxVisible only on the web-component side).
renderBadges (lines 757-774) and close (lines 1675-1696) contain uiLogger.warn / uiLogger.info calls with emoji prefixes (✅ ❌ 🧹 🔒 ⏭️ 🔍 📞) that read like a one-off bug-hunting session, not production telemetry. Two problems:
- They run at
warn/infolevels — anyone enabling reasonable production logging will be flooded. close()ends withuiLogger.trace()(line 1711) which dumps a stack trace.
These should be debug level at most, or removed. Same flavor of noise appears in the tooltip code (positionBadgeTooltip logs computed styles for every position update — that's per-frame during autoUpdate).
Resolution: Removed the emoji-prefixed info/warn calls from renderBadges and close (the kept paths are functionally identical, just quieter). Removed the trailing uiLogger.trace() and emoji info calls in toggleOption. Removed the per-frame getComputedStyle log block in positionBadgeTooltip. The remaining loggers continue to log at debug level for legitimate diagnosis.
Declared but never var()-referenced anywhere. Includes the entire typography scale (--ms-font-size-xs/sm/base/lg/2xs, all --ms-font-weight-*, all --ms-line-height-*), the legacy text aliases (--ms-text-primary, --ms-text-secondary), most --ms-counter-* variables, all --ms-more-badge-*, and a naming-mismatch set: --ms-action-btn-* is declared but the rules use --ms-action-button-*.
Decision: the original framing of "unused = dead code" was wrong for the per-component override hooks. They're public theming surface — declaring them is the contract, even if no internal rule reads them. The fix is to wire them into rules so they're real overrides, not delete them. We did the wiring for the obvious border-color hooks (see §6.7). The remaining unwired vars need per-case judgment — also in §6.7.
Lines 125-139 (the "SEMANTIC VARIABLES" block) and lines 318-330 (the "INDIVIDUAL OPTIONS" block) both declare --ms-option-bg, --ms-option-bg-hover, --ms-option-bg-focused, --ms-option-bg-matched, --ms-option-bg-selected, --ms-option-mark-bg, --ms-badge-bg, --ms-badge-bg-hover. Drop one block.
Resolution: Removed the duplicate declarations from the lower per-section blocks; kept the canonical declarations in the SEMANTIC VARIABLES block. Added comments to the lower blocks pointing readers to where the bg/color vars live.
_variables.css:421:
--ms-badge-bg-hover: var(--ms-badge-bg-hover);References itself. Should reference --ms-accent-color-light-hover (the pattern used at line 148).
Resolution: The self-referential line was inside the duplicate block removed in §6.2. The remaining (canonical) declaration at line 148 is var(--base-hover-bg, var(--ms-input-bg)) — properly resolves.
The same scrollbar rule block is copy-pasted into _input-dropdown.css (191-210), _options.css (17-36), and _tooltips-popover.css (111-130). Extract to a single targeted selector or use CSS nesting.
Bypass theming: #f9fafb, #e5e7eb, #111827, #2563eb, #f3f4f6, #3b82f6, #ffffff. Should reference --ms-primary-bg, --ms-border-color, --ms-text-color-1, --ms-accent-color.
Several *-border-color semantic vars were declared but never wired into a CSS rule (so overriding them did nothing). Wired through their existing *-border shorthands:
| Variable | Shorthand affected |
|---|---|
--ms-hint-border-color |
--ms-hint-border |
--ms-dropdown-border-color |
--ms-dropdown-border (preserved --base-dropdown-border outer fallback) |
--ms-actions-border-color |
--ms-actions-border-bottom |
--ms-group-border-color |
--ms-group-border-top |
--ms-badge-counter-border-color |
--ms-badge-counter-border |
--ms-selected-popover-border-color |
--ms-selected-popover-border |
--ms-selected-popover-header-border-color |
--ms-selected-popover-header-border-bottom |
--ms-option-outline-color-focused |
--ms-option-outline-focused |
--ms-option-border-matched-color |
--ms-option-border-matched |
Each shorthand still defaults to the same color it had before (the per-component *-color defaults to var(--ms-border-color)), so no visual change. But now overriding multi-select { --ms-hint-border-color: red; } actually does something.
After §6.6, these variables are still declared in _variables.css but have no var() reader. Each needs an explicit decision:
Naming-mismatch siblings (rules use a slightly different name):
--ms-action-btn-bg,--ms-action-btn-color,--ms-action-btn-bg-hover,--ms-action-btn-border-color-hover— rules use--ms-action-button-*. Either rename the unused ones to-button-and wire, or delete as obsolete.--ms-counter-bg,--ms-counter-color,--ms-counter-bg-hover— rules use--ms-counter-badge-*. Same dilemma.--ms-input-text— rules use--ms-input-color. Likely redundant.--ms-loading-text-color— rules use--ms-loading-color. Likely redundant.
Explicit legacy aliases (the file flags these in a comment):
--ms-text-primary→ alias of--ms-text-color-1--ms-text-secondary→ alias of--ms-text-color-3Document as deprecated or remove on next major.
Replaced by sibling vars (file comment confirms):
--ms-more-badge-bg,--ms-more-badge-hover-bg,--ms-more-badge-active-bg—_badges-display.css:248says "Now uses .ms__badge--counter for styling, this just adds cursor". The "+X more" badge inherits--ms-badge-counter-*. Safe to remove.
--ms-input-size-md-* — md is the default size, handled by the regular --ms-input-padding/--ms-input-font-size/--ms-input-height. The xs/sm/lg/xl size variants ARE used. Either wire the md vars into a .ms__input--md class (matching the other variants), or remove for symmetry.
Probably-keep-as-theming-surface (declared base hooks, intentionally part of the API even if no internal rule uses them):
--ms-text-color-2— completes the 1/2/3/4 hierarchy. Keep.--ms-accent-color-active— completes accent normal/hover/active. Keep for parity even though no internal active state currently styles with it.--ms-input-border-width— granular input border control. Keep.
Typography/scale tokens (likely intentional design-token surface):
--ms-font-size-xs/sm/lg/2xs(base IS used) — design tokens. Keep.--ms-font-weight-normal/medium/semibold— design tokens. Keep.--ms-line-height-tight/normal(-none/-relaxedare used) — design tokens. Keep.--ms-spacing-md/lg(xs/smare used) — design tokens. Keep.--ms-toggle-color— likely a leftover that was renamed to--ms-toggle-icon-color. Remove or wire, judgment call.--ms-dropdown-box-shadow-semantic— duplicates--ms-dropdown-box-shadowwith a different default. Remove the redundant one.
CLAUDE.md documents an SCSS architecture that no longer exists:
- "src/scss/main.scss - Entry point importing all partials" — the actual file is
src/css/main.css. There are zero.scssfiles in the repo (Glob**/*.scssreturns nothing). - Lists
_variables.scssand_css-variables.scssas separate partials. The actual layer is one file:_variables.css. - States "SCSS Variables:
$ms-*prefix" — no$ms-*SCSS variables exist; everything is CSS custom properties. - References "Sass - CSS preprocessing" under "Build Tools" — Sass is not used.
- Approximate line counts in CLAUDE.md (multiselect.ts ~2500, web-component.ts ~950) vs actual (2567, 1011) — minor, but the architectural drift suggests the SCSS→CSS migration happened without updating CLAUDE.md.
Resolution: Replaced the SCSS section with the actual CSS structure (single src/css/main.css entry, 9 partials in src/css/). Removed the $ms-* claim and the Sass build-tool entry. Rewrote "Adding New CSS Variables" to reflect reality (declare in _variables.css, reference from a partial; warn against dead theming surface).
MultiSelectOption and MultiSelectOptions (types.ts:345-371) are marked @deprecated and have no runtime usage in the source — they're only imported as types in multiselect.ts:7 (and not actually used in the code below that import) and re-exported from index.ts. They are part of the public API surface, so removing them is a breaking change, but at minimum the import in multiselect.ts should be deleted.
MultiSelectDataItem<T> (types.ts:50) is exported but used nowhere — not even in the codebase as a type annotation. Likely safe to remove.
Resolution: Deleted the unused MultiSelectOption/MultiSelectOptions (and BadgesDisplayMode/BadgesThresholdMode) imports from multiselect.ts. Deleted the unused MultiSelectDataItem<T> type from types.ts. The @deprecated MultiSelectOption/MultiSelectOptions interfaces remain re-exported from index.ts (they're part of the public API surface; deletion is a breaking change deferred to a major bump).
- HTML string concatenation everywhere with
${callbackResult}interpolation —renderOption,renderBadges,renderBadgeForPopoverall interpolate user-callback output directly into HTML strings. For default-rendered text (no callback), this meansdisplayValueandsubtitleare not escaped. If a consumer feeds in unsanitized data containing<or&, it's an XSS vector. Either escape at the boundary or document loudly that callbacks are HTML-trusted. getValue()andselectedValuegetter (lines 2150 and 2134) are nearly identical — only difference isselectedValuereturnsnullif novalueMember/getValueCallbackis configured. Decide which is the public API; the other can be inlined.updateHiddenInput()removes and recreates all hidden inputs on every selection change. ForvalueFormat: 'array'with hundreds of selected items, this thrashes the DOM. Diff and update.- Counter element dual-purpose UI: the
ms__counterelement is both a visual indicator AND the click target for the popover, with custom mousedown/click handlers. Non-obvious — would benefit from either a wrapping interactive element or a comment explaining the intent. shouldUseVirtualScrolldisables for groups ("v1 limitation" comment, line 422) — long-standing TODO.badgesThreshold !== null && count > badgesThresholdat line 779 — whenbadgesThresholdisundefined(the default), the second clause doescount > undefinedwhich is alwaysfalse. Works by accident; should bebadgesThreshold != nullortypeof badgesThreshold === 'number'for clarity.- Logger emoji noise in tooltip positioning (line 2356-2364) calls
getComputedStylesix times per autoUpdate frame just to log them. Even at debug level, this stalls layout. Wrap inif (uiLogger.getLevel() <= log.levels.DEBUG)or remove. instanceIdgeneration usesMath.random().toString(36).substr(2, 9)—substris deprecated; should beslice. Cosmetic. ✅ FIXED
- Fix the bugs in section 1 — small, isolated, behavior-correcting. Especially 1.1 (CSS var typo) and 1.2 (setItems bug) are one-character fixes.
- Update CLAUDE.md to match reality — costs nothing, prevents future agent confusion.
- Delete dead exports (
MultiSelectDataItem, optionally the deprecated interfaces if you're willing to ship a minor-bump deprecation). - CSS cleanup — drop unused vars, remove the duplicate variable block (6.2), fix the self-reference (6.3), fix debug colors (6.5). Pure deletion, easy to verify with examples.
- Clean up the noisy logging (section 5) — pure deletion.
- Extract data-extractor helper (2.1) and
commit()helper (2.7) — local refactors with no API impact. - Extract action-button HTML helper (2.2) and badge HTML helper (2.3).
- Extract
Tooltipclass (2.5) — bigger refactor, but pays for itself in clarity and fixes the action-button leak. - Unify the two attribute parsers (section 4) — needs a shared defaults table; touches both files.
- Stop full-reinit on every attribute change (1.7 / web-component recommendation 1) — needs a
picker.updateOptions(partial)API and is the highest-leverage but riskiest change.
| File | Lines | Notes |
|---|---|---|
src/multiselect.ts |
2,567 | Core class. Largest dedup target. |
src/web-component.ts |
1,011 | ~70% getter/setter boilerplate. |
src/types.ts |
371 | Two deprecated interfaces still present. |
src/virtual-scroll.ts |
316 | Bug at line 209. Otherwise clean. |
src/logger.ts |
141 | Fine. |
src/index.ts |
107 | Fine. |
src/css/_variables.css |
597 | 48 unused vars, 8 dupes, 1 self-ref. |
src/css/_options.css |
366 | Scrollbar dup. |
src/css/_input-dropdown.css |
273 | Scrollbar dup. |
src/css/_badges-display.css |
251 | |
src/css/_tooltips-popover.css |
173 | Scrollbar dup; reads --ms-badge-height-virtual (bug 1.1). |
src/css/_rtl.css |
173 | |
src/css/_debug.css |
57 | Hardcoded colors. |
src/css/_base.css |
43 | |
src/css/_modifiers.css |
32 | |
src/css/main.css |
27 |