@besaitech/ng-design-system 0.0.81 → 0.0.82

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.
@@ -5826,6 +5826,127 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
5826
5826
  }]
5827
5827
  }], propDecorators: { level: [{ type: i0.Input, args: [{ isSignal: true, alias: "level", required: true }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }] } });
5828
5828
 
5829
+ /**
5830
+ * Where a floating panel goes, and the plumbing that keeps it there.
5831
+ *
5832
+ * <p><b>Why this exists.</b> Ten components in this library append a panel to `<body>` and position
5833
+ * it against their trigger, and nine of them had their own copy of the same twenty-odd lines. The
5834
+ * copies had already drifted: some clamp the panel inside the viewport vertically and some only
5835
+ * flip it, some match the trigger's width and some do not. Worse, the *lifecycle* was duplicated
5836
+ * too — and `sds-autocomplete` shipped a version that removed the panel through a `viewChild`
5837
+ * already cleared by `ngOnDestroy`, orphaning it in `<body>` for ever. That bug could only exist in
5838
+ * one copy of nine.
5839
+ */
5840
+ /**
5841
+ * The arithmetic, with no DOM in it.
5842
+ *
5843
+ * <p>Vertical: below the trigger, flipped above when it does not fit below and fits better above —
5844
+ * then <b>clamped inside the viewport</b>. The clamp is not decoration: without it a panel taller
5845
+ * than the space either side is pushed off the top edge and its first rows become unreachable, and
5846
+ * four of the nine copies were missing it.
5847
+ *
5848
+ * <p>Horizontal: the panel's start (or end) edge meets the trigger's, then clamped the same way. A
5849
+ * panel wider than the viewport pins to the left margin rather than running off both sides.
5850
+ */
5851
+ function placePanel(trigger, panel, viewport, options = {}) {
5852
+ const gap = options.gap ?? 4;
5853
+ const margin = options.margin ?? 8;
5854
+ const roomBelow = viewport.height - trigger.bottom - gap;
5855
+ const roomAbove = trigger.top - gap;
5856
+ const above = options.prefer === 'above'
5857
+ ? roomAbove >= panel.height || roomAbove > roomBelow
5858
+ : roomBelow < panel.height && roomAbove > roomBelow;
5859
+ const top = clamp(above ? trigger.top - gap - panel.height : trigger.bottom + gap, margin, viewport.height - margin - panel.height);
5860
+ const left = clamp(options.align === 'end' ? trigger.right - panel.width : trigger.left, margin, viewport.width - margin - panel.width);
5861
+ return { top: Math.round(top), left: Math.round(left) };
5862
+ }
5863
+ /**
5864
+ * A viewport bound that may be smaller than the lower one — when the panel is taller than the
5865
+ * window there is no position that satisfies both, and the top edge is the one that must win, or
5866
+ * the panel starts off-screen and cannot be scrolled back.
5867
+ */
5868
+ function clamp(value, low, high) {
5869
+ return Math.max(low, Math.min(value, Math.max(low, high)));
5870
+ }
5871
+ /**
5872
+ * The DOM half: append to `<body>`, place, follow scroll and resize, and — the part that was worth
5873
+ * centralising — take it all down again exactly once.
5874
+ *
5875
+ * <p>Hold one of these per component and call {@link detach} from `ngOnDestroy`. It keeps its own
5876
+ * reference to the panel element, so it does not depend on a `viewChild` that Angular has already
5877
+ * cleared by the time the component is torn down.
5878
+ */
5879
+ class SdsOverlayPanel {
5880
+ trigger = null;
5881
+ panel = null;
5882
+ options = {};
5883
+ listening = false;
5884
+ reflow = () => this.reposition();
5885
+ /** Attach (or re-attach) the panel to the body and start following the trigger. */
5886
+ attach(trigger, panel, options = {}) {
5887
+ this.trigger = trigger;
5888
+ this.panel = panel;
5889
+ this.options = options;
5890
+ if (panel.parentElement !== document.body) {
5891
+ document.body.appendChild(panel);
5892
+ }
5893
+ this.reposition();
5894
+ if (!this.listening) {
5895
+ // `true` for scroll: a panel inside a scrolling container must follow it, and scroll does
5896
+ // not bubble.
5897
+ window.addEventListener('scroll', this.reflow, true);
5898
+ window.addEventListener('resize', this.reflow);
5899
+ this.listening = true;
5900
+ }
5901
+ }
5902
+ reposition() {
5903
+ const trigger = this.trigger;
5904
+ const panel = this.panel;
5905
+ if (!trigger || !panel) {
5906
+ return;
5907
+ }
5908
+ const r = trigger.getBoundingClientRect();
5909
+ // Before measuring, because the width we are about to impose is the width placePanel must see.
5910
+ if (this.options.width === 'min') {
5911
+ panel.style.minWidth = `${Math.round(r.width)}px`;
5912
+ }
5913
+ else if (this.options.width === 'exact') {
5914
+ panel.style.width = `${Math.round(r.width)}px`;
5915
+ }
5916
+ const { top, left } = placePanel(r,
5917
+ // offset* is 0 before the panel has laid out; the fallbacks keep the first frame sane.
5918
+ { width: panel.offsetWidth || r.width, height: panel.offsetHeight || 240 }, { width: window.innerWidth, height: window.innerHeight }, this.options);
5919
+ panel.style.top = `${top}px`;
5920
+ panel.style.left = `${left}px`;
5921
+ }
5922
+ /**
5923
+ * The live panel element, or null while closed.
5924
+ *
5925
+ * <p>Read-only on purpose. A component that needs to reach inside the panel — to focus a row, to
5926
+ * trap Tab, to read its options — asks here rather than keeping its own field. A second
5927
+ * reference is how the autocomplete came to remove the panel through a `viewChild` that Angular
5928
+ * had already cleared, which is the bug this class exists to make unrepeatable.
5929
+ */
5930
+ get element() {
5931
+ return this.panel;
5932
+ }
5933
+ /** Does the panel contain this node? For "did the click land outside" checks. */
5934
+ contains(node) {
5935
+ return !!node && !!this.panel?.contains(node);
5936
+ }
5937
+ /** Stop following, and remove the panel from the body. Safe to call more than once. */
5938
+ detach() {
5939
+ if (this.listening) {
5940
+ window.removeEventListener('scroll', this.reflow, true);
5941
+ window.removeEventListener('resize', this.reflow);
5942
+ this.listening = false;
5943
+ }
5944
+ this.panel?.remove();
5945
+ this.panel = null;
5946
+ this.trigger = null;
5947
+ }
5948
+ }
5949
+
5829
5950
  let nextId$f = 0;
5830
5951
  /**
5831
5952
  * Marks the element inside `<sds-menu>` that opens it. It may be a native button or a component that
@@ -5896,7 +6017,8 @@ class SdsMenu {
5896
6017
  /** An action was activated. */
5897
6018
  select = output();
5898
6019
  projectedTrigger = contentChild(SdsMenuTrigger, ...(ngDevMode ? [{ debugName: "projectedTrigger" }] : []));
5899
- panelEl = null;
6020
+ /** Body-appending, positioning, follow-on-scroll and teardown — see SdsOverlayPanel. */
6021
+ overlay = new SdsOverlayPanel();
5900
6022
  /** Which item gets focus when the panel opens: 'first' | 'last' | 'none'. */
5901
6023
  focusOnOpen = 'first';
5902
6024
  typeBuffer = '';
