@bluevolt-tech/lumen 2.2.0 → 2.3.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.
@@ -50,6 +50,22 @@ export interface DropdownController {
50
50
  * the state. The controller is the single source of truth; consuming
51
51
  * components must NOT duplicate this sync in their own attributeChangedCallback.
52
52
  * - `bv-open` is dispatched from the host when opening via `open()`/`toggle()`.
53
+ * - `bv-dropdown-close` is dispatched from the host on every successful close,
54
+ * with `detail: { reason }` matching the `CloseReason` union — consumers can
55
+ * use it to close sibling dropdowns or release focus/scroll locks. The name
56
+ * is prefixed to avoid colliding with `bv-modal` / `bv-course-detail-modal`,
57
+ * which dispatch a bare `bv-close` on dismissal — a plain `bv-close` here
58
+ * would bubble into ancestor `bv-close` listeners (e.g. a modal that
59
+ * contains a dropdown) and fire spuriously on every dropdown dismissal.
60
+ *
61
+ * **Consumers MUST NOT call `e.stopPropagation()` on the trigger click**
62
+ * (BTN-10408). The outside-click listener attached here on `document` uses
63
+ * `composedPath().includes(host)` to skip clicks that originated inside its
64
+ * own host — so it already ignores the trigger click without needing the
65
+ * event to be swallowed. Stopping propagation on the trigger prevents the
66
+ * `document`-level listeners attached by *sibling* controllers from
67
+ * receiving the click at all, which is why opening one dropdown while
68
+ * another is open used to leave both visible.
53
69
  *
54
70
  * The controller mutates the `open` attribute on the host. Consumers should
55
71
  * drive their visual state (`.panel` visibility, focus management) from that
@@ -42,6 +42,13 @@ function bindDropdownController(host, options = {}) {
42
42
  if (options.canClose && !options.canClose(reason)) return;
43
43
  host.removeAttribute("open");
44
44
  syncExpanded();
45
+ host.dispatchEvent(
46
+ new CustomEvent("bv-dropdown-close", {
47
+ bubbles: true,
48
+ composed: true,
49
+ detail: { reason }
50
+ })
51
+ );
45
52
  (_a = options.onAfterClose) == null ? void 0 : _a.call(options);
46
53
  };
47
54
  const close = () => closeWithReason("programmatic");
@@ -549,12 +556,43 @@ var BvDateRangePicker = class extends HTMLElement {
549
556
  if (name === "preset") this._preset = val ?? "";
550
557
  if (!this._rendered) return;
551
558
  if (name === "start" || name === "end" || name === "preset") {
559
+ this._maybeRepositionView();
552
560
  this._syncCalendars();
553
561
  this._syncInputs();
554
562
  this._syncPresets();
555
563
  }
556
564
  this._syncValidity();
557
565
  }
566
+ // Hoist the left panel to the anchor's month, but ONLY when *neither*
567
+ // endpoint of the current start/end pair is already in the visible pair.
568
+ // Checking both endpoints matters after manual navigation: if the user
569
+ // scrolled far from `_start` and the consumer only touches `_end`, the
570
+ // change may land inside the visible pair even though `_start` is well
571
+ // out of view — the guard must keep the user's navigation intact in
572
+ // that case. The anchor for the actual reposition still prefers
573
+ // `_start` (fallback to `_end`) so common consumer updates that write
574
+ // both fields converge to the start's month. See BTN-10755 in
575
+ // `attributeChangedCallback` for the flow context. Returns silently on
576
+ // empty / malformed values — the sync pass afterwards can still render
577
+ // the cleared selection over the current view without a jump.
578
+ _maybeRepositionView() {
579
+ const anchor = this._start || this._end;
580
+ if (!anchor) return;
581
+ const parseYearMonth = (s2) => {
582
+ const dt = parseIso(s2);
583
+ if (!dt) return null;
584
+ return { y: dt.getFullYear(), m: dt.getMonth() };
585
+ };
586
+ const right = this._getRightMonth();
587
+ const isVisible = (y, m) => y === this._leftYear && m === this._leftMonth || y === right.year && m === right.month;
588
+ const s = this._start ? parseYearMonth(this._start) : null;
589
+ const e = this._end ? parseYearMonth(this._end) : null;
590
+ if (s && isVisible(s.y, s.m) || e && isVisible(e.y, e.m)) return;
591
+ const target = parseYearMonth(anchor);
592
+ if (!target) return;
593
+ this._leftYear = target.y;
594
+ this._leftMonth = target.m;
595
+ }
558
596
  /**
559
597
  * BTN-10312 — public reset of the picker's internal draft state to the
560
598
  * currently committed `start` / `end` / `preset` attributes. Called by
@@ -1303,9 +1341,8 @@ var BvDateRangeInput = class extends HTMLElement {
1303
1341
  icon.className = "trigger-icon";
1304
1342
  icon.innerHTML = CALENDAR_ICON;
1305
1343
  trigger.append(display, icon);
1306
- trigger.addEventListener("click", (e) => {
1344
+ trigger.addEventListener("click", () => {
1307
1345
  var _a;
1308
- e.stopPropagation();
1309
1346
  (_a = this._dropdown) == null ? void 0 : _a.toggle();
1310
1347
  });
1311
1348
  const popup = document.createElement("div");
@@ -51,6 +51,7 @@ export declare class BvDateRangePicker extends HTMLElement {
51
51
  set preset(v: string);
52
52
  connectedCallback(): void;
53
53
  attributeChangedCallback(name: string, _old: string | null, val: string | null): void;
54
+ private _maybeRepositionView;
54
55
  /**
55
56
  * BTN-10312 — public reset of the picker's internal draft state to the
56
57
  * currently committed `start` / `end` / `preset` attributes. Called by
@@ -495,12 +495,43 @@ var BvDateRangePicker = class extends HTMLElement {
495
495
  if (name === "preset") this._preset = val ?? "";
496
496
  if (!this._rendered) return;
497
497
  if (name === "start" || name === "end" || name === "preset") {
498
+ this._maybeRepositionView();
498
499
  this._syncCalendars();
499
500
  this._syncInputs();
500
501
  this._syncPresets();
501
502
  }
502
503
  this._syncValidity();
503
504
  }
505
+ // Hoist the left panel to the anchor's month, but ONLY when *neither*
506
+ // endpoint of the current start/end pair is already in the visible pair.
507
+ // Checking both endpoints matters after manual navigation: if the user
508
+ // scrolled far from `_start` and the consumer only touches `_end`, the
509
+ // change may land inside the visible pair even though `_start` is well
510
+ // out of view — the guard must keep the user's navigation intact in
511
+ // that case. The anchor for the actual reposition still prefers
512
+ // `_start` (fallback to `_end`) so common consumer updates that write
513
+ // both fields converge to the start's month. See BTN-10755 in
514
+ // `attributeChangedCallback` for the flow context. Returns silently on
515
+ // empty / malformed values — the sync pass afterwards can still render
516
+ // the cleared selection over the current view without a jump.
517
+ _maybeRepositionView() {
518
+ const anchor = this._start || this._end;
519
+ if (!anchor) return;
520
+ const parseYearMonth = (s2) => {
521
+ const dt = parseIso(s2);
522
+ if (!dt) return null;
523
+ return { y: dt.getFullYear(), m: dt.getMonth() };
524
+ };
525
+ const right = this._getRightMonth();
526
+ const isVisible = (y, m) => y === this._leftYear && m === this._leftMonth || y === right.year && m === right.month;
527
+ const s = this._start ? parseYearMonth(this._start) : null;
528
+ const e = this._end ? parseYearMonth(this._end) : null;
529
+ if (s && isVisible(s.y, s.m) || e && isVisible(e.y, e.m)) return;
530
+ const target = parseYearMonth(anchor);
531
+ if (!target) return;
532
+ this._leftYear = target.y;
533
+ this._leftMonth = target.m;
534
+ }
504
535
  /**
505
536
  * BTN-10312 — public reset of the picker's internal draft state to the
506
537
  * currently committed `start` / `end` / `preset` attributes. Called by
@@ -42,6 +42,13 @@ function bindDropdownController(host, options = {}) {
42
42
  if (options.canClose && !options.canClose(reason)) return;
43
43
  host.removeAttribute("open");
44
44
  syncExpanded();
45
+ host.dispatchEvent(
46
+ new CustomEvent("bv-dropdown-close", {
47
+ bubbles: true,
48
+ composed: true,
49
+ detail: { reason }
50
+ })
51
+ );
45
52
  (_a = options.onAfterClose) == null ? void 0 : _a.call(options);
46
53
  };
47
54
  const close = () => closeWithReason("programmatic");
@@ -187,9 +194,8 @@ var BvFilterDropdown = class extends HTMLElement {
187
194
  chevron.className = "trigger-chevron";
188
195
  chevron.innerHTML = CHEVRON_DOWN;
189
196
  trigger.append(label, chevron);
190
- trigger.addEventListener("click", (e) => {
197
+ trigger.addEventListener("click", () => {
191
198
  var _a;
192
- e.stopPropagation();
193
199
  if (this.disabled) return;
194
200
  (_a = this._dropdown) == null ? void 0 : _a.toggle();
195
201
  });
@@ -42,6 +42,13 @@ function bindDropdownController(host, options = {}) {
42
42
  if (options.canClose && !options.canClose(reason)) return;
43
43
  host.removeAttribute("open");
44
44
  syncExpanded();
45
+ host.dispatchEvent(
46
+ new CustomEvent("bv-dropdown-close", {
47
+ bubbles: true,
48
+ composed: true,
49
+ detail: { reason }
50
+ })
51
+ );
45
52
  (_a = options.onAfterClose) == null ? void 0 : _a.call(options);
46
53
  };
47
54
  const close = () => closeWithReason("programmatic");
@@ -343,9 +350,8 @@ var BvInputDropdown = class extends HTMLElement {
343
350
  trigger.setAttribute("aria-haspopup", "listbox");
344
351
  trigger.setAttribute("aria-expanded", "false");
345
352
  trigger.setAttribute("part", "trigger");
346
- trigger.addEventListener("click", (e) => {
353
+ trigger.addEventListener("click", () => {
347
354
  var _a;
348
- e.stopPropagation();
349
355
  if (!this.disabled) (_a = this._dropdown) == null ? void 0 : _a.toggle();
350
356
  });
351
357
  const triggerLabel = document.createElement("span");
@@ -1,5 +1,5 @@
1
1
  export declare class BvPagination extends HTMLElement {
2
- static readonly observedAttributes: readonly ["page", "total-pages", "total", "page-size", "siblings", "show-results", "show-page-size", "page-size-options", "compact"];
2
+ static readonly observedAttributes: readonly ["page", "total-pages", "total", "page-size", "siblings", "show-results", "show-page-size", "page-size-options", "compact", "hide-range"];
3
3
  private _rendered;
4
4
  private _metaEl;
5
5
  private _pagesEl;
@@ -24,6 +24,8 @@ export declare class BvPagination extends HTMLElement {
24
24
  set showPageSize(v: boolean);
25
25
  get compact(): boolean;
26
26
  set compact(v: boolean);
27
+ get hideRange(): boolean;
28
+ set hideRange(v: boolean);
27
29
  get pageSizeOptions(): number[];
28
30
  private _go;
29
31
  private _render;
@@ -32,7 +32,8 @@ var BvPagination = class extends HTMLElement {
32
32
  "show-results",
33
33
  "show-page-size",
34
34
  "page-size-options",
35
- "compact"
35
+ "compact",
36
+ "hide-range"
36
37
  ];
37
38
  _rendered = false;
38
39
  _metaEl = null;
@@ -90,6 +91,13 @@ var BvPagination = class extends HTMLElement {
90
91
  if (v) this.setAttribute("compact", "");
91
92
  else this.removeAttribute("compact");
92
93
  }
94
+ get hideRange() {
95
+ return this.hasAttribute("hide-range");
96
+ }
97
+ set hideRange(v) {
98
+ if (v) this.setAttribute("hide-range", "");
99
+ else this.removeAttribute("hide-range");
100
+ }
93
101
  get pageSizeOptions() {
94
102
  const raw = this.getAttribute("page-size-options") ?? "10,25,50,100";
95
103
  return raw.split(",").map((v) => parseInt(v.trim(), 10)).filter((v) => !isNaN(v));
@@ -169,7 +177,7 @@ var BvPagination = class extends HTMLElement {
169
177
  this._update();
170
178
  }
171
179
  _update() {
172
- const { page, totalPages, total, pageSize, siblings, compact } = this;
180
+ const { page, totalPages, total, pageSize, siblings, compact, hideRange } = this;
173
181
  if (this._prevBtn) this._prevBtn.disabled = page <= 1;
174
182
  if (this._nextBtn) this._nextBtn.disabled = page >= totalPages;
175
183
  if (this._pageLabelEl) {
@@ -180,7 +188,7 @@ var BvPagination = class extends HTMLElement {
180
188
  this._pagesEl.hidden = compact;
181
189
  }
182
190
  if (this._rangeEl) {
183
- if (compact) {
191
+ if (compact || hideRange) {
184
192
  this._rangeEl.hidden = true;
185
193
  } else if (total > 0) {
186
194
  const start = (page - 1) * pageSize + 1;
@@ -0,0 +1,231 @@
1
+ import '../bv-searchbox/bv-searchbox.ts';
2
+ import '../bv-segmented-control/bv-segmented-control.ts';
3
+ import '../bv-toggle/bv-toggle.ts';
4
+ import '../bv-button/bv-button.ts';
5
+ export type SearchPickerMode = 'include' | 'exclude';
6
+ export interface SearchPickerItem {
7
+ id: string;
8
+ name: string;
9
+ inactive?: boolean;
10
+ }
11
+ /**
12
+ * Detail on the `bv-search` event.
13
+ *
14
+ * Note: `showInactive` is intentionally NOT on this payload — read it directly
15
+ * off the picker (`picker.showInactive`) inside the handler. It changes via its
16
+ * own event (`bv-show-inactive-change`), so mixing it into `bv-search` would
17
+ * emit an update on every keystroke rather than only when it actually changes.
18
+ */
19
+ export interface SearchPickerSearchDetail {
20
+ /** Trimmed user query. Only emitted past `min-query-length`. */
21
+ query: string;
22
+ /** Monotonic id the consumer echoes back via `results-request-id` so the picker can drop stale responses. */
23
+ requestId: number;
24
+ }
25
+ export interface SearchPickerBasketChangeDetail {
26
+ /** Staged mode at the moment of the change (before Apply). */
27
+ mode: SearchPickerMode;
28
+ /** Staged basket at the moment of the change (before Apply). Consumer sees every add/remove/clear. */
29
+ basket: SearchPickerItem[];
30
+ }
31
+ export interface SearchPickerModeChangeDetail {
32
+ /** Staged mode after the toggle (before Apply). */
33
+ mode: SearchPickerMode;
34
+ }
35
+ export interface SearchPickerShowInactiveChangeDetail {
36
+ showInactive: boolean;
37
+ }
38
+ export interface SearchPickerApplyDetail {
39
+ /** Committed mode. */
40
+ mode: SearchPickerMode;
41
+ /** Committed basket. */
42
+ basket: SearchPickerItem[];
43
+ }
44
+ export interface SearchPickerClearedDetail {
45
+ /** Consumer-supplied string identifying why the basket was cleared (e.g. `'category-changed'`, `'group-changed'`). */
46
+ reason: string;
47
+ }
48
+ export declare class BvSearchPicker extends HTMLElement {
49
+ static readonly observedAttributes: readonly ["label", "entity-label", "search-placeholder", "inactive-toggle-label", "mode", "min-query-length", "debounce-ms", "disabled", "loading", "show-inactive", "open", "results", "results-total", "results-request-id", "basket", "label-all", "label-count", "label-except", "label-only-these", "label-all-except", "label-empty-basket", "label-clear-all", "label-min-chars", "label-no-results", "label-results-cap", "label-cleared"];
50
+ private _rendered;
51
+ private _query;
52
+ /**
53
+ * BTN-10322 — staged basket edited inside the panel. Kept separate from the
54
+ * committed `basket` attribute so the trigger pill doesn't flicker mid-edit
55
+ * (add/remove doesn't touch it) and so a cascade reset (`reset()`) has a
56
+ * clean staged copy to reseed from. Committed on Apply, AND automatically
57
+ * whenever the panel closes any other way (Escape / outside-click / trigger
58
+ * toggle) — see `onAfterClose` in `_render()`. Closing is what "confirms"
59
+ * the edit; Apply is just the explicit, no-need-to-move-your-mouse way to
60
+ * do the same thing.
61
+ */
62
+ private _stagedBasket;
63
+ /** BTN-10322 — staged mode edited inside the panel. Committed on close, same as `_stagedBasket`. */
64
+ private _stagedMode;
65
+ /** BTN-10322 — monotonic id emitted with `bv-search`. The consumer echoes it via `results-request-id`; stale responses drop silently. */
66
+ private _searchRequestId;
67
+ /** BTN-10322 — timer handle for the internal debounce on `bv-search`. */
68
+ private _searchDebounceHandle;
69
+ /**
70
+ * BTN-10755 — set synchronously the moment a keystroke enters over-threshold
71
+ * territory and the debounce is armed; cleared when the debounce fires (or
72
+ * on any reset / clear path). `_syncResultsEmptyText` treats it the same as
73
+ * `loading`, so the "No X found." copy can't flash in the ~300ms gap
74
+ * between the keystroke and the dispatched `bv-search`.
75
+ */
76
+ private _searchPending;
77
+ /**
78
+ * BTN-10322 — the reason to display in the cascade cleared affordance,
79
+ * or `null` when the banner is hidden. Set by `reset({ reason })`,
80
+ * cleared when the user next opens the panel or explicitly dismisses.
81
+ */
82
+ private _clearedReason;
83
+ private _labelEl;
84
+ private _triggerEl;
85
+ private _panelEl;
86
+ private _pillEl;
87
+ private _pillTextEl;
88
+ private _pillClearEl;
89
+ private _modeControlEl;
90
+ private _inactiveToggleEl;
91
+ private _searchEl;
92
+ private _resultsRowsEl;
93
+ private _resultsEmptyEl;
94
+ private _resultsLimitNoteEl;
95
+ private _basketHeaderLabelEl;
96
+ private _basketClearAllEl;
97
+ private _basketListEl;
98
+ private _basketEmptyEl;
99
+ private _resultsListEl;
100
+ private _clearedAffordanceEl;
101
+ private _clearedAffordanceMsgEl;
102
+ private _dropdown;
103
+ constructor();
104
+ get mode(): SearchPickerMode;
105
+ /**
106
+ * Setting `mode` from outside is treated as a commit — it aligns the staged
107
+ * mode inside the panel with the new committed value. Useful for initialising
108
+ * from URL params or reverting from a saved report configuration. If the panel
109
+ * is currently open, the segmented control snaps to the new mode immediately;
110
+ * the user is expected to see this as an intentional external change, not an
111
+ * accidental override of their in-flight edit.
112
+ */
113
+ set mode(v: SearchPickerMode);
114
+ get entityLabel(): string;
115
+ set entityLabel(v: string);
116
+ get minQueryLength(): number;
117
+ set minQueryLength(v: number);
118
+ get disabled(): boolean;
119
+ set disabled(v: boolean);
120
+ get loading(): boolean;
121
+ set loading(v: boolean);
122
+ get showInactive(): boolean;
123
+ set showInactive(v: boolean);
124
+ get results(): SearchPickerItem[];
125
+ set results(v: SearchPickerItem[]);
126
+ get resultsTotal(): number | null;
127
+ set resultsTotal(v: number | null);
128
+ get debounceMs(): number;
129
+ set debounceMs(v: number);
130
+ get basket(): SearchPickerItem[];
131
+ set basket(v: SearchPickerItem[]);
132
+ connectedCallback(): void;
133
+ disconnectedCallback(): void;
134
+ attributeChangedCallback(name: string, _old: string | null, _val: string | null): void;
135
+ /**
136
+ * BTN-10322 — request-id guard. When `results` (or friends) are set from
137
+ * the consumer's async search, `results-request-id` must match the most
138
+ * recent id emitted by our own `bv-search`. Any mismatch means the
139
+ * response arrived out of order (user typed faster than the network) and
140
+ * we drop it silently to avoid rendering stale rows.
141
+ *
142
+ * `results-request-id` unset entirely is fine — consumers that don't
143
+ * bother with the guard behave as before (no correlation).
144
+ */
145
+ private _syncResultsWithGuard;
146
+ private _render;
147
+ private _positionPanel;
148
+ private _syncLabel;
149
+ private _syncModeOptions;
150
+ private _syncSearchPlaceholder;
151
+ private _syncInactiveToggleLabel;
152
+ private _syncMode;
153
+ private _syncDisabled;
154
+ private _syncShowInactive;
155
+ private _syncResults;
156
+ private _effectiveQueryLength;
157
+ private _syncResultsEmptyText;
158
+ private _syncResultsLimitNote;
159
+ private _syncBasket;
160
+ private _syncBasketHeaderLabel;
161
+ private _syncBasketRowColors;
162
+ private _syncBasketEmptyText;
163
+ private _syncTriggerSummary;
164
+ private _interp;
165
+ private _addBasket;
166
+ private _removeBasket;
167
+ private _clearAllStaged;
168
+ /**
169
+ * The trigger's pill-clear (`x`) is an explicit user action that means "back
170
+ * to the neutral state". Unlike the basket's "Clear all" (which only touches
171
+ * staged), this commits immediately — the pill lives on the trigger, which
172
+ * only reflects committed state. Dispatches `bv-apply` because a commit
173
+ * happened.
174
+ */
175
+ private _clearCommitted;
176
+ private _stagedDiffersFromCommitted;
177
+ private _commitStaged;
178
+ private _apply;
179
+ private _dispatchBasketChange;
180
+ private _dispatchModeChange;
181
+ private _dispatchShowInactiveChange;
182
+ private _dispatchApply;
183
+ private _dispatchSearch;
184
+ private _scheduleSearchDispatch;
185
+ /**
186
+ * BTN-10322 — Arrow Up/Down navigation between result rows; Enter or Space
187
+ * adds the currently-focused row to the staged basket. Home/End jump to
188
+ * first/last. Escape delegates to the shared dropdown-controller (already
189
+ * wired on the host).
190
+ *
191
+ * `role="option"` rows use `aria-selected` only to communicate focus
192
+ * position to assistive tech — clicking / Enter also fires the add flow,
193
+ * which then removes the row from the results list.
194
+ */
195
+ private _onResultsKeyDown;
196
+ /**
197
+ * BTN-10322 — count of the currently committed basket. Consumers can read
198
+ * this to render an external counter badge without duplicating state.
199
+ */
200
+ get count(): number;
201
+ /**
202
+ * BTN-10322 — cascade reset from outside the panel. This is THE canonical
203
+ * way for a consumer to clear the picker in response to an upstream filter
204
+ * change (e.g. CET Report when Categories or Groups change). It:
205
+ *
206
+ * - clears the committed `basket` and the staged edit,
207
+ * - clears `results` + `resultsTotal` (post-cascade the last search's
208
+ * rows are stale — leaving them visible next to the cleared banner
209
+ * is confusing),
210
+ * - clears the internal search query,
211
+ * - surfaces the "cleared" affordance banner with `{ reason }` interpolated
212
+ * into the copy,
213
+ * - dispatches `bv-cleared` with `{ reason }`.
214
+ *
215
+ * Does NOT dispatch `bv-apply` — this is not a user commit, it's an
216
+ * out-of-band cascade. The consumer that triggered it already knows the
217
+ * basket was cleared.
218
+ *
219
+ * The affordance auto-hides the next time the user opens the panel or
220
+ * dismisses it explicitly via the banner's close button.
221
+ *
222
+ * **Prefer this over `element.basket = []`** — the setter is silent (no
223
+ * bv-cleared, no visible affordance) and is intended for state restoration
224
+ * from URL/deep-link, not for cascade reset.
225
+ */
226
+ reset({ reason }: {
227
+ reason: string;
228
+ }): void;
229
+ private _syncClearedAffordance;
230
+ private _dismissClearedAffordance;
231
+ }