@bluevolt-tech/lumen 2.6.0 → 2.6.1

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.
@@ -93,6 +93,14 @@ export declare class BvSearchPicker extends HTMLElement {
93
93
  * cleared when the user next opens the panel or explicitly dismisses.
94
94
  */
95
95
  private _clearedReason;
96
+ /**
97
+ * BTN-11916 — roving-tabindex position in the basket. The basket is a
98
+ * composite widget: one tab stop for the whole group, Arrow keys to move
99
+ * between the remove buttons inside it. Without this, a basket of 40 picked
100
+ * users costs 40 tab presses to step over — and the basket is the *unbounded*
101
+ * list here, since the results list is capped by the consumer.
102
+ */
103
+ private _basketActiveIdx;
96
104
  private _labelEl;
97
105
  private _triggerEl;
98
106
  private _panelEl;
@@ -211,6 +219,28 @@ export declare class BvSearchPicker extends HTMLElement {
211
219
  * would bounce Shift+Tab back into the container's focus handler.
212
220
  */
213
221
  private _setActiveRow;
222
+ /**
223
+ * BTN-11916 — the basket's counterpart to `_setActiveRow`, and deliberately
224
+ * a different mechanism. The results list is a `role="listbox"`: one focus
225
+ * holder, `aria-activedescendant` pointing at the active option. The basket
226
+ * is not a listbox — nothing is being *chosen* there, each row carries its
227
+ * own action — so it uses a roving tabindex over the native buttons instead,
228
+ * which keeps their button semantics intact.
229
+ *
230
+ * Clamps, so callers may pass an index that has just gone out of range after
231
+ * a removal. When the basket empties there is no button left to hold focus:
232
+ * `moveFocus` then falls back to the search box rather than letting the
233
+ * document drop focus to <body>.
234
+ */
235
+ private _setActiveBasketRow;
236
+ /**
237
+ * BTN-11916 — Arrow Up/Down (plus Home/End) rove between the basket's remove
238
+ * buttons. Enter and Space are NOT handled here on purpose: these are native
239
+ * `<button>`s, so the browser already turns both into a click, and the click
240
+ * handler owns both the removal and the follow-up focus move. Intercepting
241
+ * them would mean re-implementing activation we get for free.
242
+ */
243
+ private _onBasketKeyDown;
214
244
  /**
215
245
  * BTN-10322 — Arrow Up/Down navigation between result rows; Enter or Space
216
246
  * adds the currently-focused row to the staged basket. Home/End jump to
@@ -1,5 +1,5 @@
1
1
  // css-inline:/home/runner/work/BV_lumen/BV_lumen/components/bv-search-picker/bv-search-picker.css
2
- var bv_search_picker_default = ":host {\n display: block;\n position: relative;\n font-family: var(--bv-font-family-base);\n}\n\n:host([hidden]) {\n display: none;\n}\n\n/* \u2500\u2500 Field label \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.field-label {\n display: block;\n font-size: var(--bv-font-size-sm);\n line-height: 1.5;\n font-weight: var(--bv-font-weight-semibold);\n color: var(--bv-color-neutral-800);\n margin-bottom: var(--bv-spacing-xs-plus);\n}\n\n.field-label:empty {\n display: none;\n}\n\n/* \u2500\u2500 Trigger \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.trigger {\n display: flex;\n align-items: center;\n gap: var(--bv-spacing-xs);\n /* Shrink-to-fit its own label by default (a compact pill, not a full-width\n dropdown) \u2014 consumers that need a uniform width across a row of mixed\n field types (e.g. sitting alongside bv-input-dropdown) can override this\n rather than the component silently always filling its host's box. */\n width: var(--bv-search-picker-trigger-width, max-content);\n /* Also overridable \u2014 a consumer that shrinks the host below 120px (e.g. to\n fit a fixed viewport floor) needs this floor to shrink too, or the\n trigger silently overflows its own host box and overlaps whatever sits\n next to it. */\n min-width: var(--bv-search-picker-trigger-min-width, 120px);\n max-width: 220px;\n box-sizing: border-box;\n min-height: var(--bv-control-height-lg);\n border: 1px solid var(--bv-color-neutral-300);\n border-radius: var(--bv-radius-md);\n background: var(--bv-color-neutral-0);\n padding: 5px var(--bv-spacing-sm);\n cursor: pointer;\n font-family: inherit;\n transition:\n border-color var(--bv-transition-fast),\n box-shadow var(--bv-transition-fast);\n}\n\n.trigger:focus-visible {\n outline: none;\n border-color: var(--bv-color-primary);\n box-shadow: 0 0 0 3px var(--bv-color-primary-light);\n}\n\n:host([open]) .trigger {\n border-color: var(--bv-color-primary);\n box-shadow: 0 0 0 3px var(--bv-color-primary-light);\n}\n\n:host([disabled]) .trigger {\n background: var(--bv-color-neutral-100);\n cursor: not-allowed;\n}\n\n.trigger-pill {\n display: inline-flex;\n align-items: center;\n gap: var(--bv-spacing-xs);\n height: 26px;\n /* Symmetric padding so the pill still looks balanced with the clear button\n hidden (count === 0) \u2014 an asymmetric right pad only reads correctly when\n the clear button is actually there to visually fill the extra space. */\n padding: 0 var(--bv-spacing-sm-plus);\n border-radius: var(--bv-radius-full);\n background: var(--bv-color-primary-light);\n color: var(--bv-color-primary-dark);\n font-size: var(--bv-font-size-sm);\n font-weight: var(--bv-font-weight-semibold);\n white-space: nowrap;\n /* Without this, this flex item's default min-width:auto refuses to shrink\n below its content's natural width \u2014 the same reason .trigger needs its\n own min-width overridden (see bv-search-picker.ts) \u2014 and the pill spills\n out past a narrowed .trigger instead of truncating with it. */\n min-width: 0;\n}\n\n.trigger-pill-text {\n overflow: hidden;\n text-overflow: ellipsis;\n min-width: 0;\n}\n\n.trigger-pill.mode-exclude {\n background: var(--bv-color-danger-light);\n color: var(--bv-color-danger-dark);\n}\n\n.trigger-pill-clear {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 16px;\n height: 16px;\n flex-shrink: 0;\n padding: 0;\n border: none;\n border-radius: 50%;\n background: none;\n color: inherit;\n cursor: pointer;\n opacity: 0.7;\n}\n\n/* The `display` rule above wins over the UA's [hidden]{display:none} (author\n origin beats user-agent origin regardless of specificity) \u2014 restate it. */\n.trigger-pill-clear[hidden] {\n display: none;\n}\n\n.trigger-pill-clear:hover {\n /* Overlay derived from the neutral-900 token (matches the pattern\n bv-alert-banner uses for its close-btn hover \u2014 token + color-mix, no\n hardcoded rgba). Works over both include (primary-light) and exclude\n (danger-light) pill backgrounds. */\n background: color-mix(in srgb, var(--bv-color-neutral-900) 12%, transparent);\n opacity: 1;\n}\n\n/*\n * The pill-clear is a `span[role=\"button\"]` (not a native <button>) so that\n * it can nest inside the trigger button without producing invalid HTML.\n * Native <button> would give us a visible focus ring for free; role=\"button\"\n * doesn't, so paint one explicitly for keyboard users.\n */\n.trigger-pill-clear:focus-visible {\n outline: 2px solid var(--bv-color-primary);\n outline-offset: 1px;\n opacity: 1;\n}\n\n.trigger-caret {\n display: flex;\n align-items: center;\n margin-left: auto;\n color: var(--bv-color-neutral-500);\n flex-shrink: 0;\n transition: transform var(--bv-transition-fast);\n}\n\n:host([open]) .trigger-caret {\n transform: rotate(180deg);\n}\n\n/* \u2500\u2500 BTN-10322 \xB7 cascade cleared affordance \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.cleared-affordance {\n display: flex;\n align-items: center;\n gap: var(--bv-spacing-sm);\n margin-top: var(--bv-spacing-xs-plus);\n padding: var(--bv-spacing-sm) var(--bv-spacing-md-minus);\n background: var(--bv-color-warning-light);\n color: var(--bv-color-warning-dark);\n border-radius: var(--bv-radius-md);\n font-size: var(--bv-font-size-xs);\n line-height: 1.4;\n}\n\n.cleared-affordance[hidden] {\n display: none;\n}\n\n.cleared-affordance-icon {\n display: inline-flex;\n align-items: center;\n color: var(--bv-color-warning);\n flex-shrink: 0;\n}\n\n.cleared-affordance-msg {\n flex: 1;\n}\n\n.cleared-affordance-dismiss {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 16px;\n height: 16px;\n padding: 0;\n border: none;\n border-radius: var(--bv-radius-full);\n background: none;\n color: inherit;\n cursor: pointer;\n opacity: 0.7;\n flex-shrink: 0;\n}\n\n.cleared-affordance-dismiss:hover {\n background: color-mix(in srgb, var(--bv-color-neutral-900) 12%, transparent);\n opacity: 1;\n}\n\n/* \u2500\u2500 Panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.panel {\n display: none;\n position: absolute;\n top: calc(100% + 4px);\n left: 0;\n width: 680px;\n max-width: calc(100vw - var(--bv-spacing-md) * 2);\n background: var(--bv-color-neutral-0);\n border: 1px solid var(--bv-color-neutral-200);\n border-radius: var(--bv-radius-lg);\n box-shadow: var(--bv-shadow-md);\n z-index: 200;\n overflow: hidden;\n}\n\n:host([open]) .panel {\n display: flex;\n flex-direction: column;\n}\n\n/* Smart positioning \u2014 flips to whichever side has room (see _positionPanel\n in bv-search-picker.ts) instead of always overflowing past a screen edge. */\n:host([data-placement='top']) .panel {\n top: auto;\n bottom: calc(100% + 4px);\n}\n\n:host([data-align='right']) .panel {\n left: auto;\n right: 0;\n}\n\n.panel-toolbar {\n display: flex;\n align-items: center;\n gap: var(--bv-spacing-sm);\n padding: var(--bv-spacing-sm-plus) var(--bv-spacing-md-minus);\n border-bottom: 1px solid var(--bv-color-neutral-200);\n background: var(--bv-color-neutral-100);\n}\n\n.panel-toolbar bv-toggle {\n margin-left: auto;\n flex-shrink: 0;\n}\n\n.panel-body {\n display: flex;\n min-height: 230px;\n}\n\n/* \u2500\u2500 Results pane \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.results-pane {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--bv-color-neutral-200);\n}\n\n/* bv-searchbox's :host is width:100%, so the inset has to be padding on this\n wrapper \u2014 a margin on the searchbox itself would push it past the pane's\n edge, since 100% resolves against the container regardless of the child's\n own margin. */\n.results-search {\n padding: var(--bv-spacing-sm);\n}\n\n.results-list {\n flex: 1;\n max-height: 190px;\n overflow-y: auto;\n}\n\n.result-row {\n display: flex;\n align-items: center;\n gap: var(--bv-spacing-sm);\n padding: var(--bv-spacing-sm) var(--bv-spacing-md-minus);\n border-top: 1px solid var(--bv-color-neutral-100);\n cursor: pointer;\n}\n\n.result-row:hover {\n background: var(--bv-color-primary-50);\n}\n\n/* BTN-10322 \xB7 a11y \u2014 keyboard-focused row (Arrow Up/Down) gets a visible\n ring so a keyboard-only user can see where they are. `aria-selected`\n mirrors the focus position so screen readers announce it. */\n.result-row:focus-visible,\n.result-row[aria-selected='true'] {\n background: var(--bv-color-primary-50);\n outline: 2px solid var(--bv-color-primary);\n outline-offset: -2px;\n}\n\n.results-rows:focus-visible {\n outline: 2px solid var(--bv-color-primary);\n outline-offset: -2px;\n}\n\n.result-name {\n /* min-width:0 overrides the flex-item default of min-width:auto, which\n would otherwise refuse to shrink below the text's natural width and\n defeat the wrap/clamp below entirely. */\n min-width: 0;\n font-size: var(--bv-font-size-sm);\n color: var(--bv-color-neutral-800);\n font-weight: var(--bv-font-weight-semibold);\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n overflow: hidden;\n white-space: normal;\n word-break: break-word;\n}\n\n.result-inactive {\n font-size: var(--bv-font-size-sm);\n color: var(--bv-color-neutral-500);\n flex-shrink: 0;\n}\n\n.result-add {\n margin-left: auto;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: none;\n background: none;\n color: var(--bv-color-primary);\n flex-shrink: 0;\n padding: 0;\n cursor: pointer;\n}\n\n.results-empty {\n padding: var(--bv-spacing-md-minus) var(--bv-spacing-md-minus);\n font-size: var(--bv-font-size-xs);\n color: var(--bv-color-neutral-400);\n line-height: 1.5;\n}\n\n.results-empty:empty,\n.results-empty[hidden] {\n display: none;\n}\n\n/* \u2500\u2500 Loading skeleton (mirrors bv-filter-panel's pattern) \u2500\u2500\u2500 */\n\n.skeleton {\n display: none;\n flex-direction: column;\n}\n\n:host([loading]) .skeleton {\n display: flex;\n}\n\n:host([loading]) .results-rows,\n:host([loading]) .results-empty {\n display: none;\n}\n\n.skeleton-row {\n display: flex;\n align-items: center;\n gap: var(--bv-spacing-sm-plus);\n padding: var(--bv-spacing-sm) var(--bv-spacing-md-minus);\n border-top: 1px solid var(--bv-color-neutral-100);\n}\n\n.skeleton-line {\n height: 11px;\n border-radius: var(--bv-radius-sm);\n background: var(--bv-color-neutral-200);\n animation: bv-skeleton-shimmer 1.5s ease-in-out infinite;\n}\n\n@keyframes bv-skeleton-shimmer {\n 0%,\n 100% {\n opacity: 1;\n }\n 50% {\n opacity: 0.5;\n }\n}\n\n/* \u2500\u2500 Basket pane \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.basket-pane {\n width: 210px;\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n background: var(--bv-color-neutral-100);\n}\n\n.basket-header {\n display: flex;\n align-items: center;\n gap: var(--bv-spacing-sm);\n padding: var(--bv-spacing-sm-plus) var(--bv-spacing-md-minus);\n}\n\n.basket-header-label {\n font-size: var(--bv-font-size-xs);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n font-weight: var(--bv-font-weight-semibold);\n color: var(--bv-color-neutral-500);\n}\n\n.basket-clear-all {\n margin-left: auto;\n border: none;\n background: none;\n padding: 0;\n font-size: var(--bv-font-size-xs);\n font-weight: var(--bv-font-weight-semibold);\n color: var(--bv-color-primary);\n cursor: pointer;\n}\n\n.basket-clear-all:hover {\n color: var(--bv-color-primary-dark);\n text-decoration: underline;\n}\n\n.basket-clear-all[hidden] {\n display: none;\n}\n\n.basket-list {\n flex: 1;\n max-height: 190px;\n overflow-y: auto;\n padding: 0 var(--bv-spacing-sm) var(--bv-spacing-sm);\n display: flex;\n flex-direction: column;\n gap: var(--bv-spacing-xs);\n}\n\n.basket-row {\n display: flex;\n align-items: center;\n gap: var(--bv-spacing-xs);\n padding: var(--bv-spacing-xs) var(--bv-spacing-sm);\n border-radius: var(--bv-radius-sm);\n background: var(--bv-color-primary-light);\n}\n\n.basket-row.mode-exclude {\n background: var(--bv-color-danger-light);\n}\n\n.basket-row-name {\n min-width: 0;\n font-size: var(--bv-font-size-xs);\n font-weight: var(--bv-font-weight-semibold);\n color: var(--bv-color-neutral-800);\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n overflow: hidden;\n white-space: normal;\n word-break: break-word;\n}\n\n.basket-row-remove {\n margin-left: auto;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: none;\n background: none;\n color: var(--bv-color-neutral-500);\n flex-shrink: 0;\n padding: 0;\n cursor: pointer;\n}\n\n.basket-empty {\n padding: var(--bv-spacing-xs) var(--bv-spacing-2xs);\n font-size: var(--bv-font-size-xs);\n color: var(--bv-color-neutral-400);\n line-height: 1.5;\n}\n\n/* \u2500\u2500 Footer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.panel-footer {\n display: flex;\n align-items: center;\n gap: var(--bv-spacing-sm);\n padding: var(--bv-spacing-sm-plus) var(--bv-spacing-md-minus);\n border-top: 1px solid var(--bv-color-neutral-200);\n background: var(--bv-color-neutral-0);\n}\n\n.panel-footer bv-button {\n margin-left: auto;\n flex-shrink: 0;\n}\n\n.results-limit-note {\n font-size: var(--bv-font-size-xs);\n color: var(--bv-color-neutral-500);\n line-height: 1.4;\n}\n\n/* These elements have an author `display`, which beats the user-agent\n `[hidden] { display: none }` regardless of specificity. The component\n toggles their `hidden`, so without this the property changes nothing on\n screen (BTN-11521). */\n.cleared-affordance[hidden] {\n display: none;\n}\n";
2
+ var bv_search_picker_default = ":host {\n display: block;\n position: relative;\n font-family: var(--bv-font-family-base);\n}\n\n:host([hidden]) {\n display: none;\n}\n\n/* \u2500\u2500 Field label \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.field-label {\n display: block;\n font-size: var(--bv-font-size-sm);\n line-height: 1.5;\n font-weight: var(--bv-font-weight-semibold);\n color: var(--bv-color-neutral-800);\n margin-bottom: var(--bv-spacing-xs-plus);\n}\n\n.field-label:empty {\n display: none;\n}\n\n/* \u2500\u2500 Trigger \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.trigger {\n display: flex;\n align-items: center;\n gap: var(--bv-spacing-xs);\n /* Shrink-to-fit its own label by default (a compact pill, not a full-width\n dropdown) \u2014 consumers that need a uniform width across a row of mixed\n field types (e.g. sitting alongside bv-input-dropdown) can override this\n rather than the component silently always filling its host's box. */\n width: var(--bv-search-picker-trigger-width, max-content);\n /* Also overridable \u2014 a consumer that shrinks the host below 120px (e.g. to\n fit a fixed viewport floor) needs this floor to shrink too, or the\n trigger silently overflows its own host box and overlaps whatever sits\n next to it. */\n min-width: var(--bv-search-picker-trigger-min-width, 120px);\n max-width: 220px;\n box-sizing: border-box;\n min-height: var(--bv-control-height-lg);\n border: 1px solid var(--bv-color-neutral-300);\n border-radius: var(--bv-radius-md);\n background: var(--bv-color-neutral-0);\n padding: 5px var(--bv-spacing-sm);\n cursor: pointer;\n font-family: inherit;\n transition:\n border-color var(--bv-transition-fast),\n box-shadow var(--bv-transition-fast);\n}\n\n.trigger:focus-visible {\n outline: none;\n border-color: var(--bv-color-primary);\n box-shadow: 0 0 0 3px var(--bv-color-primary-light);\n}\n\n:host([open]) .trigger {\n border-color: var(--bv-color-primary);\n box-shadow: 0 0 0 3px var(--bv-color-primary-light);\n}\n\n:host([disabled]) .trigger {\n background: var(--bv-color-neutral-100);\n cursor: not-allowed;\n}\n\n.trigger-pill {\n display: inline-flex;\n align-items: center;\n gap: var(--bv-spacing-xs);\n height: 26px;\n /* Symmetric padding so the pill still looks balanced with the clear button\n hidden (count === 0) \u2014 an asymmetric right pad only reads correctly when\n the clear button is actually there to visually fill the extra space. */\n padding: 0 var(--bv-spacing-sm-plus);\n border-radius: var(--bv-radius-full);\n background: var(--bv-color-primary-light);\n color: var(--bv-color-primary-dark);\n font-size: var(--bv-font-size-sm);\n font-weight: var(--bv-font-weight-semibold);\n white-space: nowrap;\n /* Without this, this flex item's default min-width:auto refuses to shrink\n below its content's natural width \u2014 the same reason .trigger needs its\n own min-width overridden (see bv-search-picker.ts) \u2014 and the pill spills\n out past a narrowed .trigger instead of truncating with it. */\n min-width: 0;\n}\n\n.trigger-pill-text {\n overflow: hidden;\n text-overflow: ellipsis;\n min-width: 0;\n}\n\n.trigger-pill.mode-exclude {\n background: var(--bv-color-danger-light);\n color: var(--bv-color-danger-dark);\n}\n\n.trigger-pill-clear {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 16px;\n height: 16px;\n flex-shrink: 0;\n padding: 0;\n border: none;\n border-radius: 50%;\n background: none;\n color: inherit;\n cursor: pointer;\n opacity: 0.7;\n}\n\n/* The `display` rule above wins over the UA's [hidden]{display:none} (author\n origin beats user-agent origin regardless of specificity) \u2014 restate it. */\n.trigger-pill-clear[hidden] {\n display: none;\n}\n\n.trigger-pill-clear:hover {\n /* Overlay derived from the neutral-900 token (matches the pattern\n bv-alert-banner uses for its close-btn hover \u2014 token + color-mix, no\n hardcoded rgba). Works over both include (primary-light) and exclude\n (danger-light) pill backgrounds. */\n background: color-mix(in srgb, var(--bv-color-neutral-900) 12%, transparent);\n opacity: 1;\n}\n\n/*\n * The pill-clear is a `span[role=\"button\"]` (not a native <button>) so that\n * it can nest inside the trigger button without producing invalid HTML.\n * Native <button> would give us a visible focus ring for free; role=\"button\"\n * doesn't, so paint one explicitly for keyboard users.\n */\n.trigger-pill-clear:focus-visible {\n outline: 2px solid var(--bv-color-primary);\n outline-offset: 1px;\n opacity: 1;\n}\n\n.trigger-caret {\n display: flex;\n align-items: center;\n margin-left: auto;\n color: var(--bv-color-neutral-500);\n flex-shrink: 0;\n transition: transform var(--bv-transition-fast);\n}\n\n:host([open]) .trigger-caret {\n transform: rotate(180deg);\n}\n\n/* \u2500\u2500 BTN-10322 \xB7 cascade cleared affordance \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.cleared-affordance {\n display: flex;\n align-items: center;\n gap: var(--bv-spacing-sm);\n margin-top: var(--bv-spacing-xs-plus);\n padding: var(--bv-spacing-sm) var(--bv-spacing-md-minus);\n background: var(--bv-color-warning-light);\n color: var(--bv-color-warning-dark);\n border-radius: var(--bv-radius-md);\n font-size: var(--bv-font-size-xs);\n line-height: 1.4;\n}\n\n.cleared-affordance[hidden] {\n display: none;\n}\n\n.cleared-affordance-icon {\n display: inline-flex;\n align-items: center;\n color: var(--bv-color-warning);\n flex-shrink: 0;\n}\n\n.cleared-affordance-msg {\n flex: 1;\n}\n\n.cleared-affordance-dismiss {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 16px;\n height: 16px;\n padding: 0;\n border: none;\n border-radius: var(--bv-radius-full);\n background: none;\n color: inherit;\n cursor: pointer;\n opacity: 0.7;\n flex-shrink: 0;\n}\n\n.cleared-affordance-dismiss:hover {\n background: color-mix(in srgb, var(--bv-color-neutral-900) 12%, transparent);\n opacity: 1;\n}\n\n/* \u2500\u2500 Panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.panel {\n display: none;\n position: absolute;\n top: calc(100% + 4px);\n left: 0;\n width: 680px;\n max-width: calc(100vw - var(--bv-spacing-md) * 2);\n background: var(--bv-color-neutral-0);\n border: 1px solid var(--bv-color-neutral-200);\n border-radius: var(--bv-radius-lg);\n box-shadow: var(--bv-shadow-md);\n z-index: 200;\n overflow: hidden;\n}\n\n:host([open]) .panel {\n display: flex;\n flex-direction: column;\n}\n\n/* Smart positioning \u2014 flips to whichever side has room (see _positionPanel\n in bv-search-picker.ts) instead of always overflowing past a screen edge. */\n:host([data-placement='top']) .panel {\n top: auto;\n bottom: calc(100% + 4px);\n}\n\n:host([data-align='right']) .panel {\n left: auto;\n right: 0;\n}\n\n.panel-toolbar {\n display: flex;\n align-items: center;\n gap: var(--bv-spacing-sm);\n padding: var(--bv-spacing-sm-plus) var(--bv-spacing-md-minus);\n border-bottom: 1px solid var(--bv-color-neutral-200);\n background: var(--bv-color-neutral-100);\n}\n\n.panel-toolbar bv-toggle {\n margin-left: auto;\n flex-shrink: 0;\n}\n\n.panel-body {\n display: flex;\n min-height: 230px;\n}\n\n/* \u2500\u2500 Results pane \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.results-pane {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--bv-color-neutral-200);\n}\n\n/* bv-searchbox's :host is width:100%, so the inset has to be padding on this\n wrapper \u2014 a margin on the searchbox itself would push it past the pane's\n edge, since 100% resolves against the container regardless of the child's\n own margin. */\n.results-search {\n padding: var(--bv-spacing-sm);\n}\n\n.results-list {\n flex: 1;\n max-height: 190px;\n overflow-y: auto;\n}\n\n.result-row {\n display: flex;\n align-items: center;\n gap: var(--bv-spacing-sm);\n padding: var(--bv-spacing-sm) var(--bv-spacing-md-minus);\n border-top: 1px solid var(--bv-color-neutral-100);\n cursor: pointer;\n}\n\n.result-row:hover {\n background: var(--bv-color-primary-50);\n}\n\n/* BTN-10322 \xB7 a11y \u2014 keyboard-focused row (Arrow Up/Down) gets a visible\n ring so a keyboard-only user can see where they are. `aria-selected`\n mirrors the focus position so screen readers announce it. */\n.result-row:focus-visible,\n.result-row[aria-selected='true'] {\n background: var(--bv-color-primary-50);\n outline: 2px solid var(--bv-color-primary);\n outline-offset: -2px;\n}\n\n.results-rows:focus-visible {\n outline: 2px solid var(--bv-color-primary);\n outline-offset: -2px;\n}\n\n.result-name {\n /* min-width:0 overrides the flex-item default of min-width:auto, which\n would otherwise refuse to shrink below the text's natural width and\n defeat the wrap/clamp below entirely. */\n min-width: 0;\n font-size: var(--bv-font-size-sm);\n color: var(--bv-color-neutral-800);\n font-weight: var(--bv-font-weight-semibold);\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n overflow: hidden;\n white-space: normal;\n word-break: break-word;\n}\n\n.result-inactive {\n font-size: var(--bv-font-size-sm);\n color: var(--bv-color-neutral-500);\n flex-shrink: 0;\n}\n\n.result-add {\n margin-left: auto;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: none;\n background: none;\n color: var(--bv-color-primary);\n flex-shrink: 0;\n padding: 0;\n cursor: pointer;\n}\n\n.results-empty {\n padding: var(--bv-spacing-md-minus) var(--bv-spacing-md-minus);\n font-size: var(--bv-font-size-xs);\n color: var(--bv-color-neutral-400);\n line-height: 1.5;\n}\n\n.results-empty:empty,\n.results-empty[hidden] {\n display: none;\n}\n\n/* \u2500\u2500 Loading skeleton (mirrors bv-filter-panel's pattern) \u2500\u2500\u2500 */\n\n.skeleton {\n display: none;\n flex-direction: column;\n}\n\n:host([loading]) .skeleton {\n display: flex;\n}\n\n:host([loading]) .results-rows,\n:host([loading]) .results-empty {\n display: none;\n}\n\n.skeleton-row {\n display: flex;\n align-items: center;\n gap: var(--bv-spacing-sm-plus);\n padding: var(--bv-spacing-sm) var(--bv-spacing-md-minus);\n border-top: 1px solid var(--bv-color-neutral-100);\n}\n\n.skeleton-line {\n height: 11px;\n border-radius: var(--bv-radius-sm);\n background: var(--bv-color-neutral-200);\n animation: bv-skeleton-shimmer 1.5s ease-in-out infinite;\n}\n\n@keyframes bv-skeleton-shimmer {\n 0%,\n 100% {\n opacity: 1;\n }\n 50% {\n opacity: 0.5;\n }\n}\n\n/* \u2500\u2500 Basket pane \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.basket-pane {\n width: 210px;\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n background: var(--bv-color-neutral-100);\n}\n\n.basket-header {\n display: flex;\n align-items: center;\n gap: var(--bv-spacing-sm);\n padding: var(--bv-spacing-sm-plus) var(--bv-spacing-md-minus);\n}\n\n.basket-header-label {\n font-size: var(--bv-font-size-xs);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n font-weight: var(--bv-font-weight-semibold);\n color: var(--bv-color-neutral-500);\n}\n\n.basket-clear-all {\n margin-left: auto;\n border: none;\n background: none;\n padding: 0;\n font-size: var(--bv-font-size-xs);\n font-weight: var(--bv-font-weight-semibold);\n color: var(--bv-color-primary);\n cursor: pointer;\n}\n\n.basket-clear-all:hover {\n color: var(--bv-color-primary-dark);\n text-decoration: underline;\n}\n\n.basket-clear-all[hidden] {\n display: none;\n}\n\n.basket-list {\n flex: 1;\n max-height: 190px;\n overflow-y: auto;\n padding: 0 var(--bv-spacing-sm) var(--bv-spacing-sm);\n display: flex;\n flex-direction: column;\n gap: var(--bv-spacing-xs);\n}\n\n.basket-row {\n display: flex;\n align-items: center;\n gap: var(--bv-spacing-xs);\n padding: var(--bv-spacing-xs) var(--bv-spacing-sm);\n border-radius: var(--bv-radius-sm);\n background: var(--bv-color-primary-light);\n}\n\n.basket-row.mode-exclude {\n background: var(--bv-color-danger-light);\n}\n\n.basket-row-name {\n min-width: 0;\n font-size: var(--bv-font-size-xs);\n font-weight: var(--bv-font-weight-semibold);\n color: var(--bv-color-neutral-800);\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n overflow: hidden;\n white-space: normal;\n word-break: break-word;\n}\n\n.basket-row-remove {\n margin-left: auto;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: none;\n background: none;\n color: var(--bv-color-neutral-500);\n flex-shrink: 0;\n padding: 0;\n cursor: pointer;\n}\n\n/* BTN-11916 \xB7 a11y \u2014 the basket is a roving-tabindex group, so Arrow Up/Down\n moves focus between these buttons without any click. A keyboard user has to\n be able to see which one they are on; matches the ring on .result-row. */\n.basket-row-remove:focus-visible {\n outline: 2px solid var(--bv-color-primary);\n outline-offset: 2px;\n border-radius: var(--bv-radius-sm);\n}\n\n.basket-empty {\n padding: var(--bv-spacing-xs) var(--bv-spacing-2xs);\n font-size: var(--bv-font-size-xs);\n color: var(--bv-color-neutral-400);\n line-height: 1.5;\n}\n\n/* \u2500\u2500 Footer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.panel-footer {\n display: flex;\n align-items: center;\n gap: var(--bv-spacing-sm);\n padding: var(--bv-spacing-sm-plus) var(--bv-spacing-md-minus);\n border-top: 1px solid var(--bv-color-neutral-200);\n background: var(--bv-color-neutral-0);\n}\n\n.panel-footer bv-button {\n margin-left: auto;\n flex-shrink: 0;\n}\n\n.results-limit-note {\n font-size: var(--bv-font-size-xs);\n color: var(--bv-color-neutral-500);\n line-height: 1.4;\n}\n\n/* These elements have an author `display`, which beats the user-agent\n `[hidden] { display: none }` regardless of specificity. The component\n toggles their `hidden`, so without this the property changes nothing on\n screen (BTN-11521). */\n.cleared-affordance[hidden] {\n display: none;\n}\n";
3
3
 
4
4
  // components/_shared/adopt-styles.ts
5
5
  var _sheetCache = /* @__PURE__ */ new Map();
@@ -641,6 +641,14 @@ var BvSearchPicker = class extends HTMLElement {
641
641
  * cleared when the user next opens the panel or explicitly dismisses.
642
642
  */
643
643
  _clearedReason = null;
644
+ /**
645
+ * BTN-11916 — roving-tabindex position in the basket. The basket is a
646
+ * composite widget: one tab stop for the whole group, Arrow keys to move
647
+ * between the remove buttons inside it. Without this, a basket of 40 picked
648
+ * users costs 40 tab presses to step over — and the basket is the *unbounded*
649
+ * list here, since the results list is capped by the consumer.
650
+ */
651
+ _basketActiveIdx = 0;
644
652
  _labelEl = null;
645
653
  _triggerEl = null;
646
654
  _panelEl = null;
@@ -959,6 +967,7 @@ var BvSearchPicker = class extends HTMLElement {
959
967
  basketHeader.append(basketHeaderLabel, basketClearAll);
960
968
  const basketList = document.createElement("div");
961
969
  basketList.className = "basket-list";
970
+ basketList.addEventListener("keydown", (e) => this._onBasketKeyDown(e));
962
971
  this._basketListEl = basketList;
963
972
  const basketEmpty = document.createElement("div");
964
973
  basketEmpty.className = "basket-empty";
@@ -1206,7 +1215,11 @@ var BvSearchPicker = class extends HTMLElement {
1206
1215
  removeBtn.className = "basket-row-remove";
1207
1216
  removeBtn.setAttribute("aria-label", `Remove ${item.name}`);
1208
1217
  removeBtn.innerHTML = getIconSvg("x", 10);
1209
- removeBtn.addEventListener("click", () => this._removeBasket(item.id));
1218
+ removeBtn.addEventListener("click", (e) => {
1219
+ const idx = this._stagedBasket.findIndex((c) => c.id === item.id);
1220
+ this._removeBasket(item.id);
1221
+ if (e.detail === 0) this._setActiveBasketRow(idx, true);
1222
+ });
1210
1223
  row.append(nameEl, removeBtn);
1211
1224
  this._basketListEl.appendChild(row);
1212
1225
  }
@@ -1216,6 +1229,7 @@ var BvSearchPicker = class extends HTMLElement {
1216
1229
  { count: basket.length }
1217
1230
  );
1218
1231
  this._basketEmptyEl.hidden = basket.length > 0;
1232
+ this._setActiveBasketRow(this._basketActiveIdx, false);
1219
1233
  this._syncBasketRowColors();
1220
1234
  this._syncBasketHeaderLabel();
1221
1235
  this._syncBasketEmptyText();
@@ -1432,6 +1446,67 @@ var BvSearchPicker = class extends HTMLElement {
1432
1446
  this._resultsRowsEl.setAttribute("aria-activedescendant", active.id);
1433
1447
  if (moveFocus) active.focus();
1434
1448
  }
1449
+ /**
1450
+ * BTN-11916 — the basket's counterpart to `_setActiveRow`, and deliberately
1451
+ * a different mechanism. The results list is a `role="listbox"`: one focus
1452
+ * holder, `aria-activedescendant` pointing at the active option. The basket
1453
+ * is not a listbox — nothing is being *chosen* there, each row carries its
1454
+ * own action — so it uses a roving tabindex over the native buttons instead,
1455
+ * which keeps their button semantics intact.
1456
+ *
1457
+ * Clamps, so callers may pass an index that has just gone out of range after
1458
+ * a removal. When the basket empties there is no button left to hold focus:
1459
+ * `moveFocus` then falls back to the search box rather than letting the
1460
+ * document drop focus to <body>.
1461
+ */
1462
+ _setActiveBasketRow(idx, moveFocus) {
1463
+ var _a, _b;
1464
+ if (!this._basketListEl) return;
1465
+ const btns = Array.from(
1466
+ this._basketListEl.querySelectorAll(".basket-row-remove")
1467
+ );
1468
+ if (btns.length === 0) {
1469
+ this._basketActiveIdx = 0;
1470
+ if (moveFocus) (_a = this._searchEl) == null ? void 0 : _a.focus();
1471
+ return;
1472
+ }
1473
+ const bounded = Math.max(0, Math.min(idx, btns.length - 1));
1474
+ this._basketActiveIdx = bounded;
1475
+ btns.forEach((btn, i) => {
1476
+ btn.tabIndex = i === bounded ? 0 : -1;
1477
+ });
1478
+ if (moveFocus) (_b = btns[bounded]) == null ? void 0 : _b.focus();
1479
+ }
1480
+ /**
1481
+ * BTN-11916 — Arrow Up/Down (plus Home/End) rove between the basket's remove
1482
+ * buttons. Enter and Space are NOT handled here on purpose: these are native
1483
+ * `<button>`s, so the browser already turns both into a click, and the click
1484
+ * handler owns both the removal and the follow-up focus move. Intercepting
1485
+ * them would mean re-implementing activation we get for free.
1486
+ */
1487
+ _onBasketKeyDown(e) {
1488
+ if (!this._basketListEl) return;
1489
+ const count = this._basketListEl.querySelectorAll(".basket-row-remove").length;
1490
+ if (count === 0) return;
1491
+ switch (e.key) {
1492
+ case "ArrowDown":
1493
+ e.preventDefault();
1494
+ this._setActiveBasketRow(this._basketActiveIdx + 1, true);
1495
+ break;
1496
+ case "ArrowUp":
1497
+ e.preventDefault();
1498
+ this._setActiveBasketRow(this._basketActiveIdx - 1, true);
1499
+ break;
1500
+ case "Home":
1501
+ e.preventDefault();
1502
+ this._setActiveBasketRow(0, true);
1503
+ break;
1504
+ case "End":
1505
+ e.preventDefault();
1506
+ this._setActiveBasketRow(count - 1, true);
1507
+ break;
1508
+ }
1509
+ }
1435
1510
  /**
1436
1511
  * BTN-10322 — Arrow Up/Down navigation between result rows; Enter or Space
1437
1512
  * adds the currently-focused row to the staged basket. Home/End jump to
@@ -1443,7 +1518,7 @@ var BvSearchPicker = class extends HTMLElement {
1443
1518
  * which then removes the row from the results list.
1444
1519
  */
1445
1520
  _onResultsKeyDown(e) {
1446
- var _a;
1521
+ var _a, _b, _c;
1447
1522
  if (!this._resultsRowsEl) return;
1448
1523
  const rows = Array.from(this._resultsRowsEl.querySelectorAll(".result-row"));
1449
1524
  if (rows.length === 0) return;
@@ -1473,7 +1548,13 @@ var BvSearchPicker = class extends HTMLElement {
1473
1548
  const focusedId = (_a = rows[currentIdx]) == null ? void 0 : _a.dataset["id"];
1474
1549
  if (!focusedId) return;
1475
1550
  const item = this.results.find((r) => r.id === focusedId);
1476
- if (item) this._addBasket(item);
1551
+ if (!item) return;
1552
+ this._addBasket(item);
1553
+ if ((_b = this._resultsRowsEl) == null ? void 0 : _b.querySelector(".result-row")) {
1554
+ this._setActiveRow(currentIdx, true);
1555
+ } else {
1556
+ (_c = this._searchEl) == null ? void 0 : _c.focus();
1557
+ }
1477
1558
  break;
1478
1559
  }
1479
1560
  }
@@ -22,6 +22,21 @@ export declare class BvTable extends HTMLElement {
22
22
  private _mutationObserver;
23
23
  private _bodyObserver;
24
24
  private _headerObserver;
25
+ /**
26
+ * BTN-11914 — the `<thead>` the header observer currently watches for
27
+ * `characterData`. Held so the re-attach below can tell "already watching
28
+ * this one" from "the consumer swapped the header out", and so
29
+ * `disconnectedCallback` can drop it: `disconnect()` clears the
30
+ * registration, and a stale ref would make the re-attach skip itself.
31
+ */
32
+ private _observedThead;
33
+ /**
34
+ * BTN-11914 — dedicated to the `<thead>` `characterData` watch. Separate from
35
+ * `_headerObserver` so a replaced header can be dropped with `disconnect()`
36
+ * without discarding the host observer's queued childList records. See
37
+ * `_observeTheadCharacterData`.
38
+ */
39
+ private _theadObserver;
25
40
  get loading(): boolean;
26
41
  set loading(v: boolean);
27
42
  get fitViewport(): boolean;
@@ -53,6 +68,36 @@ export declare class BvTable extends HTMLElement {
53
68
  private _syncSortableTh;
54
69
  private _cycleSort;
55
70
  private _observeHeaderMutations;
71
+ /**
72
+ * The header observer's record handling, shared by both observers that feed
73
+ * it: the host-wide one above (childList + attributes) and the `<thead>`
74
+ * characterData one below. One body so a branch added to either cannot drift
75
+ * from the other.
76
+ */
77
+ private _handleHeaderMutations;
78
+ /**
79
+ * BTN-11914 — watches `characterData` on the current `<thead>`, and nothing
80
+ * else. Cheap to call repeatedly: it returns immediately while the same
81
+ * element is already being watched.
82
+ *
83
+ * `characterData` must keep reaching the header (BTN-11247): Angular's Ivy
84
+ * creates the header's text node empty and fills it on the update pass, which
85
+ * arrives as a characterData mutation, not a childList one.
86
+ *
87
+ * **Why its own MutationObserver instead of a second `observe()` on the
88
+ * header one.** A replaced `<thead>` has to stop being watched, or a consumer
89
+ * that keeps and mutates the detached subtree still reaches
90
+ * `_enhanceFilledHeader` — records for stale DOM, which is the narrow scope
91
+ * leaking back open (raised by Copilot on PR #50). There is no
92
+ * `unobserve(node)`, so the only way to drop one registration is
93
+ * `disconnect()`, and that also **discards records already queued and not yet
94
+ * delivered**. On the shared observer that would throw away pending childList
95
+ * and attribute records mid-batch — the first version of this fix accepted the
96
+ * stale registration for exactly that reason. A dedicated observer removes the
97
+ * trade entirely: the records it discards are the old `<thead>`'s, which are
98
+ * the ones being dropped on purpose.
99
+ */
100
+ private _observeTheadCharacterData;
56
101
  private _enhanceFilledHeader;
57
102
  private _enhanceAddedHeaders;
58
103
  }
@@ -186,6 +186,21 @@ var BvTable = class extends HTMLElement {
186
186
  _mutationObserver = null;
187
187
  _bodyObserver = null;
188
188
  _headerObserver = null;
189
+ /**
190
+ * BTN-11914 — the `<thead>` the header observer currently watches for
191
+ * `characterData`. Held so the re-attach below can tell "already watching
192
+ * this one" from "the consumer swapped the header out", and so
193
+ * `disconnectedCallback` can drop it: `disconnect()` clears the
194
+ * registration, and a stale ref would make the re-attach skip itself.
195
+ */
196
+ _observedThead = null;
197
+ /**
198
+ * BTN-11914 — dedicated to the `<thead>` `characterData` watch. Separate from
199
+ * `_headerObserver` so a replaced header can be dropped with `disconnect()`
200
+ * without discarding the host observer's queued childList records. See
201
+ * `_observeTheadCharacterData`.
202
+ */
203
+ _theadObserver = null;
189
204
  get loading() {
190
205
  return this.hasAttribute("loading");
191
206
  }
@@ -211,13 +226,16 @@ var BvTable = class extends HTMLElement {
211
226
  if (this.hasAttribute("loading")) this._renderSkeleton();
212
227
  }
213
228
  disconnectedCallback() {
214
- var _a, _b;
229
+ var _a, _b, _c;
215
230
  this._teardownDrag();
216
231
  this._teardownStickyColumns();
217
232
  (_a = this._bodyObserver) == null ? void 0 : _a.disconnect();
218
233
  this._bodyObserver = null;
219
234
  (_b = this._headerObserver) == null ? void 0 : _b.disconnect();
220
235
  this._headerObserver = null;
236
+ (_c = this._theadObserver) == null ? void 0 : _c.disconnect();
237
+ this._theadObserver = null;
238
+ this._observedThead = null;
221
239
  }
222
240
  attributeChangedCallback(name, _old, val) {
223
241
  if (!this.isConnected) return;
@@ -626,34 +644,78 @@ var BvTable = class extends HTMLElement {
626
644
  // for <th>.
627
645
  _observeHeaderMutations() {
628
646
  this._headerObserver = new MutationObserver((mutations) => {
629
- for (const m of mutations) {
630
- if (m.target.nodeType === Node.COMMENT_NODE) continue;
631
- if (m.type === "childList") {
632
- this._enhanceAddedHeaders(m);
633
- continue;
634
- }
635
- if (m.type === "characterData") {
636
- this._enhanceFilledHeader(m);
637
- continue;
638
- }
639
- if (m.type !== "attributes") continue;
640
- const target = m.target;
641
- if (!(target instanceof HTMLElement)) continue;
642
- if (target.tagName !== "TH" || !target.closest("thead")) continue;
643
- if (m.attributeName === "data-sort") {
644
- if (target.hasAttribute("data-sortable")) this._syncSortableTh(target);
645
- } else if (m.attributeName === "data-sortable") {
646
- if (target.hasAttribute("data-sortable")) this._enhanceOneHeader(target);
647
- }
648
- }
647
+ this._observeTheadCharacterData();
648
+ this._handleHeaderMutations(mutations);
649
649
  });
650
650
  this._headerObserver.observe(this, {
651
651
  attributes: true,
652
652
  childList: true,
653
- characterData: true,
654
653
  subtree: true,
655
654
  attributeFilter: ["data-sort", "data-sortable"]
656
655
  });
656
+ this._observeTheadCharacterData();
657
+ }
658
+ /**
659
+ * The header observer's record handling, shared by both observers that feed
660
+ * it: the host-wide one above (childList + attributes) and the `<thead>`
661
+ * characterData one below. One body so a branch added to either cannot drift
662
+ * from the other.
663
+ */
664
+ _handleHeaderMutations(mutations) {
665
+ for (const m of mutations) {
666
+ if (m.target.nodeType === Node.COMMENT_NODE) continue;
667
+ if (m.type === "childList") {
668
+ this._enhanceAddedHeaders(m);
669
+ continue;
670
+ }
671
+ if (m.type === "characterData") {
672
+ this._enhanceFilledHeader(m);
673
+ continue;
674
+ }
675
+ if (m.type !== "attributes") continue;
676
+ const target = m.target;
677
+ if (!(target instanceof HTMLElement)) continue;
678
+ if (target.tagName !== "TH" || !target.closest("thead")) continue;
679
+ if (m.attributeName === "data-sort") {
680
+ if (target.hasAttribute("data-sortable")) this._syncSortableTh(target);
681
+ } else if (m.attributeName === "data-sortable") {
682
+ if (target.hasAttribute("data-sortable")) this._enhanceOneHeader(target);
683
+ }
684
+ }
685
+ }
686
+ /**
687
+ * BTN-11914 — watches `characterData` on the current `<thead>`, and nothing
688
+ * else. Cheap to call repeatedly: it returns immediately while the same
689
+ * element is already being watched.
690
+ *
691
+ * `characterData` must keep reaching the header (BTN-11247): Angular's Ivy
692
+ * creates the header's text node empty and fills it on the update pass, which
693
+ * arrives as a characterData mutation, not a childList one.
694
+ *
695
+ * **Why its own MutationObserver instead of a second `observe()` on the
696
+ * header one.** A replaced `<thead>` has to stop being watched, or a consumer
697
+ * that keeps and mutates the detached subtree still reaches
698
+ * `_enhanceFilledHeader` — records for stale DOM, which is the narrow scope
699
+ * leaking back open (raised by Copilot on PR #50). There is no
700
+ * `unobserve(node)`, so the only way to drop one registration is
701
+ * `disconnect()`, and that also **discards records already queued and not yet
702
+ * delivered**. On the shared observer that would throw away pending childList
703
+ * and attribute records mid-batch — the first version of this fix accepted the
704
+ * stale registration for exactly that reason. A dedicated observer removes the
705
+ * trade entirely: the records it discards are the old `<thead>`'s, which are
706
+ * the ones being dropped on purpose.
707
+ */
708
+ _observeTheadCharacterData() {
709
+ const thead = this.querySelector("thead");
710
+ if (thead === this._observedThead) return;
711
+ this._observedThead = thead;
712
+ if (!this._theadObserver) {
713
+ this._theadObserver = new MutationObserver(
714
+ (mutations) => this._handleHeaderMutations(mutations)
715
+ );
716
+ }
717
+ this._theadObserver.disconnect();
718
+ if (thead) this._theadObserver.observe(thead, { characterData: true, subtree: true });
657
719
  }
658
720
  // BTN-11247 — characterData half of the header observer. BTN-11212 covered
659
721
  // the two childList shapes (a <th> appended, and a text node appended into
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bluevolt-tech/lumen",
3
- "version": "2.6.0",
3
+ "version": "2.6.1",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",