@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", () =>
|
|
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)
|
|
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
|
-
|
|
630
|
-
|
|
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
|