@brightspace-ui/core 3.74.1 → 3.75.0

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.
@@ -1,10 +1,19 @@
1
1
  import '../colors/colors.js';
2
2
  import '../focus-trap/focus-trap.js';
3
3
  import { clearDismissible, setDismissible } from '../../helpers/dismissible.js';
4
- import { css, html } from 'lit';
4
+ import { css, html, nothing } from 'lit';
5
5
  import { getComposedActiveElement, getFirstFocusableDescendant, getPreviousFocusableAncestor } from '../../helpers/focus.js';
6
- import { isComposedAncestor } from '../../helpers/dom.js';
7
-
6
+ import { getComposedParent, isComposedAncestor } from '../../helpers/dom.js';
7
+ import { _offscreenStyleDeclarations } from '../offscreen/offscreen.js';
8
+ import { styleMap } from 'lit/directives/style-map.js';
9
+
10
+ const defaultPreferredPosition = {
11
+ location: 'block-end', // block-start, block-end
12
+ span: 'all', // start, end, all
13
+ allowFlip: true
14
+ };
15
+ const pointerLength = 16;
16
+ const pointerRotatedLength = Math.SQRT2 * parseFloat(pointerLength);
8
17
  const isSupported = ('popover' in HTMLElement.prototype);
9
18
 
10
19
  // eslint-disable-next-line no-console
@@ -14,11 +23,27 @@ export const PopoverMixin = superclass => class extends superclass {
14
23
 
15
24
  static get properties() {
16
25
  return {
26
+ _contentHeight: { state: true },
27
+ _location: { type: String, reflect: true, attribute: '_location' },
28
+ _margin: { state: true },
29
+ _maxHeight: { state: true },
30
+ _maxWidth: { state: true },
31
+ _minHeight: { state: true },
32
+ _minWidth: { state: true },
17
33
  _noAutoClose: { state: true },
34
+ _noAutoFit: { state: true },
18
35
  _noAutoFocus: { state: true },
36
+ _noPointer: { state: true },
37
+ _offscreen: { type: Boolean, reflect: true, attribute: '_offscreen' },
38
+ _offset: { state: true },
19
39
  _opened: { type: Boolean, reflect: true, attribute: '_opened' },
40
+ _pointerPosition: { state: true },
41
+ _position: { state: true },
42
+ _preferredPosition: { state: true },
43
+ _rtl: { state: true },
20
44
  _trapFocus: { state: true },
21
- _useNativePopover: { type: String, reflect: true, attribute: 'popover' }
45
+ _useNativePopover: { type: String, reflect: true, attribute: 'popover' },
46
+ _width: { state: true }
22
47
  };
23
48
  }
24
49
 
@@ -31,18 +56,18 @@ export const PopoverMixin = superclass => class extends superclass {
31
56
  --d2l-popover-default-border-radius: 0.3rem;
32
57
  --d2l-popover-default-foreground-color: var(--d2l-color-ferrite);
33
58
  --d2l-popover-default-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.15);
34
- background-color: transparent;
35
- border: none;
59
+ background-color: transparent; /* override popover default */
60
+ border: none; /* override popover */
36
61
  box-sizing: border-box;
37
62
  color: var(--d2l-popover-foreground-color, var(--d2l-popover-default-foreground-color));
38
63
  display: none;
39
- height: fit-content;
40
- inset: 0;
41
- margin: auto;
42
- overflow: visible;
43
- padding: 0;
44
- position: fixed;
45
- width: fit-content;
64
+ height: fit-content; /* normalize popover */
65
+ inset: 0; /* normalize popover */
66
+ margin: 0; /* override popover */
67
+ overflow: visible; /* override popover */
68
+ padding: 0; /* override popover */
69
+ position: fixed; /* normalize popover */
70
+ width: fit-content; /* normalize popover */
46
71
  }