@@ -5991,23 +6113,18 @@ class SdsMenu {
5991
6113
  afterOpen() {
5992
6114
  if (!this.open())
5993
6115
  return;
5994
- this.panelEl = this.host.nativeElement.querySelector('[data-sds-menu-panel]');
5995
- if (!this.panelEl)
6116
+ const panel = this.host.nativeElement.querySelector('[data-sds-menu-panel]');
6117
+ const trigger = this.triggerEl();
6118
+ if (!panel || !trigger)
5996
6119
  return;
5997
- if (this.panelEl.parentElement !== document.body)
5998
- document.body.appendChild(this.panelEl);
5999
6120
  // Name the menu after its trigger unless an explicit [ariaLabel] was given.
6000
- const trigger = this.triggerEl();
6001
- if (!this.ariaLabel() && trigger?.id)
6002
- this.panelEl.setAttribute('aria-labelledby', trigger.id);
6003
- this.position();
6004
- this.addReflowListeners();
6121
+ if (!this.ariaLabel() && trigger.id)
6122
+ panel.setAttribute('aria-labelledby', trigger.id);
6123
+ this.overlay.attach(trigger, panel, { align: this.align() });
6005
6124
  this.focusItemAt(this.focusOnOpen);
6006
6125
  }
6007
6126
  teardown() {
6008
- this.removeReflowListeners();
6009
- this.panelEl?.remove();
6010
- this.panelEl = null;
6127
+ this.overlay.detach();
6011
6128
  }
6012
6129
  // --- trigger events -------------------------------------------------------
6013
6130
  onTriggerClick(event) {
@@ -6094,12 +6211,12 @@ class SdsMenu {
6094
6211
  this.typeTimer = setTimeout(() => (this.typeBuffer = ''), 600);
6095
6212
  }
6096
6213
  focusIndex(i) {
6097
- this.panelEl?.querySelector(`[data-index="${i}"]`)?.focus();
6214
+ this.overlay.element?.querySelector(`[data-index="${i}"]`)?.focus();
6098
6215
  }
6099
6216
  focusItemAt(where) {
6100
6217
  const idx = this.enabledIndexes();
6101
6218
  if (!idx.length) {
6102
- this.panelEl?.focus();
6219
+ this.overlay.element?.focus();
6103
6220
  return;
6104
6221
  }
6105
6222
  this.focusIndex(where === 'first' ? idx[0] : idx[idx.length - 1]);
@@ -6119,47 +6236,10 @@ class SdsMenu {
6119
6236
  const target = event.target;
6120
6237
  if (this.host.nativeElement.contains(target))
6121
6238
  return;
6122
- if (this.panelEl?.contains(target))
6239
+ if (this.overlay.contains(target))
6123
6240
  return;
6124
6241
  this.close(false);
6125
6242
  }
6126
- // --- positioning (body-appended, mirrors sds-select) ------------------------
6127
- reflow = () => this.position();
6128
- listening = false;
6129
- addReflowListeners() {
6130
- if (this.listening)
6131
- return;
6132
- window.addEventListener('scroll', this.reflow, true);
6133
- window.addEventListener('resize', this.reflow);
6134
- this.listening = true;
6135
- }
6136
- removeReflowListeners() {
6137
- if (!this.listening)
6138
- return;
6139
- window.removeEventListener('scroll', this.reflow, true);
6140
- window.removeEventListener('resize', this.reflow);
6141
- this.listening = false;
6142
- }
6143
- position() {
6144
- const trigger = this.triggerEl();
6145
- const panel = this.panelEl;
6146
- if (!trigger || !panel)
6147
- return;
6148
- const r = trigger.getBoundingClientRect();
6149
- const pw = panel.offsetWidth;
6150
- const ph = panel.offsetHeight;
6151
- const gap = 4;
6152
- const margin = 8;
6153
- let top = r.bottom + gap;
6154
- if (top + ph > window.innerHeight - margin && r.top - gap - ph > margin) {
6155
- top = r.top - gap - ph;
6156
- }
6157
- top = Math.max(margin, Math.min(top, window.innerHeight - margin - ph));
6158
- let left = this.align() === 'end' ? r.right - pw : r.left;
6159
- left = Math.max(margin, Math.min(left, window.innerWidth - margin - pw));
6160
- panel.style.top = `${Math.round(top)}px`;
6161
- panel.style.left = `${Math.round(left)}px`;
6162
- }
6163
6243
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsMenu, deps: [], target: i0.ɵɵFactoryTarget.Component });
6164
6244
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsMenu, isStandalone: true, selector: "sds-menu", inputs: { items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null }, align: { classPropertyName: "align", publicName: "align", isSignal: true, isRequired: false, transformFunction: null }, triggerLabel: { classPropertyName: "triggerLabel", publicName: "triggerLabel", isSignal: true, isRequired: false, transformFunction: null }, triggerIcon: { classPropertyName: "triggerIcon", publicName: "triggerIcon", isSignal: true, isRequired: false, transformFunction: null }, triggerText: { classPropertyName: "triggerText", publicName: "triggerText", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, keepOpen: { classPropertyName: "keepOpen", publicName: "keepOpen", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { open: "openChange", select: "select" }, host: { listeners: { "document:click": "onDocumentClick($event)" }, classAttribute: "relative inline-flex" }, queries: [{ propertyName: "projectedTrigger", first: true, predicate: SdsMenuTrigger, descendants: true, isSignal: true }], ngImport: i0, template: `
6165
6245
  <span
@@ -9105,8 +9185,8 @@ class SdsDatePicker {
9105
9185
  cvaDisabled = signal(false, ...(ngDevMode ? [{ debugName: "cvaDisabled" }] : []));
9106
9186
  calendar = viewChild(SdsCalendar, ...(ngDevMode ? [{ debugName: "calendar" }] : []));
9107
9187
  trigger = viewChild('trigger', ...(ngDevMode ? [{ debugName: "trigger" }] : []));
9108
- /** The overlay panel, relocated to document.body while open. */
9109
- panelEl = null;
9188
+ /** Body-appending, positioning, follow-on-scroll and teardown — see SdsOverlayPanel. */
9189
+ overlay = new SdsOverlayPanel();
9110
9190
  isDisabled = computed(() => this.disabled() || this.cvaDisabled(), ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
9111
9191
  displayValue = computed(() => {
9112
9192
  const v = this.value();
@@ -9140,18 +9220,19 @@ class SdsDatePicker {
9140
9220
  this.onTouched();
9141
9221
  // Run after the overlay is rendered, then relocate it to <body>.
9142
9222
  afterNextRender(() => {
9143
- this.panelEl = this.host.nativeElement.querySelector('[role="dialog"]');
9144
- this.positionPanel(); // also relocates to document.body
9145
- this.addReflowListeners();
9223
+ const panel = this.host.nativeElement.querySelector('[role="dialog"]');
9224
+ const trigger = this.trigger()?.nativeElement;
9225
+ if (panel && trigger) {
9226
+ // gap 6, as this field has always used — the shared default is 4.
9227
+ this.overlay.attach(trigger, panel, { gap: 6 });
9228
+ }
9146
9229
  this.calendar()?.focusActiveCell();
9147
9230
  }, { injector: this.injector });
9148
9231
  }
9149
9232
  close(returnFocus = false) {
9150
9233
  if (!this.open())
9151
9234
  return;
9152
- this.removeReflowListeners();
9153
- this.panelEl?.remove(); // pull the body-relocated node before destroying the view
9154
- this.panelEl = null;
9235
+ this.overlay.detach(); // pull the body-relocated node before destroying the view
9155
9236
  this.open.set(false);
9156
9237
  if (returnFocus) {
9157
9238
  this.host.nativeElement.querySelector('input')?.focus();
@@ -9196,17 +9277,18 @@ class SdsDatePicker {
9196
9277
  * the page behind the dialog. Keeps Tab / Shift+Tab cycling within the panel's own focusables.
9197
9278
  */
9198
9279
  onPanelKeydown(event) {
9199
- if (event.key !== 'Tab' || !this.panelEl)
9280
+ const panel = this.overlay.element;
9281
+ if (event.key !== 'Tab' || !panel)
9200
9282
  return;
9201
9283
  const sel = 'a[href],button:not([disabled]):not([tabindex="-1"]),input:not([disabled]),' +
9202
9284
  '[tabindex]:not([tabindex="-1"])';
9203
- const items = Array.from(this.panelEl.querySelectorAll(sel)).filter((el) => el.offsetParent !== null);
9285
+ const items = Array.from(panel.querySelectorAll(sel)).filter((el) => el.offsetParent !== null);
9204
9286
  if (items.length === 0)
9205
9287
  return;
9206
9288
  const first = items[0];
9207
9289
  const last = items[items.length - 1];
9208
9290
  const active = document.activeElement;
9209
- if (event.shiftKey && (active === first || !this.panelEl.contains(active))) {
9291
+ if (event.shiftKey && (active === first || !panel.contains(active))) {
9210
9292
  event.preventDefault();
9211
9293
  last.focus();
9212
9294
  }
@@ -9220,59 +9302,12 @@ class SdsDatePicker {
9220
9302
  return;
9221
9303
  const target = event.target;
9222
9304
  const inField = this.host.nativeElement.contains(target);
9223
- const inPanel = !!this.panelEl && this.panelEl.contains(target);
9305
+ const inPanel = this.overlay.contains(target);
9224
9306
  if (!inField && !inPanel)
9225
9307
  this.close();
9226
9308
  }
9227
- // --- body-appended overlay positioning ---
9228
- reflow = () => this.positionPanel();
9229
- listening = false;
9230
- addReflowListeners() {
9231
- if (this.listening)
9232
- return;
9233
- // capture=true so scrolling any ancestor scroll container repositions.
9234
- window.addEventListener('scroll', this.reflow, true);
9235
- window.addEventListener('resize', this.reflow);
9236
- this.listening = true;
9237
- }
9238
- removeReflowListeners() {
9239
- if (!this.listening)
9240
- return;
9241
- window.removeEventListener('scroll', this.reflow, true);
9242
- window.removeEventListener('resize', this.reflow);
9243
- this.listening = false;
9244
- }
9245
- positionPanel() {
9246
- const triggerEl = this.trigger()?.nativeElement;
9247
- const panelEl = this.panelEl;
9248
- if (!triggerEl || !panelEl)
9249
- return;
9250
- // appendTo body: relocate out of any clipping/stacking ancestor.
9251
- if (panelEl.parentElement !== document.body) {
9252
- document.body.appendChild(panelEl);
9253
- }
9254
- const r = triggerEl.getBoundingClientRect();
9255
- const ph = panelEl.offsetHeight || 360;
9256
- const pw = panelEl.offsetWidth || 296;
9257
- const gap = 6;
9258
- const margin = 8;
9259
- // Below the field by default; flip above when there isn't room.
9260
- let top = r.bottom + gap;
9261
- if (top + ph > window.innerHeight - margin && r.top - gap - ph > margin) {
9262
- top = r.top - gap - ph;
9263
- }
9264
- // Align to the field's left, but keep it within the viewport.
9265
- let left = r.left;
9266
- if (left + pw > window.innerWidth - margin) {
9267
- left = Math.max(margin, window.innerWidth - margin - pw);
9268
- }
9269
- // Set directly (position: fixed → viewport coordinates) to avoid a flash.
9270
- panelEl.style.top = `${Math.round(top)}px`;
9271
- panelEl.style.left = `${Math.round(left)}px`;
9272
- }
9273
9309
  ngOnDestroy() {
9274
- this.removeReflowListeners();
9275
- this.panelEl?.remove();
9310
+ this.overlay.detach();
9276
9311
  }
9277
9312
  setValue(v) {
9278
9313
  this.value.set(v);
@@ -9379,7 +9414,7 @@ class SdsDatePicker {
9379
9414
 
9380
9415
  @if (open()) {
9381
9416
  <!-- Relocated to document.body on open (appendTo body) to escape
9382
- ancestor overflow/stacking; positioned via positionPanel(). -->
9417
+ ancestor overflow/stacking; placed by SdsOverlayPanel. -->
9383
9418
  <div
9384
9419
  class="fixed z-[1000]"
9385
9420
  role="dialog"
@@ -9514,7 +9549,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
9514
9549
 
9515
9550
  @if (open()) {
9516
9551
  <!-- Relocated to document.body on open (appendTo body) to escape
9517
- ancestor overflow/stacking; positioned via positionPanel(). -->
9552
+ ancestor overflow/stacking; placed by SdsOverlayPanel. -->
9518
9553
  <div
9519
9554
  class="fixed z-[1000]"
9520
9555
  role="dialog"
@@ -9943,7 +9978,12 @@ class SdsRangeCalendar {
9943
9978
  }
9944
9979
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsRangeCalendar, deps: [], target: i0.ɵɵFactoryTarget.Component });
9945
9980
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsRangeCalendar, isStandalone: true, selector: "sds-range-calendar", inputs: { start: { classPropertyName: "start", publicName: "start", isSignal: true, isRequired: false, transformFunction: null }, end: { classPropertyName: "end", publicName: "end", isSignal: true, isRequired: false, transformFunction: null }, min: { classPropertyName: "min", publicName: "min", isSignal: true, isRequired: false, transformFunction: null }, max: { classPropertyName: "max", publicName: "max", isSignal: true, isRequired: false, transformFunction: null }, maxDays: { classPropertyName: "maxDays", publicName: "maxDays", isSignal: true, isRequired: false, transformFunction: null }, firstDayOfWeek: { classPropertyName: "firstDayOfWeek", publicName: "firstDayOfWeek", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null }, months: { classPropertyName: "months", publicName: "months", isSignal: true, isRequired: false, transformFunction: null }, calendarLabel: { classPropertyName: "calendarLabel", publicName: "calendarLabel", isSignal: true, isRequired: false, transformFunction: null }, previousLabel: { classPropertyName: "previousLabel", publicName: "previousLabel", isSignal: true, isRequired: false, transformFunction: null }, nextLabel: { classPropertyName: "nextLabel", publicName: "nextLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { start: "startChange", end: "endChange", daySelect: "daySelect", rangeSelect: "rangeSelect", dayHover: "dayHover" }, host: { classAttribute: "block" }, ngImport: i0, template: `
9946
- <div class="select-none" role="group" [attr.aria-label]="calendarLabel()" (mouseleave)="setHover(null)">
9981
+ <div
9982
+ class="select-none"
9983
+ role="group"
9984
+ [attr.aria-label]="calendarLabel()"
9985
+ (mouseleave)="setHover(null)"
9986
+ >
9947
9987
  <div class="flex flex-col gap-4 sm:flex-row sm:gap-6">
9948
9988
  @for (m of monthViews(); track m.key; let first = $first; let last = $last) {
9949
9989
  <div class="w-[17.5rem]">
@@ -9979,7 +10019,11 @@ class SdsRangeCalendar {
9979
10019
  }
9980
10020
  </div>
9981
10021
 
9982
- <table class="w-full table-fixed border-collapse" role="grid" [attr.aria-label]="m.title">
10022
+ <table
10023
+ class="w-full table-fixed border-collapse"
10024
+ role="grid"
10025
+ [attr.aria-label]="m.title"
10026
+ >
9983
10027
  <thead>
9984
10028
  <tr>
9985
10029
  @for (wd of weekdayLabels(); track $index) {
@@ -10036,7 +10080,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
10036
10080
  imports: [SdsIcon],
10037
10081
  host: { class: 'block' },
10038
10082
  template: `
10039
- <div class="select-none" role="group" [attr.aria-label]="calendarLabel()" (mouseleave)="setHover(null)">
10083
+ <div
10084
+ class="select-none"
10085
+ role="group"
10086
+ [attr.aria-label]="calendarLabel()"
10087
+ (mouseleave)="setHover(null)"
10088
+ >
10040
10089
  <div class="flex flex-col gap-4 sm:flex-row sm:gap-6">
10041
10090
  @for (m of monthViews(); track m.key; let first = $first; let last = $last) {
10042
10091
  <div class="w-[17.5rem]">
@@ -10072,7 +10121,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
10072
10121
  }
10073
10122
  </div>
10074
10123
 
10075
- <table class="w-full table-fixed border-collapse" role="grid" [attr.aria-label]="m.title">
10124
+ <table
10125
+ class="w-full table-fixed border-collapse"
10126
+ role="grid"
10127
+ [attr.aria-label]="m.title"
10128
+ >
10076
10129
  <thead>
10077
10130
  <tr>
10078
10131
  @for (wd of weekdayLabels(); track $index) {
@@ -10220,8 +10273,8 @@ class SdsDateRangePicker {
10220
10273
  pendingEnd = signal(null, ...(ngDevMode ? [{ debugName: "pendingEnd" }] : []));
10221
10274
  calendar = viewChild(SdsRangeCalendar, ...(ngDevMode ? [{ debugName: "calendar" }] : []));
10222
10275
  trigger = viewChild('trigger', ...(ngDevMode ? [{ debugName: "trigger" }] : []));
10223
- /** The overlay panel, relocated to document.body while open. */
10224
- panelEl = null;
10276
+ /** Body-appending, positioning, follow-on-scroll and teardown — see SdsOverlayPanel. */
10277
+ overlay = new SdsOverlayPanel();
10225
10278
  /** Id of the preset the current value equals, or `null` (no value, or a custom range). */
10226
10279
  activePresetId = computed(() => this.presetIdFor(this.value()), ...(ngDevMode ? [{ debugName: "activePresetId" }] : []));
10227
10280
  pendingPresetId = computed(() => {
@@ -10290,9 +10343,12 @@ class SdsDateRangePicker {
10290
10343
  this.open.set(true);
10291
10344
  // Run after the overlay is rendered, then relocate it to <body>.
10292
10345
  afterNextRender(() => {
10293
- this.panelEl = this.host.nativeElement.querySelector('[role="dialog"]');
10294
- this.positionPanel(); // also relocates to document.body
10295
- this.addReflowListeners();
10346
+ const panel = this.host.nativeElement.querySelector('[role="dialog"]');
10347
+ const trigger = this.trigger()?.nativeElement;
10348
+ if (panel && trigger) {
10349
+ // gap 6, as this field has always used — the shared default is 4.
10350
+ this.overlay.attach(trigger, panel, { gap: 6 });
10351
+ }
10296
10352
  this.calendar()?.focusActiveCell();
10297
10353
  }, { injector: this.injector });
10298
10354
  }
@@ -10300,9 +10356,7 @@ class SdsDateRangePicker {
10300
10356
  close(returnFocus = false) {
10301
10357
  if (!this.open())
10302
10358
  return;
10303
- this.removeReflowListeners();
10304
- this.panelEl?.remove(); // pull the body-relocated node before destroying the view
10305
- this.panelEl = null;
10359
+ this.overlay.detach(); // pull the body-relocated node before destroying the view
10306
10360
  this.open.set(false);
10307
10361
  if (returnFocus)
10308
10362
  this.trigger()?.nativeElement.focus();
@@ -10339,17 +10393,18 @@ class SdsDateRangePicker {
10339
10393
  * modal's focus trap; this keeps Tab / Shift+Tab cycling within the panel's own focusables.
10340
10394
  */
10341
10395
  onPanelKeydown(event) {
10342
- if (event.key !== 'Tab' || !this.panelEl)
10396
+ const panel = this.overlay.element;
10397
+ if (event.key !== 'Tab' || !panel)
10343
10398
  return;
10344
10399
  const sel = 'a[href],button:not([disabled]):not([tabindex="-1"]),input:not([disabled]),' +
10345
10400
  '[tabindex]:not([tabindex="-1"])';
10346
- const items = Array.from(this.panelEl.querySelectorAll(sel)).filter((el) => el.offsetParent !== null);
10401
+ const items = Array.from(panel.querySelectorAll(sel)).filter((el) => el.offsetParent !== null);
10347
10402
  if (items.length === 0)
10348
10403
  return;
10349
10404
  const first = items[0];
10350
10405
  const last = items[items.length - 1];
10351
10406
  const active = document.activeElement;
10352
- if (event.shiftKey && (active === first || !this.panelEl.contains(active))) {
10407
+ if (event.shiftKey && (active === first || !panel.contains(active))) {
10353
10408
  event.preventDefault();
10354
10409
  last.focus();
10355
10410
  }
@@ -10364,58 +10419,13 @@ class SdsDateRangePicker {
10364
10419
  // composedPath is fixed at dispatch time, so a click on a node re-rendered away still counts.
10365
10420
  const path = event.composedPath();
10366
10421
  const inField = path.includes(this.host.nativeElement);
10367
- const inPanel = !!this.panelEl && path.includes(this.panelEl);
10422
+ const inPanel = !!this.overlay.element && path.includes(this.overlay.element);
10368
10423
  if (!inField && !inPanel)
10369
10424
  this.close();
10370
10425
  }
10371
10426
  // --- body-appended overlay positioning ---
10372
- reflow = () => this.positionPanel();
10373
- listening = false;
10374
- addReflowListeners() {
10375
- if (this.listening)
10376
- return;
10377
- window.addEventListener('scroll', this.reflow, true);
10378
- window.addEventListener('resize', this.reflow);
10379
- this.listening = true;
10380
- }
10381
- removeReflowListeners() {
10382
- if (!this.listening)
10383
- return;
10384
- window.removeEventListener('scroll', this.reflow, true);
10385
- window.removeEventListener('resize', this.reflow);
10386
- this.listening = false;
10387
- }
10388
- positionPanel() {
10389
- const triggerEl = this.trigger()?.nativeElement;
10390
- const panelEl = this.panelEl;
10391
- if (!triggerEl || !panelEl)
10392
- return;
10393
- if (panelEl.parentElement !== document.body) {
10394
- document.body.appendChild(panelEl);
10395
- }
10396
- const r = triggerEl.getBoundingClientRect();
10397
- const ph = panelEl.offsetHeight || 420;
10398
- const pw = panelEl.offsetWidth || 760;
10399
- const gap = 6;
10400
- const margin = 8;
10401
- // Below the field by default; flip above when there isn't room; else clamp into the viewport.
10402
- let top = r.bottom + gap;
10403
- if (top + ph > window.innerHeight - margin) {
10404
- if (r.top - gap - ph > margin)
10405
- top = r.top - gap - ph;
10406
- else
10407
- top = Math.max(margin, window.innerHeight - margin - ph);
10408
- }
10409
- let left = r.left;
10410
- if (left + pw > window.innerWidth - margin) {
10411
- left = Math.max(margin, window.innerWidth - margin - pw);
10412
- }
10413
- panelEl.style.top = `${Math.round(top)}px`;
10414
- panelEl.style.left = `${Math.round(left)}px`;
10415
- }
10416
10427
  ngOnDestroy() {
10417
- this.removeReflowListeners();
10418
- this.panelEl?.remove();
10428
+ this.overlay.detach();
10419
10429
  }
10420
10430
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsDateRangePicker, deps: [], target: i0.ɵɵFactoryTarget.Component });
10421
10431
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsDateRangePicker, isStandalone: true, selector: "sds-date-range-picker", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, presets: { classPropertyName: "presets", publicName: "presets", isSignal: true, isRequired: false, transformFunction: null }, min: { classPropertyName: "min", publicName: "min", isSignal: true, isRequired: false, transformFunction: null }, max: { classPropertyName: "max", publicName: "max", isSignal: true, isRequired: false, transformFunction: null }, maxDays: { classPropertyName: "maxDays", publicName: "maxDays", isSignal: true, isRequired: false, transformFunction: null }, firstDayOfWeek: { classPropertyName: "firstDayOfWeek", publicName: "firstDayOfWeek", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, months: { classPropertyName: "months", publicName: "months", isSignal: true, isRequired: false, transformFunction: null }, format: { classPropertyName: "format", publicName: "format", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, panelLabel: { classPropertyName: "panelLabel", publicName: "panelLabel", isSignal: true, isRequired: false, transformFunction: null }, presetsLabel: { classPropertyName: "presetsLabel", publicName: "presetsLabel", isSignal: true, isRequired: false, transformFunction: null }, startLabel: { classPropertyName: "startLabel", publicName: "startLabel", isSignal: true, isRequired: false, transformFunction: null }, endLabel: { classPropertyName: "endLabel", publicName: "endLabel", isSignal: true, isRequired: false, transformFunction: null }, applyLabel: { classPropertyName: "applyLabel", publicName: "applyLabel", isSignal: true, isRequired: false, transformFunction: null }, cancelLabel: { classPropertyName: "cancelLabel", publicName: "cancelLabel", isSignal: true, isRequired: false, transformFunction: null }, calendarLabel: { classPropertyName: "calendarLabel", publicName: "calendarLabel", isSignal: true, isRequired: false, transformFunction: null }, previousLabel: { classPropertyName: "previousLabel", publicName: "previousLabel", isSignal: true, isRequired: false, transformFunction: null }, nextLabel: { classPropertyName: "nextLabel", publicName: "nextLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", presetSelect: "presetSelect" }, host: { listeners: { "document:click": "onDocumentClick($event)", "keydown.escape": "onEscape($event)" }, classAttribute: "block" }, viewQueries: [{ propertyName: "calendar", first: true, predicate: SdsRangeCalendar, descendants: true, isSignal: true }, { propertyName: "trigger", first: true, predicate: ["trigger"], descendants: true, isSignal: true }], ngImport: i0, template: `
@@ -10433,7 +10443,9 @@ class SdsDateRangePicker {
10433
10443
  aria-haspopup="dialog"
10434
10444
  [attr.aria-expanded]="open()"
10435
10445
  [attr.aria-labelledby]="label() ? labelId + ' ' + valueId : null"
10436
- [attr.aria-label]="!label() && ariaLabel() ? ariaLabel() + ', ' + (displayValue() || placeholder()) : null"
10446
+ [attr.aria-label]="
10447
+ !label() && ariaLabel() ? ariaLabel() + ', ' + (displayValue() || placeholder()) : null
10448
+ "
10437
10449
  [class]="triggerClasses()"
10438
10450
  (click)="toggle($event)"
10439
10451
  (keydown.arrowdown)="onTriggerArrowDown($event)"
@@ -10458,7 +10470,7 @@ class SdsDateRangePicker {
10458
10470
 
10459
10471
  @if (open()) {
10460
10472
  <!-- Relocated to document.body on open (appendTo body) to escape ancestor overflow/stacking;
10461
- positioned via positionPanel(). -->
10473
+ placed by SdsOverlayPanel. -->
10462
10474
  <div
10463
10475
  class="fixed z-[1000] max-w-[calc(100vw-1rem)] overflow-auto rounded-card border border-border bg-surface shadow-lg"
10464
10476
  style="max-height: calc(100vh - 1rem)"
@@ -10475,7 +10487,9 @@ class SdsDateRangePicker {
10475
10487
  role="group"
10476
10488
  [attr.aria-label]="presetsLabel()"
10477
10489
  >
10478
- <p class="px-2 pb-1.5 pt-1 text-[11px] font-semibold uppercase tracking-wide text-ink-faint">
10490
+ <p
10491
+ class="px-2 pb-1.5 pt-1 text-[11px] font-semibold uppercase tracking-wide text-ink-faint"
10492
+ >
10479
10493
  {{ presetsLabel() }}
10480
10494
  </p>
10481
10495
  <div class="flex flex-wrap gap-1 sm:flex-col sm:flex-nowrap">
@@ -10515,15 +10529,27 @@ class SdsDateRangePicker {
10515
10529
  >
10516
10530
  <p class="flex items-stretch gap-2 text-sm" aria-live="polite">
10517
10531
  <span class="flex flex-col leading-tight">
10518
- <span class="text-[11px] font-medium uppercase text-ink-faint">{{ startLabel() }}</span>
10519
- <span class="font-medium" [class.text-ink]="pendingStart()" [class.text-ink-faint]="!pendingStart()">
10532
+ <span class="text-[11px] font-medium uppercase text-ink-faint">{{
10533
+ startLabel()
10534
+ }}</span>
10535
+ <span
10536
+ class="font-medium"
10537
+ [class.text-ink]="pendingStart()"
10538
+ [class.text-ink-faint]="!pendingStart()"
10539
+ >
10520
10540
  {{ pendingStartText() }}
10521
10541
  </span>
10522
10542
  </span>
10523
10543
  <span class="self-end text-ink-faint" aria-hidden="true">–</span>
10524
10544
  <span class="flex flex-col leading-tight">
10525
- <span class="text-[11px] font-medium uppercase text-ink-faint">{{ endLabel() }}</span>
10526
- <span class="font-medium" [class.text-ink]="pendingEnd()" [class.text-ink-faint]="!pendingEnd()">
10545
+ <span class="text-[11px] font-medium uppercase text-ink-faint">{{
10546
+ endLabel()
10547
+ }}</span>
10548
+ <span
10549
+ class="font-medium"
10550
+ [class.text-ink]="pendingEnd()"
10551
+ [class.text-ink-faint]="!pendingEnd()"
10552
+ >
10527
10553
  {{ pendingEndText() }}
10528
10554
  </span>
10529
10555
  </span>
@@ -10569,7 +10595,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
10569
10595
  aria-haspopup="dialog"
10570
10596
  [attr.aria-expanded]="open()"
10571
10597
  [attr.aria-labelledby]="label() ? labelId + ' ' + valueId : null"
10572
- [attr.aria-label]="!label() && ariaLabel() ? ariaLabel() + ', ' + (displayValue() || placeholder()) : null"
10598
+ [attr.aria-label]="
10599
+ !label() && ariaLabel() ? ariaLabel() + ', ' + (displayValue() || placeholder()) : null
10600
+ "
10573
10601
  [class]="triggerClasses()"
10574
10602
  (click)="toggle($event)"
10575
10603
  (keydown.arrowdown)="onTriggerArrowDown($event)"
@@ -10594,7 +10622,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
10594
10622
 
10595
10623
  @if (open()) {
10596
10624
  <!-- Relocated to document.body on open (appendTo body) to escape ancestor overflow/stacking;
10597
- positioned via positionPanel(). -->
10625
+ placed by SdsOverlayPanel. -->
10598
10626
  <div
10599
10627
  class="fixed z-[1000] max-w-[calc(100vw-1rem)] overflow-auto rounded-card border border-border bg-surface shadow-lg"
10600
10628
  style="max-height: calc(100vh - 1rem)"
@@ -10611,7 +10639,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
10611
10639
  role="group"
10612
10640
  [attr.aria-label]="presetsLabel()"
10613
10641
  >
10614
- <p class="px-2 pb-1.5 pt-1 text-[11px] font-semibold uppercase tracking-wide text-ink-faint">
10642
+ <p
10643
+ class="px-2 pb-1.5 pt-1 text-[11px] font-semibold uppercase tracking-wide text-ink-faint"
10644
+ >
10615
10645
  {{ presetsLabel() }}
10616
10646
  </p>
10617
10647
  <div class="flex flex-wrap gap-1 sm:flex-col sm:flex-nowrap">
@@ -10651,15 +10681,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
10651
10681
  >
10652
10682
  <p class="flex items-stretch gap-2 text-sm" aria-live="polite">
10653
10683
  <span class="flex flex-col leading-tight">
10654
- <span class="text-[11px] font-medium uppercase text-ink-faint">{{ startLabel() }}</span>
10655
- <span class="font-medium" [class.text-ink]="pendingStart()" [class.text-ink-faint]="!pendingStart()">
10684
+ <span class="text-[11px] font-medium uppercase text-ink-faint">{{
10685
+ startLabel()
10686
+ }}</span>
10687
+ <span
10688
+ class="font-medium"
10689
+ [class.text-ink]="pendingStart()"
10690
+ [class.text-ink-faint]="!pendingStart()"
10691
+ >
10656
10692
  {{ pendingStartText() }}
10657
10693
  </span>
10658
10694
  </span>
10659
10695
  <span class="self-end text-ink-faint" aria-hidden="true">–</span>
10660
10696
  <span class="flex flex-col leading-tight">
10661
- <span class="text-[11px] font-medium uppercase text-ink-faint">{{ endLabel() }}</span>
10662
- <span class="font-medium" [class.text-ink]="pendingEnd()" [class.text-ink-faint]="!pendingEnd()">
10697
+ <span class="text-[11px] font-medium uppercase text-ink-faint">{{
10698
+ endLabel()
10699
+ }}</span>
10700
+ <span
10701
+ class="font-medium"
10702
+ [class.text-ink]="pendingEnd()"
10703
+ [class.text-ink-faint]="!pendingEnd()"
10704
+ >
10663
10705
  {{ pendingEndText() }}
10664
10706
  </span>
10665
10707
  </span>
@@ -10682,112 +10724,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
10682
10724
  }], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], presets: [{ type: i0.Input, args: [{ isSignal: true, alias: "presets", required: false }] }], min: [{ type: i0.Input, args: [{ isSignal: true, alias: "min", required: false }] }], max: [{ type: i0.Input, args: [{ isSignal: true, alias: "max", required: false }] }], maxDays: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxDays", required: false }] }], firstDayOfWeek: [{ type: i0.Input, args: [{ isSignal: true, alias: "firstDayOfWeek", required: false }] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], months: [{ type: i0.Input, args: [{ isSignal: true, alias: "months", required: false }] }], format: [{ type: i0.Input, args: [{ isSignal: true, alias: "format", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], panelLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "panelLabel", required: false }] }], presetsLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "presetsLabel", required: false }] }], startLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "startLabel", required: false }] }], endLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "endLabel", required: false }] }], applyLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "applyLabel", required: false }] }], cancelLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "cancelLabel", required: false }] }], calendarLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "calendarLabel", required: false }] }], previousLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "previousLabel", required: false }] }], nextLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "nextLabel", required: false }] }], presetSelect: [{ type: i0.Output, args: ["presetSelect"] }], calendar: [{ type: i0.ViewChild, args: [i0.forwardRef(() => SdsRangeCalendar), { isSignal: true }] }], trigger: [{ type: i0.ViewChild, args: ['trigger', { isSignal: true }] }] } });
10683
10725
  let nextId$a = 0;
10684
10726
 
10685
- /**
10686
- * Where a floating panel goes, and the plumbing that keeps it there.
10687
- *
10688
- * <p><b>Why this exists.</b> Ten components in this library append a panel to `<body>` and position
10689
- * it against their trigger, and nine of them had their own copy of the same twenty-odd lines. The
10690
- * copies had already drifted: some clamp the panel inside the viewport vertically and some only
10691
- * flip it, some match the trigger's width and some do not. Worse, the *lifecycle* was duplicated
10692
- * too — and `sds-autocomplete` shipped a version that removed the panel through a `viewChild`
10693
- * already cleared by `ngOnDestroy`, orphaning it in `<body>` for ever. That bug could only exist in
10694
- * one copy of nine.
10695
- */
10696
- /**
10697
- * The arithmetic, with no DOM in it.
10698
- *
10699
- * <p>Vertical: below the trigger, flipped above when it does not fit below and fits better above —
10700
- * then <b>clamped inside the viewport</b>. The clamp is not decoration: without it a panel taller
10701
- * than the space either side is pushed off the top edge and its first rows become unreachable, and
10702
- * four of the nine copies were missing it.
10703
- *
10704
- * <p>Horizontal: the panel's start (or end) edge meets the trigger's, then clamped the same way. A
10705
- * panel wider than the viewport pins to the left margin rather than running off both sides.
10706
- */
10707
- function placePanel(trigger, panel, viewport, options = {}) {
10708
- const gap = options.gap ?? 4;
10709
- const margin = options.margin ?? 8;
10710
- const roomBelow = viewport.height - trigger.bottom - gap;
10711
- const roomAbove = trigger.top - gap;
10712
- const above = options.prefer === 'above'
10713
- ? roomAbove >= panel.height || roomAbove > roomBelow
10714
- : roomBelow < panel.height && roomAbove > roomBelow;
10715
- const top = clamp(above ? trigger.top - gap - panel.height : trigger.bottom + gap, margin, viewport.height - margin - panel.height);
10716
- const left = clamp(options.align === 'end' ? trigger.right - panel.width : trigger.left, margin, viewport.width - margin - panel.width);
10717
- return { top: Math.round(top), left: Math.round(left) };
10718
- }
10719
- /**
10720
- * A viewport bound that may be smaller than the lower one — when the panel is taller than the
10721
- * window there is no position that satisfies both, and the top edge is the one that must win, or
10722
- * the panel starts off-screen and cannot be scrolled back.
10723
- */
10724
- function clamp(value, low, high) {
10725
- return Math.max(low, Math.min(value, Math.max(low, high)));
10726
- }
10727
- /**
10728
- * The DOM half: append to `<body>`, place, follow scroll and resize, and — the part that was worth
10729
- * centralising — take it all down again exactly once.
10730
- *
10731
- * <p>Hold one of these per component and call {@link detach} from `ngOnDestroy`. It keeps its own
10732
- * reference to the panel element, so it does not depend on a `viewChild` that Angular has already
10733
- * cleared by the time the component is torn down.
10734
- */
10735
- class SdsOverlayPanel {
10736
- trigger = null;
10737
- panel = null;
10738
- options = {};
10739
- listening = false;
10740
- reflow = () => this.reposition();
10741
- /** Attach (or re-attach) the panel to the body and start following the trigger. */
10742
- attach(trigger, panel, options = {}) {
10743
- this.trigger = trigger;
10744
- this.panel = panel;
10745
- this.options = options;
10746
- if (panel.parentElement !== document.body) {
10747
- document.body.appendChild(panel);
10748
- }
10749
- this.reposition();
10750
- if (!this.listening) {
10751
- // `true` for scroll: a panel inside a scrolling container must follow it, and scroll does
10752
- // not bubble.
10753
- window.addEventListener('scroll', this.reflow, true);
10754
- window.addEventListener('resize', this.reflow);
10755
- this.listening = true;
10756
- }
10757
- }
10758
- reposition() {
10759
- const trigger = this.trigger;
10760
- const panel = this.panel;
10761
- if (!trigger || !panel) {
10762
- return;
10763
- }
10764
- const r = trigger.getBoundingClientRect();
10765
- if (this.options.matchTriggerWidth) {
10766
- panel.style.minWidth = `${Math.round(r.width)}px`;
10767
- }
10768
- const { top, left } = placePanel(r,
10769
- // offset* is 0 before the panel has laid out; the fallbacks keep the first frame sane.
10770
- { width: panel.offsetWidth || r.width, height: panel.offsetHeight || 240 }, { width: window.innerWidth, height: window.innerHeight }, this.options);
10771
- panel.style.top = `${top}px`;
10772
- panel.style.left = `${left}px`;
10773
- }
10774
- /** Does the panel contain this node? For "did the click land outside" checks. */
10775
- contains(node) {
10776
- return !!node && !!this.panel?.contains(node);
10777
- }
10778
- /** Stop following, and remove the panel from the body. Safe to call more than once. */
10779
- detach() {
10780
- if (this.listening) {
10781
- window.removeEventListener('scroll', this.reflow, true);
10782
- window.removeEventListener('resize', this.reflow);
10783
- this.listening = false;
10784
- }
10785
- this.panel?.remove();
10786
- this.panel = null;
10787
- this.trigger = null;
10788
- }
10789
- }
10790
-
10791
10727
  /**
10792
10728
  * Supplies a custom template for each option row (and the selected value shown
10793
10729
  * in the trigger) of an `<sds-select>` / `<sds-multi-select>`. Use as a
@@ -11224,7 +11160,7 @@ class SdsSelect {
11224
11160
  attachPanel() {
11225
11161
  const trigger = this.trigger()?.nativeElement;
11226
11162
  if (trigger && this.panelEl) {
11227
- this.overlay.attach(trigger, this.panelEl, { matchTriggerWidth: true });
11163
+ this.overlay.attach(trigger, this.panelEl, { width: 'min' });
11228
11164
  }
11229
11165
  }
11230
11166
  onDocumentClick(event) {
@@ -12010,7 +11946,7 @@ class SdsMultiSelect {
12010
11946
  attachPanel() {
12011
11947
  const trigger = this.trigger()?.nativeElement;
12012
11948
  if (trigger && this.panelEl) {
12013
- this.overlay.attach(trigger, this.panelEl, { matchTriggerWidth: true });
11949
+ this.overlay.attach(trigger, this.panelEl, { width: 'min' });
12014
11950
  }
12015
11951
  }
12016
11952
  onDocumentClick(event) {
@@ -12813,7 +12749,7 @@ class SdsAutocomplete {
12813
12749
  const trigger = this.triggerEl()?.nativeElement;
12814
12750
  const panel = this.panelRef()?.nativeElement;
12815
12751
  if (trigger && panel) {
12816
- this.overlay.attach(trigger, panel, { matchTriggerWidth: true });
12752
+ this.overlay.attach(trigger, panel, { width: 'min' });
12817
12753
  }
12818
12754
  }
12819
12755
  ngOnDestroy() {
@@ -13735,7 +13671,7 @@ class SdsPhoneInput {
13735
13671
  attachPanel() {
13736
13672
  const trigger = this.anchor()?.nativeElement;
13737
13673
  if (trigger && this.panelEl) {
13738
- this.overlay.attach(trigger, this.panelEl, { matchTriggerWidth: true });
13674
+ this.overlay.attach(trigger, this.panelEl, { width: 'min' });
13739
13675
  }
13740
13676
  }
13741
13677
  onDocumentClick(event) {
@@ -14272,7 +14208,8 @@ class SdsAddressAutocomplete {
14272
14208
  seq = 0;
14273
14209
  timer;
14274
14210
  destroyed = false;
14275
- panelEl = null;
14211
+ /** Body-appending, positioning, follow-on-scroll and teardown — see SdsOverlayPanel. */
14212
+ overlay = new SdsOverlayPanel();
14276
14213
  fieldId = computed(() => this.inputId() || this.autoId, ...(ngDevMode ? [{ debugName: "fieldId" }] : []));
14277
14214
  isDisabled = computed(() => this.disabled() || this.cvaDisabled(), ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
14278
14215
  pill = computed(() => this.shape() === 'pill', ...(ngDevMode ? [{ debugName: "pill" }] : []));
@@ -14535,79 +14472,57 @@ class SdsAddressAutocomplete {
14535
14472
  }
14536
14473
  scrollHighlightedIntoView() {
14537
14474
  setTimeout(() => {
14538
- const root = this.panelEl ?? this.host.nativeElement;
14475
+ const root = this.overlay.element ?? this.host.nativeElement;
14539
14476
  root
14540
14477
  .querySelector(`[data-index="${this.highlighted()}"]`)
14541
14478
  ?.scrollIntoView?.({ block: 'nearest' });
14542
14479
  });
14543
14480
  }
14544
14481
  // --- overlay ----------------------------------------------------------------
14545
- /** Close the overlay (a no-op `inline`, where the rows are always shown). */
14546
- close() {
14547
- this.open.set(false);
14548
- this.highlighted.set(-1);
14549
- this.detachPanel();
14550
- }
14551
- reflow = () => this.positionPanel();
14552
- listening = false;
14482
+ /**
14483
+ * Keep the body-appended panel in step with whether it should be shown at all.
14484
+ *
14485
+ * <p>Driven from `afterEveryRender`, so it runs on every change and must be cheap and idempotent
14486
+ * — attaching once, then repositioning while it stays open.
14487
+ */
14553
14488
  syncPanel() {
14554
14489
  if (this.inline() || !this.panelShown()) {
14555
- if (this.panelEl)
14556
- this.detachPanel();
14490
+ this.overlay.detach();
14557
14491
  return;
14558
14492
  }
14559
- if (!this.panelEl) {
14560
- this.panelEl = this.host.nativeElement.querySelector('[data-sds-address-panel]');
14561
- if (!this.panelEl)
14562
- return;
14563
- document.body.appendChild(this.panelEl);
14564
- if (!this.listening) {
14565
- window.addEventListener('scroll', this.reflow, true);
14566
- window.addEventListener('resize', this.reflow);
14567
- this.listening = true;
14568
- }
14569
- }
14570
- this.positionPanel();
14571
- }
14572
- detachPanel() {
14573
- if (this.listening) {
14574
- window.removeEventListener('scroll', this.reflow, true);
14575
- window.removeEventListener('resize', this.reflow);
14576
- this.listening = false;
14577
- }
14578
- this.panelEl?.remove();
14579
- this.panelEl = null;
14580
- }
14581
- positionPanel() {
14582
- const fieldEl = this.fieldRef()?.nativeElement;
14583
- const panelEl = this.panelEl;
14584
- if (!fieldEl || !panelEl)
14493
+ const field = this.fieldRef()?.nativeElement;
14494
+ if (!field)
14495
+ return;
14496
+ if (this.overlay.element) {
14497
+ this.overlay.reposition();
14585
14498
  return;
14586
- const r = fieldEl.getBoundingClientRect();
14587
- const gap = 4;
14588
- const margin = 8;
14589
- panelEl.style.width = `${Math.round(r.width)}px`;
14590
- const ph = panelEl.offsetHeight || 240;
14591
- let top = r.bottom + gap;
14592
- if (top + ph > window.innerHeight - margin && r.top - gap - ph > margin) {
14593
- top = r.top - gap - ph;
14594
14499
  }
14595
- panelEl.style.top = `${Math.round(top)}px`;
14596
- panelEl.style.left = `${Math.round(Math.max(margin, r.left))}px`;
14500
+ const panel = this.host.nativeElement.querySelector('[data-sds-address-panel]');
14501
+ if (!panel)
14502
+ return;
14503
+ // `exact`, not `min`: a suggestion list wider than the input it hangs off stops reading as
14504
+ // part of the field.
14505
+ this.overlay.attach(field, panel, { width: 'exact' });
14506
+ }
14507
+ /** Close the overlay (a no-op `inline`, where the rows are always shown). */
14508
+ close() {
14509
+ this.open.set(false);
14510
+ this.highlighted.set(-1);
14511
+ this.overlay.detach();
14597
14512
  }
14598
14513
  onDocumentClick(event) {
14599
14514
  if (this.inline() || !this.open())
14600
14515
  return;
14601
14516
  const target = event.target;
14602
14517
  const inField = this.host.nativeElement.contains(target);
14603
- const inPanel = !!this.panelEl && this.panelEl.contains(target);
14518
+ const inPanel = this.overlay.contains(target);
14604
14519
  if (!inField && !inPanel)
14605
14520
  this.close();
14606
14521
  }
14607
14522
  ngOnDestroy() {
14608
14523
  this.destroyed = true;
14609
14524
  clearTimeout(this.timer);
14610
- this.detachPanel();
14525
+ this.overlay.detach();
14611
14526
  }
14612
14527
  // --- ControlValueAccessor -----------------------------------------------------
14613
14528
  onChange = () => { };
@@ -14714,7 +14629,7 @@ class SdsAddressAutocomplete {
14714
14629
  <ng-container [ngTemplateOutlet]="panelBody" />
14715
14630
  </div>
14716
14631
  } @else if (panelShown()) {
14717
- <!-- Appended to document.body on open (see syncPanel). -->
14632
+ <!-- Appended to document.body on open (see SdsOverlayPanel). -->
14718
14633
  <div
14719
14634
  data-sds-address-panel
14720
14635
  class="fixed z-[1000] max-h-80 overflow-auto rounded-card border border-border bg-surface p-1 shadow-lg"
@@ -14939,7 +14854,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
14939
14854
  <ng-container [ngTemplateOutlet]="panelBody" />
14940
14855
  </div>
14941
14856
  } @else if (panelShown()) {
14942
- <!-- Appended to document.body on open (see syncPanel). -->
14857
+ <!-- Appended to document.body on open (see SdsOverlayPanel). -->
14943
14858
  <div
14944
14859
  data-sds-address-panel
14945
14860
  class="fixed z-[1000] max-h-80 overflow-auto rounded-card border border-border bg-surface p-1 shadow-lg"
@@ -21309,7 +21224,7 @@ let nextId = 0;
21309
21224
  * disclosure mechanics (closes on outside click, on Escape, and on selection, restoring focus to the trigger),
21310
21225
  * and the same panel styling and open animation — so the three top-bar controls read as one set.
21311
21226
  *
21312
- * The panel is appended to `document.body` while open and positioned against the trigger (like sds-select),
21227
+ * The panel is appended to `document.body` while open and placed by `SdsOverlayPanel` (like sds-select),
21313
21228
  * so a clipping ancestor — a bar with `overflow:hidden`, or a `glass` bar whose `backdrop-filter` is a
21314
21229
  * containing block — can never cut it off or paint over it. It repositions on scroll/resize and is removed
21315
21230
  * on close. Accessibility follows the APG select-only combobox: focus stays on the trigger, and Arrow/Home/
@@ -21350,7 +21265,8 @@ class SdsLanguageSwitcher {
21350
21265
  openChange = output();
21351
21266
  open = signal(false, ...(ngDevMode ? [{ debugName: "open" }] : []));
21352
21267
  highlightedIndex = signal(-1, ...(ngDevMode ? [{ debugName: "highlightedIndex" }] : []));
21353
- panelEl = null;
21268
+ /** Body-appending, positioning, follow-on-scroll and teardown — see SdsOverlayPanel. */
21269
+ overlay = new SdsOverlayPanel();
21354
21270
  /** Normalise the mixed `languages` input to `{ code, label }`, defaulting the label to the code. */
21355
21271
  options = computed(() => this.languages().map((l) => typeof l === 'string'
21356
21272
  ? { code: l, label: l.toUpperCase() }
@@ -21377,9 +21293,17 @@ class SdsLanguageSwitcher {
21377
21293
  // clipping/stacking ancestor (an overflow:hidden bar, a backdrop-filter glass bar) can cut it off.
21378
21294
  // Focus stays on the trigger (combobox model); the active row is surfaced via aria-activedescendant.
21379
21295
  afterNextRender(() => {
21380
- this.panelEl = this.el.nativeElement.querySelector('[data-sds-language-panel]');
21381
- this.positionPanel();
21382
- this.addReflowListeners();
21296
+ const panel = this.el.nativeElement.querySelector('[data-sds-language-panel]');
21297
+ const trigger = this.trigger()?.nativeElement;
21298
+ if (panel && trigger) {
21299
+ // gap 8 and an end-aligned panel, as this control has always used; `direction="up"`
21300
+ // becomes the shared `prefer: 'above'`.
21301
+ this.overlay.attach(trigger, panel, {
21302
+ gap: 8,
21303
+ align: this.align(),
21304
+ prefer: this.direction() === 'up' ? 'above' : 'below',
21305
+ });
21306
+ }
21383
21307
  this.trigger()?.nativeElement.focus();
21384
21308
  this.scrollHighlightedIntoView();
21385
21309
  }, { injector: this.injector });
@@ -21387,10 +21311,8 @@ class SdsLanguageSwitcher {
21387
21311
  close(returnFocus = false) {
21388
21312
  if (!this.open())
21389
21313
  return;
21390
- this.removeReflowListeners();
21391
21314
  // Remove the portalled node ourselves before Angular tears the view down (it lives in <body> now).
21392
- this.panelEl?.remove();
21393
- this.panelEl = null;
21315
+ this.overlay.detach();
21394
21316
  this.open.set(false);
21395
21317
  this.openChange.emit(false);
21396
21318
  if (returnFocus)
@@ -21406,7 +21328,7 @@ class SdsLanguageSwitcher {
21406
21328
  return;
21407
21329
  const target = event.target;
21408
21330
  const inHost = this.el.nativeElement.contains(target);
21409
- const inPanel = !!this.panelEl && this.panelEl.contains(target);
21331
+ const inPanel = this.overlay.contains(target);
21410
21332
  if (!inHost && !inPanel)
21411
21333
  this.close();
21412
21334
  }
@@ -21453,7 +21375,8 @@ class SdsLanguageSwitcher {
21453
21375
  case 'Tab':
21454
21376
  this.close(false);
21455
21377
  break;
21456
- default: break;
21378
+ default:
21379
+ break;
21457
21380
  }
21458
21381
  }
21459
21382
  initHighlight() {
@@ -21480,68 +21403,19 @@ class SdsLanguageSwitcher {
21480
21403
  }
21481
21404
  scrollHighlightedIntoView() {
21482
21405
  setTimeout(() => {
21483
- this.panelEl
21406
+ this.overlay.element
21484
21407
  ?.querySelector(`[data-index="${this.highlightedIndex()}"]`)
21485
21408
  ?.scrollIntoView({ block: 'nearest' });
21486
21409
  });
21487
21410
  }
21488
- // --- body-appended overlay positioning (mirrors sds-select / sds-date-picker) ---
21489
- reflow = () => this.positionPanel();
21490
- listening = false;
21491
- addReflowListeners() {
21492
- if (this.listening)
21493
- return;
21494
- window.addEventListener('scroll', this.reflow, true);
21495
- window.addEventListener('resize', this.reflow);
21496
- this.listening = true;
21497
- }
21498
- removeReflowListeners() {
21499
- if (!this.listening)
21500
- return;
21501
- window.removeEventListener('scroll', this.reflow, true);
21502
- window.removeEventListener('resize', this.reflow);
21503
- this.listening = false;
21504
- }
21505
- positionPanel() {
21506
- const triggerEl = this.trigger()?.nativeElement;
21507
- const panelEl = this.panelEl;
21508
- if (!triggerEl || !panelEl)
21509
- return;
21510
- if (panelEl.parentElement !== document.body)
21511
- document.body.appendChild(panelEl);
21512
- const r = triggerEl.getBoundingClientRect();
21513
- const gap = 8;
21514
- const margin = 8;
21515
- const pw = panelEl.offsetWidth || 176;
21516
- const ph = panelEl.offsetHeight || 200;
21517
- // Vertical: below by default; open above when asked (direction='up') and there is room, or when there
21518
- // isn't room below but there is above. The panel height is bounded by the list's max-h, but clamp
21519
- // defensively so top can never be pushed off the top edge.
21520
- const roomBelow = window.innerHeight - r.bottom - gap;
21521
- const roomAbove = r.top - gap;
21522
- let top = (this.direction() === 'up' && roomAbove >= ph) || (roomBelow < ph && roomAbove > roomBelow)
21523
- ? r.top - gap - ph
21524
- : r.bottom + gap;
21525
- if (top + ph > window.innerHeight - margin)
21526
- top = window.innerHeight - margin - ph;
21527
- if (top < margin)
21528
- top = margin;
21529
- // Horizontal: align the panel's end (right) or start (left) edge to the trigger, then clamp.
21530
- let left = this.align() === 'end' ? r.right - pw : r.left;
21531
- left = Math.min(left, window.innerWidth - margin - pw);
21532
- left = Math.max(margin, left);
21533
- panelEl.style.top = `${Math.round(top)}px`;
21534
- panelEl.style.left = `${Math.round(left)}px`;
21535
- }
21536
21411
  ngOnDestroy() {
21537
- this.removeReflowListeners();
21538
- this.panelEl?.remove();
21412
+ this.overlay.detach();
21539
21413
  }
21540
21414
  onBrand = computed(() => this.appearance() === 'on-brand', ...(ngDevMode ? [{ debugName: "onBrand" }] : []));
21541
21415
  triggerClass = computed(() => this.onBrand()
21542
21416
  ? 'text-white/85 hover:bg-white/10 hover:text-white focus-visible:ring-white/40'
21543
21417
  : 'bg-muted text-ink-soft hover:bg-brand-50 hover:text-brand-600 focus-visible:ring-brand-500/40', ...(ngDevMode ? [{ debugName: "triggerClass" }] : []));
21544
- chevronClass = computed(() => (this.onBrand() ? 'text-white/70' : 'text-ink-faint'), ...(ngDevMode ? [{ debugName: "chevronClass" }] : []));
21418
+ chevronClass = computed(() => this.onBrand() ? 'text-white/70' : 'text-ink-faint', ...(ngDevMode ? [{ debugName: "chevronClass" }] : []));
21545
21419
  rowClass(index, selected) {
21546
21420
  const base = 'flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-sm font-medium transition-colors';
21547
21421
  const highlighted = this.highlightedIndex() === index;
@@ -21582,7 +21456,7 @@ class SdsLanguageSwitcher {
21582
21456
  </button>
21583
21457
 
21584
21458
  @if (open()) {
21585
- <!-- Appended to document.body on open (see positionPanel) so no ancestor can clip it. -->
21459
+ <!-- Appended to document.body on open (see SdsOverlayPanel) so no ancestor can clip it. -->
21586
21460
  <div
21587
21461
  data-sds-language-panel
21588
21462
  [id]="panelId"
@@ -21591,7 +21465,9 @@ class SdsLanguageSwitcher {
21591
21465
  class="fixed z-[1000] min-w-44 overflow-hidden rounded-card border border-border bg-surface shadow-xl animate-sds-menu"
21592
21466
  >
21593
21467
  @if (heading(); as h) {
21594
- <p class="border-b border-border px-4 pb-2 pt-3 text-[0.68rem] font-semibold uppercase tracking-wider text-ink-faint">
21468
+ <p
21469
+ class="border-b border-border px-4 pb-2 pt-3 text-[0.68rem] font-semibold uppercase tracking-wider text-ink-faint"
21470
+ >
21595
21471
  {{ h }}
21596
21472
  </p>
21597
21473
  }
@@ -21657,7 +21533,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
21657
21533
  </button>
21658
21534
 
21659
21535
  @if (open()) {
21660
- <!-- Appended to document.body on open (see positionPanel) so no ancestor can clip it. -->
21536
+ <!-- Appended to document.body on open (see SdsOverlayPanel) so no ancestor can clip it. -->
21661
21537
  <div
21662
21538
  data-sds-language-panel
21663
21539
  [id]="panelId"
@@ -21666,7 +21542,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
21666
21542
  class="fixed z-[1000] min-w-44 overflow-hidden rounded-card border border-border bg-surface shadow-xl animate-sds-menu"
21667
21543
  >
21668
21544
  @if (heading(); as h) {
21669
- <p class="border-b border-border px-4 pb-2 pt-3 text-[0.68rem] font-semibold uppercase tracking-wider text-ink-faint">
21545
+ <p
21546
+ class="border-b border-border px-4 pb-2 pt-3 text-[0.68rem] font-semibold uppercase tracking-wider text-ink-faint"
21547
+ >
21670
21548
  {{ h }}
21671
21549
  </p>
21672
21550
  }