47
72
  :host([hidden]) {
48
73
  display: none;
@@ -53,14 +78,56 @@ export const PopoverMixin = superclass => class extends superclass {
53
78
  :host([_opened]) {
54
79
  display: inline-block;
55
80
  }
81
+ :host([_location="block-start"]) {
82
+ bottom: 0;
83
+ top: auto;
84
+ }
56
85
 
57
- .content {
86
+ .content-position {
87
+ display: inline-block;
88
+ position: absolute;
89
+ }
90
+ .content-width {
58
91
  background-color: var(--d2l-popover-background-color, var(--d2l-popover-default-background-color));
59
92
  border: 1px solid var(--d2l-popover-border-color, var(--d2l-popover-default-border-color));
60
93
  border-radius: var(--d2l-popover-border-radius, var(--d2l-popover-default-border-radius));
61
94
  box-shadow: var(--d2l-popover-shadow, var(--d2l-popover-default-shadow));
62
95
  box-sizing: border-box;
96
+ max-width: 370px;
97
+ min-width: 70px;
98
+ width: 100vw;
99
+ }
100
+ .content-container {
101
+ box-sizing: border-box;
102
+ display: inline-block;
103
+ max-width: 100%;
63
104
  outline: none;
105
+ overflow-y: auto;
106
+ }
107
+
108
+ .pointer {
109
+ clip: rect(-5px, 21px, 8px, -7px);
110
+ display: inline-block;
111
+ position: absolute;
112
+ z-index: 1;
113
+ }
114
+
115
+ .pointer > div {
116
+ background-color: var(--d2l-popover-background-color, var(--d2l-popover-default-background-color));
117
+ border: 1px solid var(--d2l-popover-border-color, var(--d2l-popover-default-border-color));
118
+ border-radius: 0.1rem;
119
+ box-shadow: -4px -4px 12px -5px rgba(32, 33, 34, 0.2); /* ferrite */
120
+ height: ${pointerLength}px;
121
+ transform: rotate(45deg);
122
+ width: ${pointerLength}px;
123
+ }
124
+
125
+ :host([_location="block-start"]) .pointer {
126
+ clip: rect(9px, 21px, 22px, -3px);
127
+ }
128
+
129
+ :host([_location="block-start"]) .pointer > div {
130
+ box-shadow: 4px 4px 12px -5px rgba(32, 33, 34, 0.2); /* ferrite */
64
131
  }
65
132
 
66
133
  @keyframes d2l-popover-animation {
@@ -72,6 +139,10 @@ export const PopoverMixin = superclass => class extends superclass {
72
139
  animation: var(--d2l-popover-animation-name, var(--d2l-popover-default-animation-name)) 300ms ease;
73
140
  }
74
141
  }
142
+
143
+ :host([_offscreen]) {
144
+ ${_offscreenStyleDeclarations}
145
+ }
75
146
  `;
76
147
  }
77
148
 
@@ -79,19 +150,26 @@ export const PopoverMixin = superclass => class extends superclass {
79
150
  super();
80
151
  this.configure();
81
152
  this._useNativePopover = isSupported ? 'manual' : undefined;
82
- this._handleAutoCloseClick = this._handleAutoCloseClick.bind(this);
83
- this._handleAutoCloseFocus = this._handleAutoCloseFocus.bind(this);
153
+ this.#handleAncestorMutationBound = this.#handleAncestorMutation.bind(this);
154
+ this.#handleAutoCloseClickBound = this.#handleAutoCloseClick.bind(this);
155
+ this.#handleAutoCloseFocusBound = this.#handleAutoCloseFocus.bind(this);
156
+ this.#handleResizeBound = this.#handleResize.bind(this);
157
+ this.#repositionBound = this.#reposition.bind(this);
84
158
  }
85
159
 
86
160
  connectedCallback() {
87
161
  super.connectedCallback();
88
- if (this._opened) this._addAutoCloseHandlers();
162
+ if (this._opened) {
163
+ this.#addAutoCloseHandlers();
164
+ this.#addRepositionHandlers();
165
+ }
89
166
  }
90
167
 
91
168
  disconnectedCallback() {
92
169
  super.disconnectedCallback();
93
- this._removeAutoCloseHandlers();
94
- this._clearDismissible();
170
+ this.#removeAutoCloseHandlers();
171
+ this.#removeRepositionHandlers();
172
+ this.#clearDismissible();
95
173
  }
96
174
 
97
175
  async close() {
@@ -102,22 +180,44 @@ export const PopoverMixin = superclass => class extends superclass {
102
180
  if (this._useNativePopover) this.hidePopover();
103
181
 
104
182
  this._previousFocusableAncestor = null;
105
- this._removeAutoCloseHandlers();
106
- this._clearDismissible();
183
+ this.#removeAutoCloseHandlers();
184
+ this.#removeRepositionHandlers();
185
+ this.#clearDismissible();
107
186
  await this.updateComplete; // wait before applying focus to opener
108
- this._focusOpener();
187
+ this.#focusOpener();
109
188
  this.dispatchEvent(new CustomEvent('d2l-popover-close', { bubbles: true, composed: true }));
189
+
110
190
  }
111
191
 
112
192
  configure(properties) {
193
+ this._margin = properties?.margin ?? 18;
194
+ this._maxHeight = properties?.maxHeight;
195
+ this._maxWidth = properties?.maxWidth;
196
+ this._minHeight = properties?.minHeight;
197
+ this._minWidth = properties?.minWidth;
113
198
  this._noAutoClose = properties?.noAutoClose ?? false;
199
+ this._noAutoFit = properties?.noAutoFit ?? false;
114
200
  this._noAutoFocus = properties?.noAutoFocus ?? false;
201
+ this._noPointer = properties?.noPointer ?? false;
202
+ this._offset = properties?.offset ?? 16;
203
+ if (!properties) {
204
+ this._preferredPosition = defaultPreferredPosition;
205
+ } else if (this._preferredPosition?.location !== properties.position?.location
206
+ || this._preferredPosition?.span !== properties.position?.span
207
+ || this._preferredPosition?.allowFlip !== properties.position?.allowFlip) {
208
+ this._preferredPosition = {
209
+ location: properties?.position?.location ?? 'block-end',
210
+ span: properties?.position?.span ?? 'all',
211
+ allowFlip: properties?.position?.allowFlip ?? true
212
+ };
213
+ }
115
214
  this._trapFocus = properties?.trapFocus ?? false;
116
215
  }
117
216
 
118
217
  async open(applyFocus = true) {
119
218
  if (this._opened) return;
120
219
 
220
+ this._rtl = document.documentElement.getAttribute('dir') === 'rtl';
121
221
  this._applyFocus = applyFocus !== undefined ? applyFocus : true;
122
222
  this._opened = true;
123
223
 
@@ -127,10 +227,73 @@ export const PopoverMixin = superclass => class extends superclass {
127
227
  this._previousFocusableAncestor = getPreviousFocusableAncestor(this, false, false);
128
228
 
129
229
  this._opener = getComposedActiveElement();
130
- this._addAutoCloseHandlers();
230
+ this.#addAutoCloseHandlers();
231
+
232
+ await this.#position();
233
+
131
234
  this._dismissibleId = setDismissible(() => this.close());
132
- this._focusContent(this);
235
+
236
+ this.#focusContent(this);
237
+
238
+ this.#addRepositionHandlers();
239
+
133
240
  this.dispatchEvent(new CustomEvent('d2l-popover-open', { bubbles: true, composed: true }));
241
+
242
+ }
243
+
244
+ renderPopover(content) {
245
+
246
+ const stylesMap = this.#getStyleMaps();
247
+ const widthStyle = stylesMap['width'];
248
+ const contentStyle = stylesMap['content'];
249
+
250
+ content = html`
251
+ <div class="content-width vdiff-target" style=${styleMap(widthStyle)}>
252
+ <div class="content-container" style=${styleMap(contentStyle)}>${content}</div>
253
+ </div>
254
+ `;
255
+
256
+ if (this._trapFocus) {
257
+ content = html`
258
+ <d2l-focus-trap @d2l-focus-trap-enter="${this.#handleFocusTrapEnter}" ?trap="${this._opened}">
259
+ ${content}
260
+ </d2l-focus-trap>
261
+ `;
262
+ }
263
+
264
+ const positionStyles = {};
265
+ if (this._position) {
266
+ for (const prop in this._position) {
267
+ positionStyles[prop] = `${this._position[prop]}px`;
268
+ }
269
+ }
270
+
271
+ content = html`
272
+ <div class="content-position" style=${styleMap(positionStyles)}>
273
+ ${content}
274
+ </div>
275
+ `;
276
+
277
+ const pointerPositionStyles = {};
278
+ if (this._pointerPosition) {
279
+ for (const prop in this._pointerPosition) {
280
+ pointerPositionStyles[prop] = `${this._pointerPosition[prop]}px`;
281
+ }
282
+ }
283
+
284
+ const pointer = !this._noPointer ? html`
285
+ <div class="pointer" style="${styleMap(pointerPositionStyles)}">
286
+ <div></div>
287
+ </div>
288
+ ` : nothing;
289
+
290
+ return html`${content}${pointer}`;
291
+
292
+ }
293
+
294
+ async resize() {
295
+ if (!this._opened) return;
296
+ await this.#position();
134
297
  }
135
298
 
136
299
  toggleOpen(applyFocus = true) {
@@ -138,49 +301,289 @@ export const PopoverMixin = superclass => class extends superclass {
138
301
  else return this.open(!this._noAutoFocus && applyFocus);
139
302
  }
140
303
 
141
- _addAutoCloseHandlers() {
142
- this.addEventListener('blur', this._handleAutoCloseFocus, { capture: true });
143
- document.body.addEventListener('focus', this._handleAutoCloseFocus, { capture: true });
144
- document.addEventListener('click', this._handleAutoCloseClick, { capture: true });
304
+ #handleAncestorMutationBound;
305
+ #handleAutoCloseClickBound;
306
+ #handleAutoCloseFocusBound;
307
+ #handleResizeBound;
308
+ #repositionBound;
309
+
310
+ #addAutoCloseHandlers() {
311
+ this.addEventListener('blur', this.#handleAutoCloseFocusBound, { capture: true });
312
+ document.body.addEventListener('focus', this.#handleAutoCloseFocusBound, { capture: true });
313
+ document.addEventListener('click', this.#handleAutoCloseClickBound, { capture: true });
314
+ }
315
+
316
+ #addRepositionHandlers() {
317
+
318
+ const isScrollable = (node, prop) => {
319
+ const value = window.getComputedStyle(node, null).getPropertyValue(prop);
320
+ return (value === 'scroll' || value === 'auto');
321
+ };
322
+
323
+ this.#removeRepositionHandlers();
324
+
325
+ window.addEventListener('resize', this.#handleResizeBound);
326
+
327
+ this._ancestorMutationObserver ??= new MutationObserver(this.#handleAncestorMutationBound);
328
+ const mutationConfig = { attributes: true, childList: true, subtree: true };
329
+
330
+ let node = this;
331
+ this._scrollablesObserved = [];
332
+ while (node) {
333
+
334
+ // observe scrollables
335
+ let observeScrollable = false;
336
+ if (node.nodeType === Node.ELEMENT_NODE) {
337
+ observeScrollable = isScrollable(node, 'overflow-y') || isScrollable(node, 'overflow-x');
338
+ } else if (node.nodeType === Node.DOCUMENT_NODE) {
339
+ observeScrollable = true;
340
+ }
341
+ if (observeScrollable) {
342
+ this._scrollablesObserved.push(node);
343
+ node.addEventListener('scroll', this.#repositionBound);
344
+ }
345
+
346
+ // observe mutations on each DOM scope (excludes sibling scopes... can only do so much)
347
+ if (node.nodeType === Node.DOCUMENT_NODE || (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE && node.host)) {
348
+ this._ancestorMutationObserver.observe(node, mutationConfig);
349
+ }
350
+
351
+ node = getComposedParent(node);
352
+ }
353
+
354
+ this._openerIntersectionObserver = new IntersectionObserver(entries => {
355
+ entries.forEach(entry => this._offscreen = !entry.isIntersecting);
356
+ }, { threshold: 0 }); // 0-1 (0 -> intersection requires any pixel visible, 1 -> intersection requires all pixels visible)
357
+ if (this._opener) {
358
+ this._openerIntersectionObserver.observe(this._opener);
359
+ }
360
+
145
361
  }
146
362
 
147
- _clearDismissible() {
363
+ #clearDismissible() {
148
364
  if (!this._dismissibleId) return;
149
365
  clearDismissible(this._dismissibleId);
150
366
  this._dismissibleId = null;
151
367
  }
152
368
 
153
- _focusContent(container) {
369
+ #constrainSpaceAround(spaceAround, spaceRequired, openerRect) {
370
+ const constrained = { ...spaceAround };
371
+
372
+ if ((this._preferredPosition.span === 'end' && !this._rtl) || (this._preferredPosition.span === 'start' && this._rtl)) {
373
+ constrained.left = Math.max(0, spaceRequired.width - (openerRect.width + spaceAround.right));
374
+ } else if ((this._preferredPosition.span === 'end' && this._rtl) || (this._preferredPosition.span === 'start' && !this._rtl)) {
375
+ constrained.right = Math.max(0, spaceRequired.width - (openerRect.width + spaceAround.left));
376
+ }
377
+
378
+ return constrained;
379
+ }
380
+
381
+ #focusContent(container) {
154
382
  if (this._noAutoFocus || this._applyFocus === false) return;
155
383
 
156
384
  const focusable = getFirstFocusableDescendant(container);
157
385
  if (focusable) {
158
- // Removing the rAF call can allow infinite focus looping to happen in content using a focus trap
386
+ // removing the rAF call can allow infinite focus looping to happen in content using a focus trap
159
387
  requestAnimationFrame(() => focusable.focus());
160
388
  } else {
161
- const content = this._getContentContainer();
389
+ const content = this.#getContentContainer();
162
390
  content.setAttribute('tabindex', '-1');
163
391
  content.focus();
164
392
  }
165
393
  }
166
394
 
167
- _focusOpener() {
395
+ #focusOpener() {
168
396
  if (!document.activeElement) return;
169
397
  if (!isComposedAncestor(this, getComposedActiveElement())) return;
170
398
 
171
399
  this?._opener.focus();
172
400
  }
173
401
 
174
- _getContentContainer() {
175
- return this.shadowRoot.querySelector('.content');
402
+ #getContentContainer() {
403
+ return this.shadowRoot.querySelector('.content-container');
404
+ }
405
+
406
+ #getLocation(spaceAround, spaceAroundScroll, spaceRequired) {
407
+
408
+ const preferred = this._preferredPosition;
409
+ if (!preferred.allowFlip) {
410
+ return preferred.location;
411
+ }
412
+
413
+ if (preferred.location === 'block-end') {
414
+ if (spaceAround.below >= spaceRequired.height) return 'block-end';
415
+ if (spaceAround.above >= spaceRequired.height) return 'block-start';
416
+ // if auto-fit is enabled, scroll will be enabled for the inner content so it will always fit in the available space so pick the largest space it can be displayed in
417
+ if (!this.noAutoFit) return spaceAround.above > spaceAround.below ? 'block-start' : 'block-end';
418
+ if (spaceAroundScroll.below >= spaceRequired.height) return 'block-end';
419
+ if (spaceAroundScroll.above >= spaceRequired.height) return 'block-start';
420
+ }
421
+
422
+ if (preferred.location === 'block-start') {
423
+ if (spaceAround.above >= spaceRequired.height) return 'block-start';
424
+ if (spaceAround.below >= spaceRequired.height) return 'block-end';
425
+ // if auto-fit is enabled, scroll will be enabled for the inner content so it will always fit in the available space so pick the largest space it can be displayed in
426
+ if (!this.noAutoFit) return spaceAround.above > spaceAround.below ? 'block-start' : 'block-end';
427
+ if (spaceAroundScroll.above >= spaceRequired.height) return 'block-start';
428
+ if (spaceAroundScroll.below >= spaceRequired.height) return 'block-end';
429
+ }
430
+
431
+ // todo: add location order for inline-start and inline-end
432
+
433
+ // if auto-fit is disabled and it doesn't fit in the scrollable space above or below, always open down because it can add scrollable space
434
+ return 'block-end';
435
+ }
436
+
437
+ #getPointer() {
438
+ return this.shadowRoot.querySelector('.pointer');
439
+ }
440
+
441
+ #getPointerPosition(openerRect) {
442
+ const position = {};
443
+ const pointer = this.#getPointer();
444
+ if (!pointer) return position;
445
+
446
+ const pointerRect = pointer.getBoundingClientRect();
447
+
448
+ if (this._preferredPosition.span !== 'all') {
449
+ const xAdjustment = Math.min(20 + ((pointerRotatedLength - pointerLength) / 2), (openerRect.width - pointerLength) / 2);
450
+ if (!this._rtl) {
451
+ if (this._preferredPosition.span === 'end') {
452
+ position.left = openerRect.left + xAdjustment;
453
+ } else {
454
+ position.right = (openerRect.right * -1) + xAdjustment;
455
+ }
456
+ } else {
457
+ if (this._preferredPosition.span === 'end') {
458
+ position.right = window.innerWidth - openerRect.right + xAdjustment;
459
+ } else {
460
+ position.left = (window.innerWidth - openerRect.left - xAdjustment) * -1;
461
+ }
462
+ }
463
+ } else {
464
+ if (!this._rtl) {
465
+ position.left = openerRect.left + ((openerRect.width - pointerRect.width) / 2);
466
+ } else {
467
+ position.right = window.innerWidth - openerRect.left - ((openerRect.width + pointerRect.width) / 2);
468
+ }
469
+ }
470
+
471
+ if (this._location === 'block-start') {
472
+ position.bottom = window.innerHeight - openerRect.top + 8;
473
+ } else {
474
+ position.top = openerRect.top + openerRect.height + this._offset - 7;
475
+ }
476
+
477
+ return position;
478
+ }
479
+
480
+ #getPosition(spaceAround, openerRect, contentRect) {
481
+ const position = {};
482
+
483
+ if (this._location === 'block-end' || this._location === 'block-start') {
484
+
485
+ const xAdjustment = this.#getPositionXAdjustment(spaceAround, openerRect, contentRect);
486
+ if (xAdjustment !== null) {
487
+ if (!this._rtl) {
488
+ position.left = openerRect.left + xAdjustment;
489
+ } else {
490
+ position.right = window.innerWidth - openerRect.left - openerRect.width + xAdjustment;
491
+ }
492
+ }
493
+
494
+ if (this._location === 'block-start') {
495
+ position.bottom = window.innerHeight - openerRect.top + this._offset;
496
+ } else {
497
+ position.top = openerRect.top + openerRect.height + this._offset;
498
+ }
499
+
500
+ }
501
+
502
+ // todo: add position styles for inline-start and inline-end
503
+
504
+ return position;
505
+ }
506
+
507
+ #getPositionXAdjustment(spaceAround, openerRect, contentRect) {
508
+
509
+ if (this._location === 'block-end' || this._location === 'block-start') {
510
+
511
+ const centerDelta = contentRect.width - openerRect.width;
512
+ const contentXAdjustment = centerDelta / 2;
513
+
514
+ if (this._preferredPosition.span === 'all' && centerDelta <= 0) {
515
+ // center with target (opener wider than content)
516
+ return contentXAdjustment * -1;
517
+ }
518
+ if (this._preferredPosition.span === 'all' && spaceAround.left > contentXAdjustment && spaceAround.right > contentXAdjustment) {
519
+ // center with target (content wider than opener and enough space around)
520
+ return contentXAdjustment * -1;
521
+ }
522
+
523
+ if (!this._rtl) {
524
+ if (spaceAround.left < contentXAdjustment) {
525
+ // slide content right (not enough space to center)
526
+ return spaceAround.left * -1;
527
+ } else if (spaceAround.right < contentXAdjustment) {
528
+ // slide content left (not enough space to center)
529
+ return (centerDelta * -1) + spaceAround.right;
530
+ }
531
+ } else {
532
+ if (spaceAround.left < contentXAdjustment) {
533
+ // slide content right (not enough space to center)
534
+ return (centerDelta * -1) + spaceAround.left;
535
+ } else if (spaceAround.right < contentXAdjustment) {
536
+ // slide content left (not enough space to center)
537
+ return spaceAround.right * -1;
538
+ }
539
+ }
540
+
541
+ if (this._preferredPosition.span !== 'all') {
542
+ // shift it (not enough space to align as requested)
543
+ const shift = Math.min((openerRect.width / 2) - (20 + pointerLength / 2), 0); // 20 ~= 1rem
544
+ if (this._preferredPosition.span === 'end') {
545
+ return shift;
546
+ } else {
547
+ return openerRect.width - contentRect.width - shift;
548
+ }
549
+ }
550
+
551
+ }
552
+
553
+ // todo: add position styles for inline-start and inline-end
554
+
555
+ return null;
556
+ }
557
+
558
+ #getStyleMaps() {
559
+ const widthStyle = {
560
+ maxWidth: this._maxWidth ? `${this._maxWidth}px` : undefined,
561
+ minWidth: this._minWidth ? `${this._minWidth}px` : undefined,
562
+ width: this._width ? `${this._width + 3}px` : undefined // add 3 to content to account for possible rounding and also scrollWidth does not include border
563
+ };
564
+
565
+ const contentStyle = {
566
+ maxHeight: this._contentHeight ? `${this._contentHeight}px` : undefined,
567
+ };
568
+
569
+ return {
570
+ 'width' : widthStyle,
571
+ 'content' : contentStyle
572
+ };
176
573
  }
177
574
 
178
- _handleAutoCloseClick(e) {
575
+ #handleAncestorMutation(mutations) {
576
+ if (!this._opener) return;
577
+ // ignore mutations that are within this popover
578
+ const reposition = !!mutations.find(mutation => !isComposedAncestor(this._opener, mutation.target));
579
+ if (reposition) this.#reposition();
580
+ }
179
581
 
582
+ #handleAutoCloseClick(e) {
180
583
  if (!this._opened || this._noAutoClose) return;
181
584
 
182
585
  const rootTarget = e.composedPath()[0];
183
- if (isComposedAncestor(this._getContentContainer(), rootTarget)
586
+ if (isComposedAncestor(this.#getContentContainer(), rootTarget)
184
587
  || (this._opener !== document.body && isComposedAncestor(this._opener, rootTarget))) {
185
588
  return;
186
589
  }
@@ -188,7 +591,7 @@ export const PopoverMixin = superclass => class extends superclass {
188
591
  this.close();
189
592
  }
190
593
 
191
- _handleAutoCloseFocus() {
594
+ #handleAutoCloseFocus() {
192
595
 
193
596
  // todo: try to use relatedTarget instead - this logic is largely copied as-is from dropdown simply to mitigate risk of this fragile code
194
597
  setTimeout(() => {
@@ -212,27 +615,130 @@ export const PopoverMixin = superclass => class extends superclass {
212
615
 
213
616
  }
214
617
 
215
- _handleFocusTrapEnter() {
216
- this._focusContent(this._getContentContainer());
618
+ #handleFocusTrapEnter() {
619
+ this.#focusContent(this.#getContentContainer());
217
620
 
218
621
  /** Dispatched when user focus enters the popover (trap-focus option only) */
219
622
  this.dispatchEvent(new CustomEvent('d2l-popover-focus-enter', { detail: { applyFocus: this._applyFocus } }));
220
623
  }
221
624
 
222
- _removeAutoCloseHandlers() {
223
- this.removeEventListener('blur', this._handleAutoCloseFocus, { capture: true });
224
- document.body?.removeEventListener('focus', this._handleAutoCloseFocus, { capture: true }); // DE41322: document.body can be null in some scenarios
225
- document.removeEventListener('click', this._handleAutoCloseClick, { capture: true });
625
+ #handleResize() {
626
+ this.resize();
627
+ }
628
+
629
+ async #position(contentRect, options) {
630
+ if (!this._opener) return;
631
+
632
+ options = Object.assign({ updateLocation: true, updateHeight: true }, options);
633
+
634
+ const content = this.#getContentContainer();
635
+
636
+ if (!this._noAutoFit && options.updateHeight) {
637
+ this._contentHeight = null;
638
+ }
639
+
640
+ // don't let popover content horizontally overflow viewport
641
+ this._width = null;
642
+
643
+ await this.updateComplete;
644
+
645
+ const adjustPosition = async() => {
646
+
647
+ const scrollHeight = document.documentElement.scrollHeight;
648
+ const openerRect = this._opener.getBoundingClientRect();
649
+ contentRect = contentRect ?? content.getBoundingClientRect();
650
+
651
+ const height = this._minHeight ?? Math.min(this._maxHeight ?? Number.MAX_VALUE, contentRect.height);
652
+
653
+ const spaceRequired = {
654
+ height: height + 10,
655
+ width: contentRect.width
656
+ };
657
+
658
+ // space in viewport
659
+ const spaceAround = this.#constrainSpaceAround({
660
+ // allow for opener offset + outer margin
661
+ above: openerRect.top - this._offset - this._margin,
662
+ // allow for opener offset + outer margin
663
+ below: window.innerHeight - openerRect.bottom - this._offset - this._margin,
664
+ // allow for outer margin
665
+ left: openerRect.left - 20,
666
+ // allow for outer margin
667
+ right: document.documentElement.clientWidth - openerRect.right - 15
668
+ }, spaceRequired, openerRect);
669
+
670
+ // space in document
671
+ const spaceAroundScroll = this.#constrainSpaceAround({
672
+ above: openerRect.top + document.documentElement.scrollTop,
673
+ below: scrollHeight - openerRect.bottom - document.documentElement.scrollTop
674
+ }, spaceRequired, openerRect);
675
+
676
+ if (options.updateLocation) {
677
+ this._location = this.#getLocation(spaceAround, spaceAroundScroll, spaceRequired);
678
+ }
679
+
680
+ this._position = this.#getPosition(spaceAround, openerRect, contentRect);
681
+ if (!this._noPointer) this._pointerPosition = this.#getPointerPosition(openerRect);
682
+
683
+ if (options.updateHeight) {
684
+
685
+ // calculate height available to the popover contents for overflow because that is the only area capable of scrolling
686
+ const availableHeight = (this._location === 'block-start') ? spaceAround.above : spaceAround.below;
687
+
688
+ if (!this._noAutoFit && availableHeight && availableHeight > 0) {
689
+ // only apply maximum if it's less than space available and the header/footer alone won't exceed it (content must be visible)
690
+ this._contentHeight = this._maxHeight !== null && availableHeight > this._maxHeight
691
+ ? this._maxHeight - 2 : availableHeight;
692
+
693
+ // ensure the content height has updated when the __toggleScrollStyles event handler runs
694
+ await this.updateComplete;
695
+ }
696
+
697
+ // todo: handle inline-start and inline-end locations
698
+
699
+ }
700
+
701
+ /** Dispatched when the popover position finishes adjusting */
702
+ this.dispatchEvent(new CustomEvent('d2l-popover-position', { bubbles: true, composed: true }));
703
+
704
+ };
705
+
706
+ const scrollWidth = content.scrollWidth;
707
+ const availableWidth = window.innerWidth - 40;
708
+
709
+ this._width = (availableWidth > scrollWidth ? scrollWidth : availableWidth);
710
+
711
+ await this.updateComplete;
712
+
713
+ await adjustPosition();
714
+
226
715
  }
227
716
 
228
- _renderPopover() {
229
- const content = html`<div class="content"><slot></slot></div>`;
717
+ #removeAutoCloseHandlers() {
718
+ this.removeEventListener('blur', this.#handleAutoCloseFocusBound, { capture: true });
719
+ document.body?.removeEventListener('focus', this.#handleAutoCloseFocusBound, { capture: true }); // DE41322: document.body can be null in some scenarios
720
+ document.removeEventListener('click', this.#handleAutoCloseClickBound, { capture: true });
721
+ }
230
722
 
231
- if (this._trapFocus) return html`<d2l-focus-trap @d2l-focus-trap-enter="${this._handleFocusTrapEnter}" ?trap="${this._opened}">
232
- ${content}
233
- </d2l-focus-trap>`;
723
+ #removeRepositionHandlers() {
724
+ this._openerIntersectionObserver?.unobserve(this._opener);
725
+ this._scrollablesObserved?.forEach(node => {
726
+ node.removeEventListener('scroll', this.#repositionBound);
727
+ });
728
+ this._scrollablesObserved = null;
729
+ this._ancestorMutationObserver?.disconnect();
730
+ window.removeEventListener('resize', this.#handleResizeBound);
731
+ }
234
732
 
235
- return content;
733
+ #reposition() {
734
+ // throttle repositioning (https://developer.mozilla.org/en-US/docs/Web/API/Document/scroll_event#scroll_event_throttling)
735
+ if (!this._repositioning) {
736
+ requestAnimationFrame(() => {
737
+ this.#position(undefined, { updateLocation: false, updateHeight: false });
738
+ this._repositioning = false;
739
+ });
740
+ }
741
+ this._repositioning = true;
236
742
  }
237
743
 
238
744
  };