diff --git a/components/popover/demo/popover.html b/components/popover/demo/popover.html index e58c0e12709..c262e23501d 100644 --- a/components/popover/demo/popover.html +++ b/components/popover/demo/popover.html @@ -6,10 +6,20 @@ @@ -20,41 +30,256 @@

Popover

+ + +

Popover (content width less than min-width)

+ + + + +

Popover (content width greater than min-width, less than max-width)

+ + + + +

Popover (content width greater than max-width)

+ + + + +

Popover (no pointer)

+ + + + +

Popover (custom max-width, single-line)

+ + + + +

Popover (custom max-width, multi-line)

+ + + + +

Popover (custom min-width)

+ + + + +

Popover (position location)

+ + + + +

Popover (position span)

+ + + + +

Popover (in a scrollable container)

+ + +

Popover (with DOM mutation)

+ + + + +

Popover (in a dialog)

+ + + + +

Popover (in another popover)

+ + + + +

Popover (trap-focus)

+ - + + + diff --git a/components/popover/popover-mixin.js b/components/popover/popover-mixin.js index 4aa5a42a743..89f85ae84ae 100644 --- a/components/popover/popover-mixin.js +++ b/components/popover/popover-mixin.js @@ -1,24 +1,44 @@ import '../colors/colors.js'; import '../focus-trap/focus-trap.js'; import { clearDismissible, setDismissible } from '../../helpers/dismissible.js'; -import { css, html } from 'lit'; +import { css, html, nothing } from 'lit'; import { getComposedActiveElement, getFirstFocusableDescendant, getPreviousFocusableAncestor } from '../../helpers/focus.js'; -import { isComposedAncestor } from '../../helpers/dom.js'; +import { getComposedParent, isComposedAncestor } from '../../helpers/dom.js'; +import { _offscreenStyleDeclarations } from '../offscreen/offscreen.js'; +import { RtlMixin } from '../../mixins/rtl/rtl-mixin.js'; +import { styleMap } from 'lit/directives/style-map.js'; +const pointerLength = 16; +const pointerRotatedLength = Math.SQRT2 * parseFloat(pointerLength); const isSupported = ('popover' in HTMLElement.prototype); // eslint-disable-next-line no-console console.log('Popover', isSupported); -export const PopoverMixin = superclass => class extends superclass { +export const PopoverMixin = superclass => class extends RtlMixin(superclass) { static get properties() { return { + _contentHeight: { state: true }, + _location: { type: String, reflect: true, attribute: '_location' }, + _margin: { state: true }, + _maxHeight: { state: true }, + _maxWidth: { state: true }, + _minHeight: { state: true }, + _minWidth: { state: true }, _noAutoClose: { state: true }, + _noAutoFit: { state: true }, _noAutoFocus: { state: true }, + _noPointer: { state: true }, + _offscreen: { type: Boolean, reflect: true, attribute: '_offscreen' }, + _offset: { state: true }, _opened: { type: Boolean, reflect: true, attribute: '_opened' }, + _pointerPosition: { state: true }, + _position: { state: true }, + _preferredPosition: { state: true }, _trapFocus: { state: true }, - _useNativePopover: { type: String, reflect: true, attribute: 'popover' } + _useNativePopover: { type: String, reflect: true, attribute: 'popover' }, + _width: { state: true } }; } @@ -31,18 +51,18 @@ export const PopoverMixin = superclass => class extends superclass { --d2l-popover-default-border-radius: 0.3rem; --d2l-popover-default-foreground-color: var(--d2l-color-ferrite); --d2l-popover-default-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.15); - background-color: transparent; - border: none; + background-color: transparent; /* override popover default */ + border: none; /* override popover */ box-sizing: border-box; color: var(--d2l-popover-foreground-color, var(--d2l-popover-default-foreground-color)); display: none; - height: fit-content; - inset: 0; - margin: auto; - overflow: visible; - padding: 0; - position: fixed; - width: fit-content; + height: fit-content; /* normalize popover */ + inset: 0; /* normalize popover */ + margin: 0; /* override popover */ + overflow: visible; /* override popover */ + padding: 0; /* override popover */ + position: fixed; /* normalize popover */ + width: fit-content; /* normalize popover */ } :host([hidden]) { display: none; @@ -53,14 +73,56 @@ export const PopoverMixin = superclass => class extends superclass { :host([_opened]) { display: inline-block; } + :host([_location="block-start"]) { + bottom: 0; + top: auto; + } - .content { + .content-position { + display: inline-block; + position: absolute; + } + .content-width { background-color: var(--d2l-popover-background-color, var(--d2l-popover-default-background-color)); border: 1px solid var(--d2l-popover-border-color, var(--d2l-popover-default-border-color)); border-radius: var(--d2l-popover-border-radius, var(--d2l-popover-default-border-radius)); box-shadow: var(--d2l-popover-shadow, var(--d2l-popover-default-shadow)); box-sizing: border-box; + max-width: 370px; + min-width: 70px; + width: 100vw; + } + .content-container { + box-sizing: border-box; + display: inline-block; + max-width: 100%; outline: none; + overflow-y: auto; + } + + .pointer { + clip: rect(-5px, 21px, 8px, -7px); + display: inline-block; + position: absolute; + z-index: 1; + } + + .pointer > div { + background-color: var(--d2l-popover-background-color, var(--d2l-popover-default-background-color)); + border: 1px solid var(--d2l-popover-border-color, var(--d2l-popover-default-border-color)); + border-radius: 0.1rem; + box-shadow: -4px -4px 12px -5px rgba(32, 33, 34, 0.2); /* ferrite */ + height: ${pointerLength}px; + transform: rotate(45deg); + width: ${pointerLength}px; + } + + :host([_location="block-start"]) .pointer { + clip: rect(9px, 21px, 22px, -3px); + } + + :host([_location="block-start"]) .pointer > div { + box-shadow: 4px 4px 12px -5px rgba(32, 33, 34, 0.2); /* ferrite */ } @keyframes d2l-popover-animation { @@ -72,6 +134,10 @@ export const PopoverMixin = superclass => class extends superclass { animation: var(--d2l-popover-animation-name, var(--d2l-popover-default-animation-name)) 300ms ease; } } + + :host([_offscreen]) { + ${_offscreenStyleDeclarations} + } `; } @@ -79,19 +145,26 @@ export const PopoverMixin = superclass => class extends superclass { super(); this.configure(); this._useNativePopover = isSupported ? 'manual' : undefined; - this._handleAutoCloseClick = this._handleAutoCloseClick.bind(this); - this._handleAutoCloseFocus = this._handleAutoCloseFocus.bind(this); + this.#handleAncestorMutationBound = this.#handleAncestorMutation.bind(this); + this.#handleAutoCloseClickBound = this.#handleAutoCloseClick.bind(this); + this.#handleAutoCloseFocusBound = this.#handleAutoCloseFocus.bind(this); + this.#handleResizeBound = this.#handleResize.bind(this); + this.#repositionBound = this.#reposition.bind(this); } connectedCallback() { super.connectedCallback(); - if (this._opened) this._addAutoCloseHandlers(); + if (this._opened) { + this.#addAutoCloseHandlers(); + this.#addRepositionHandlers(); + } } disconnectedCallback() { super.disconnectedCallback(); - this._removeAutoCloseHandlers(); - this._clearDismissible(); + this.#removeAutoCloseHandlers(); + this.#removeRepositionHandlers(); + this.#clearDismissible(); } async close() { @@ -102,16 +175,31 @@ export const PopoverMixin = superclass => class extends superclass { if (this._useNativePopover) this.hidePopover(); this._previousFocusableAncestor = null; - this._removeAutoCloseHandlers(); - this._clearDismissible(); + this.#removeAutoCloseHandlers(); + this.#removeRepositionHandlers(); + this.#clearDismissible(); await this.updateComplete; // wait before applying focus to opener - this._focusOpener(); + this.#focusOpener(); this.dispatchEvent(new CustomEvent('d2l-popover-close', { bubbles: true, composed: true })); + } configure(properties) { + this._margin = properties?.margin ?? 18; + this._maxHeight = properties?.maxHeight; + this._maxWidth = properties?.maxWidth; + this._minHeight = properties?.minHeight; + this._minWidth = properties?.minWidth; this._noAutoClose = properties?.noAutoClose ?? false; + this._noAutoFit = properties?.noAutoFit ?? false; this._noAutoFocus = properties?.noAutoFocus ?? false; + this._noPointer = properties?.noPointer ?? false; + this._offset = properties?.offset ?? 16; + this._preferredPosition = { + location: properties?.position?.location ?? 'block-end', // block-start, block-end + span: properties?.position?.span ?? 'all', // start, end, all + allowFlip: properties?.position?.allowFlip ?? true + }; this._trapFocus = properties?.trapFocus ?? false; } @@ -127,10 +215,73 @@ export const PopoverMixin = superclass => class extends superclass { this._previousFocusableAncestor = getPreviousFocusableAncestor(this, false, false); this._opener = getComposedActiveElement(); - this._addAutoCloseHandlers(); + this.#addAutoCloseHandlers(); + + await this.#position(); + this._dismissibleId = setDismissible(() => this.close()); - this._focusContent(this); + + this.#focusContent(this); + + this.#addRepositionHandlers(); + this.dispatchEvent(new CustomEvent('d2l-popover-open', { bubbles: true, composed: true })); + + } + + renderPopover(content) { + + const stylesMap = this.#getStyleMaps(); + const widthStyle = stylesMap['width']; + const contentStyle = stylesMap['content']; + + content = html` +
+
${content}
+
+ `; + + if (this._trapFocus) { + content = html` + + ${content} + + `; + } + + const positionStyles = {}; + if (this._position) { + for (const prop in this._position) { + positionStyles[prop] = `${this._position[prop]}px`; + } + } + + content = html` +
+ ${content} +
+ `; + + const pointerPositionStyles = {}; + if (this._pointerPosition) { + for (const prop in this._pointerPosition) { + pointerPositionStyles[prop] = `${this._pointerPosition[prop]}px`; + } + } + + const pointer = !this._noPointer ? html` +
+
+
+ ` : nothing; + + return html`${content}${pointer}`; + + } + + async resize() { + if (!this._opened) return; + await this.#position(); } toggleOpen(applyFocus = true) { @@ -138,49 +289,293 @@ export const PopoverMixin = superclass => class extends superclass { else return this.open(!this._noAutoFocus && applyFocus); } - _addAutoCloseHandlers() { - this.addEventListener('blur', this._handleAutoCloseFocus, { capture: true }); - document.body.addEventListener('focus', this._handleAutoCloseFocus, { capture: true }); - document.addEventListener('click', this._handleAutoCloseClick, { capture: true }); + #handleAncestorMutationBound; + #handleAutoCloseClickBound; + #handleAutoCloseFocusBound; + #handleResizeBound; + #repositionBound; + + #addAutoCloseHandlers() { + this.addEventListener('blur', this.#handleAutoCloseFocusBound, { capture: true }); + document.body.addEventListener('focus', this.#handleAutoCloseFocusBound, { capture: true }); + document.addEventListener('click', this.#handleAutoCloseClickBound, { capture: true }); } - _clearDismissible() { + #addRepositionHandlers() { + + const isScrollable = (node, prop) => { + const value = window.getComputedStyle(node, null).getPropertyValue(prop); + return (value === 'scroll' || value === 'auto'); + }; + + this.#removeRepositionHandlers(); + + window.addEventListener('resize', this.#handleResizeBound); + + this._ancestorMutationObserver ??= new MutationObserver(this.#handleAncestorMutationBound); + const mutationConfig = { attributes: true, childList: true, subtree: true }; + + let node = this; + this._scrollablesObserved = []; + while (node) { + + // observe scrollables + let observeScrollable = false; + if (node.nodeType === Node.ELEMENT_NODE) { + observeScrollable = isScrollable(node, 'overflow-y') || isScrollable(node, 'overflow-x'); + } else if (node.nodeType === Node.DOCUMENT_NODE) { + observeScrollable = true; + } + if (observeScrollable) { + this._scrollablesObserved.push(node); + node.addEventListener('scroll', this.#repositionBound); + } + + // observe mutations on each DOM scope (excludes sibling scopes... can only do so much) + if (node.nodeType === Node.DOCUMENT_NODE || (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE && node.host)) { + this._ancestorMutationObserver.observe(node, mutationConfig); + } + + node = getComposedParent(node); + } + + this._openerIntersectionObserver = new IntersectionObserver(entries => { + entries.forEach(entry => this._offscreen = !entry.isIntersecting); + }, { threshold: 0 }); // 0-1 (0 -> intersection requires any pixel visible, 1 -> intersection requires all pixels visible) + if (this._opener) { + this._openerIntersectionObserver.observe(this._opener); + } + + } + + #clearDismissible() { if (!this._dismissibleId) return; clearDismissible(this._dismissibleId); this._dismissibleId = null; } - _focusContent(container) { + #constrainSpaceAround(spaceAround, spaceRequired, openerRect) { + const constrained = { ...spaceAround }; + + const isRTL = this.getAttribute('dir') === 'rtl'; + if ((this._preferredPosition.span === 'end' && !isRTL) || (this._preferredPosition.span === 'start' && isRTL)) { + constrained.left = Math.max(0, spaceRequired.width - (openerRect.width + spaceAround.right)); + } else if ((this._preferredPosition.span === 'end' && isRTL) || (this._preferredPosition.span === 'start' && !isRTL)) { + constrained.right = Math.max(0, spaceRequired.width - (openerRect.width + spaceAround.left)); + } + + return constrained; + } + + #focusContent(container) { if (this._noAutoFocus || this._applyFocus === false) return; const focusable = getFirstFocusableDescendant(container); if (focusable) { - // Removing the rAF call can allow infinite focus looping to happen in content using a focus trap + // removing the rAF call can allow infinite focus looping to happen in content using a focus trap requestAnimationFrame(() => focusable.focus()); } else { - const content = this._getContentContainer(); + const content = this.#getContentContainer(); content.setAttribute('tabindex', '-1'); content.focus(); } } - _focusOpener() { + #focusOpener() { if (!document.activeElement) return; if (!isComposedAncestor(this, getComposedActiveElement())) return; this?._opener.focus(); } - _getContentContainer() { - return this.shadowRoot.querySelector('.content'); + #getContentContainer() { + return this.shadowRoot.querySelector('.content-container'); + } + + #getLocation(spaceAround, spaceAroundScroll, spaceRequired) { + + const preferred = this._preferredPosition; + if (!preferred.allowFlip) { + return preferred.location; + } + + if (preferred.location === 'block-end') { + if (spaceAround.below >= spaceRequired.height) return 'block-end'; + if (spaceAround.above >= spaceRequired.height) return 'block-start'; + // 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 + if (!this.noAutoFit) return spaceAround.above > spaceAround.below ? 'block-start' : 'block-end'; + if (spaceAroundScroll.below >= spaceRequired.height) return 'block-end'; + if (spaceAroundScroll.above >= spaceRequired.height) return 'block-start'; + } + + if (preferred.location === 'block-start') { + if (spaceAround.above >= spaceRequired.height) return 'block-start'; + if (spaceAround.below >= spaceRequired.height) return 'block-end'; + // 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 + if (!this.noAutoFit) return spaceAround.above > spaceAround.below ? 'block-start' : 'block-end'; + if (spaceAroundScroll.above >= spaceRequired.height) return 'block-start'; + if (spaceAroundScroll.below >= spaceRequired.height) return 'block-end'; + } + + // todo: add location order for inline-start and inline-end + + // 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 + return 'block-end'; + } + + #getPointer() { + return this.shadowRoot.querySelector('.pointer'); + } + + #getPointerPosition(openerRect) { + const position = {}; + const pointer = this.#getPointer(); + if (!pointer) return position; + + const pointerRect = pointer.getBoundingClientRect(); + const isRTL = this.getAttribute('dir') === 'rtl'; + + if (this._preferredPosition.span !== 'all') { + const xAdjustment = Math.min(20 + ((pointerRotatedLength - pointerLength) / 2), (openerRect.width - pointerLength) / 2); + if (!isRTL) { + if (this._preferredPosition.span === 'end') { + position.left = openerRect.left + xAdjustment; + } else { + position.right = (openerRect.right * -1) + xAdjustment; + } + } else { + if (this._preferredPosition.span === 'end') { + position.right = window.innerWidth - openerRect.right + xAdjustment; + } else { + position.left = (window.innerWidth - openerRect.left - xAdjustment) * -1; + } + } + } else { + if (!isRTL) { + position.left = openerRect.left + ((openerRect.width - pointerRect.width) / 2); + } else { + position.right = window.innerWidth - openerRect.left - ((openerRect.width + pointerRect.width) / 2); + } + } + + if (this._location === 'block-start') { + position.bottom = window.innerHeight - openerRect.top + 8; + } else { + position.top = openerRect.top + openerRect.height + this._offset - 7; + } + + return position; + } + + #getPosition(spaceAround, openerRect, contentRect) { + const position = {}; + const isRTL = this.getAttribute('dir') === 'rtl'; + + if (this._location === 'block-end' || this._location === 'block-start') { + + const xAdjustment = this.#getPositionXAdjustment(spaceAround, openerRect, contentRect); + if (xAdjustment !== null) { + if (!isRTL) { + position.left = openerRect.left + xAdjustment; + } else { + position.right = window.innerWidth - openerRect.left - openerRect.width + xAdjustment; + } + } + + if (this._location === 'block-start') { + position.bottom = window.innerHeight - openerRect.top + this._offset; + } else { + position.top = openerRect.top + openerRect.height + this._offset; + } + + } + + // todo: add position styles for inline-start and inline-end + + return position; + } + + #getPositionXAdjustment(spaceAround, openerRect, contentRect) { + + if (this._location === 'block-end' || this._location === 'block-start') { + + const centerDelta = contentRect.width - openerRect.width; + const contentXAdjustment = centerDelta / 2; + + if (this._preferredPosition.span === 'all' && centerDelta <= 0) { + // center with target (opener wider than content) + return contentXAdjustment * -1; + } + if (this._preferredPosition.span === 'all' && spaceAround.left > contentXAdjustment && spaceAround.right > contentXAdjustment) { + // center with target (content wider than opener and enough space around) + return contentXAdjustment * -1; + } + + const isRTL = this.getAttribute('dir') === 'rtl'; + if (!isRTL) { + if (spaceAround.left < contentXAdjustment) { + // slide content right (not enough space to center) + return spaceAround.left * -1; + } else if (spaceAround.right < contentXAdjustment) { + // slide content left (not enough space to center) + return (centerDelta * -1) + spaceAround.right; + } + } else { + if (spaceAround.left < contentXAdjustment) { + // slide content right (not enough space to center) + return (centerDelta * -1) + spaceAround.left; + } else if (spaceAround.right < contentXAdjustment) { + // slide content left (not enough space to center) + return spaceAround.right * -1; + } + } + + if (this._preferredPosition.span !== 'all') { + // shift it (not enough space to align as requested) + const shift = Math.min((openerRect.width / 2) - (20 + pointerLength / 2), 0); // 20 ~= 1rem + if (this._preferredPosition.span === 'end') { + return shift; + } else { + return openerRect.width - contentRect.width - shift; + } + } + + } + + // todo: add position styles for inline-start and inline-end + + return null; + } + + #getStyleMaps() { + const widthStyle = { + maxWidth: this._maxWidth ? `${this._maxWidth}px` : undefined, + minWidth: this._minWidth ? `${this._minWidth}px` : undefined, + width: this._width ? `${this._width + 3}px` : undefined // add 3 to content to account for possible rounding and also scrollWidth does not include border + }; + + const contentStyle = { + maxHeight: this._contentHeight ? `${this._contentHeight}px` : undefined, + }; + + return { + 'width' : widthStyle, + 'content' : contentStyle + }; } - _handleAutoCloseClick(e) { + #handleAncestorMutation(mutations) { + if (!this._opener) return; + // ignore mutations that are within this popover + const reposition = !!mutations.find(mutation => !isComposedAncestor(this._opener, mutation.target)); + if (reposition) this.#reposition(); + } + #handleAutoCloseClick(e) { if (!this._opened || this._noAutoClose) return; const rootTarget = e.composedPath()[0]; - if (isComposedAncestor(this._getContentContainer(), rootTarget) + if (isComposedAncestor(this.#getContentContainer(), rootTarget) || (this._opener !== document.body && isComposedAncestor(this._opener, rootTarget))) { return; } @@ -188,7 +583,7 @@ export const PopoverMixin = superclass => class extends superclass { this.close(); } - _handleAutoCloseFocus() { + #handleAutoCloseFocus() { // todo: try to use relatedTarget instead - this logic is largely copied as-is from dropdown simply to mitigate risk of this fragile code setTimeout(() => { @@ -212,27 +607,130 @@ export const PopoverMixin = superclass => class extends superclass { } - _handleFocusTrapEnter() { - this._focusContent(this._getContentContainer()); + #handleFocusTrapEnter() { + this.#focusContent(this.#getContentContainer()); /** Dispatched when user focus enters the popover (trap-focus option only) */ this.dispatchEvent(new CustomEvent('d2l-popover-focus-enter', { detail: { applyFocus: this._applyFocus } })); } - _removeAutoCloseHandlers() { - this.removeEventListener('blur', this._handleAutoCloseFocus, { capture: true }); - document.body?.removeEventListener('focus', this._handleAutoCloseFocus, { capture: true }); // DE41322: document.body can be null in some scenarios - document.removeEventListener('click', this._handleAutoCloseClick, { capture: true }); + #handleResize() { + this.resize(); } - _renderPopover() { - const content = html`
`; + async #position(contentRect, options) { + if (!this._opener) return; + + options = Object.assign({ updateLocation: true, updateHeight: true }, options); + + const content = this.#getContentContainer(); + + if (!this._noAutoFit && options.updateHeight) { + this._contentHeight = null; + } + + // don't let popover content horizontally overflow viewport + this._width = null; + + await this.updateComplete; + + const adjustPosition = async() => { + + const scrollHeight = document.documentElement.scrollHeight; + const openerRect = this._opener.getBoundingClientRect(); + contentRect = contentRect ?? content.getBoundingClientRect(); + + const height = this._minHeight ?? Math.min(this._maxHeight ?? Number.MAX_VALUE, contentRect.height); + + const spaceRequired = { + height: height + 10, + width: contentRect.width + }; + + // space in viewport + const spaceAround = this.#constrainSpaceAround({ + // allow for opener offset + outer margin + above: openerRect.top - this._offset - this._margin, + // allow for opener offset + outer margin + below: window.innerHeight - openerRect.bottom - this._offset - this._margin, + // allow for outer margin + left: openerRect.left - 20, + // allow for outer margin + right: document.documentElement.clientWidth - openerRect.right - 15 + }, spaceRequired, openerRect); + + // space in document + const spaceAroundScroll = this.#constrainSpaceAround({ + above: openerRect.top + document.documentElement.scrollTop, + below: scrollHeight - openerRect.bottom - document.documentElement.scrollTop + }, spaceRequired, openerRect); + + if (options.updateLocation) { + this._location = this.#getLocation(spaceAround, spaceAroundScroll, spaceRequired); + } + + this._position = this.#getPosition(spaceAround, openerRect, contentRect); + if (!this._noPointer) this._pointerPosition = this.#getPointerPosition(openerRect); - if (this._trapFocus) return html` - ${content} - `; + if (options.updateHeight) { - return content; + // calculate height available to the popover contents for overflow because that is the only area capable of scrolling + const availableHeight = (this._location === 'block-start') ? spaceAround.above : spaceAround.below; + + if (!this._noAutoFit && availableHeight && availableHeight > 0) { + // only apply maximum if it's less than space available and the header/footer alone won't exceed it (content must be visible) + this._contentHeight = this._maxHeight !== null && availableHeight > this._maxHeight + ? this._maxHeight - 2 : availableHeight; + + // ensure the content height has updated when the __toggleScrollStyles event handler runs + await this.updateComplete; + } + + // todo: handle inline-start and inline-end locations + + } + + /** Dispatched when the popover position finishes adjusting */ + this.dispatchEvent(new CustomEvent('d2l-popover-position', { bubbles: true, composed: true })); + + }; + + const scrollWidth = content.scrollWidth; + const availableWidth = window.innerWidth - 40; + + this._width = (availableWidth > scrollWidth ? scrollWidth : availableWidth); + + await this.updateComplete; + + await adjustPosition(); + + } + + #removeAutoCloseHandlers() { + this.removeEventListener('blur', this.#handleAutoCloseFocusBound, { capture: true }); + document.body?.removeEventListener('focus', this.#handleAutoCloseFocusBound, { capture: true }); // DE41322: document.body can be null in some scenarios + document.removeEventListener('click', this.#handleAutoCloseClickBound, { capture: true }); + } + + #removeRepositionHandlers() { + this._openerIntersectionObserver?.unobserve(this._opener); + this._scrollablesObserved?.forEach(node => { + node.removeEventListener('scroll', this.#repositionBound); + }); + this._scrollablesObserved = null; + this._ancestorMutationObserver?.disconnect(); + window.removeEventListener('resize', this.#handleResizeBound); + } + + #reposition() { + // throttle repositioning (https://developer.mozilla.org/en-US/docs/Web/API/Document/scroll_event#scroll_event_throttling) + if (!this._repositioning) { + requestAnimationFrame(() => { + this.#position(undefined, { updateLocation: false, updateHeight: false }); + this._repositioning = false; + }); + } + this._repositioning = true; } }; diff --git a/components/popover/test/golden/popover-mixin/chromium/default.png b/components/popover/test/golden/popover-mixin/chromium/default.png index cde4f55677e..27b4d3ff124 100644 Binary files a/components/popover/test/golden/popover-mixin/chromium/default.png and b/components/popover/test/golden/popover-mixin/chromium/default.png differ diff --git a/components/popover/test/popover.js b/components/popover/test/popover.js index 0a146e6952a..890851afc9d 100644 --- a/components/popover/test/popover.js +++ b/components/popover/test/popover.js @@ -1,10 +1,32 @@ -import { LitElement } from 'lit'; +import { css, html, LitElement } from 'lit'; +import { classMap } from 'lit/directives/class-map.js'; import { PopoverMixin } from '../popover-mixin.js'; +import { styleMap } from 'lit/directives/style-map.js'; class Popover extends PopoverMixin(LitElement) { static get properties() { return { + /** + * Max-height. Note that the default behaviour is to be as tall as necessary within the viewport, so this property is usually not needed. + * @type {number} + */ + maxHeight: { type: Number, reflect: true, attribute: 'max-height' }, + /** + * Max-width (undefined). Specify a number that would be the px value. + * @type {number} + */ + maxWidth: { type: Number, reflect: true, attribute: 'max-width' }, + /** + * Min-height used when `no-auto-fit` is true. Specify a number that would be the px value. Note that the default behaviour is to be as tall as necessary within the viewport, so this property is usually not needed. + * @type {number} + */ + minHeight: { type: Number, reflect: true, attribute: 'min-height' }, + /** + * Min-width (undefined). Specify a number that would be the px value. + * @type {number} + */ + minWidth: { type: Number, reflect: true, attribute: 'min-width' }, /** * Whether to disable auto-close/light-dismiss * @type {boolean} @@ -15,46 +37,118 @@ class Popover extends PopoverMixin(LitElement) { * @type {boolean} */ noAutoFocus: { type: Boolean, reflect: true, attribute: 'no-auto-focus' }, + /** + * Render without a pointer + * @type {boolean} + */ + noPointer: { type: Boolean, reflect: true, attribute: 'no-pointer' }, /** * Whether the popover is open or not * @type {boolean} */ opened: { type: Boolean, reflect: true }, + /** + * Position the popover before or after the opener. Default is "block-end" (after). + * @type {'block-start'|'block-end'} + */ + positionLocation: { type: String, reflect: true, attribute: 'position-location' }, + /** + * Position the popover to span from the opener edge to this grid line. Default is "all" (centered). + * @type {'start'|'end'|'all'} + */ + positionSpan: { type: String, reflect: true, attribute: 'position-span' }, /** * Whether to render a d2l-focus-trap around the content * @type {boolean} */ - trapFocus: { type: Boolean, reflect: true, attribute: 'trap-focus' } + trapFocus: { type: Boolean, reflect: true, attribute: 'trap-focus' }, + _hasFooter: { state: true }, + _hasHeader: { state: true } }; } static get styles() { - return super.styles; + return [super.styles, css` + .test-content-layout { + align-items: flex-start; + display: flex; + flex-direction: column; + } + .test-content { + box-sizing: border-box; + max-width: 100%; + overflow-y: auto; + padding: 1rem; + } + .test-no-header { + display: none; + } + .test-no-footer { + display: none; + } + `]; } constructor() { super(); this.noAutoClose = false; this.noAutoFocus = false; + this.noPointer = false; this.opened = false; this.trapFocus = false; + + this._hasFooter = false; + this._hasHeader = false; } connectedCallback() { super.connectedCallback(); - this.addEventListener('d2l-popover-open', () => this.opened = true); - this.addEventListener('d2l-popover-close', () => this.opened = false); + this.addEventListener('d2l-popover-open', this.#handlePopoverOpen); + this.addEventListener('d2l-popover-close', this.#handlePopoverClose); } render() { - return this._renderPopover(); + + const headerClasses = { + 'test-header': true, + 'test-no-header': !this._hasHeader + }; + const headerStyle = {}; + + const footerClasses = { + 'test-footer': true, + 'test-no-footer': !this._hasFooter + }; + const footerStyle = {}; + + const content = html` +
+
+ +
+
+ +
+
+ +
+
+ `; + + return this.renderPopover(content); } willUpdate(changedProperties) { - if (changedProperties.has('noAutoClose') || changedProperties.has('noAutoFocus') || changedProperties.has('trapFocus')) { + if (changedProperties.has('maxHeight') || changedProperties.has('maxWidth') || changedProperties.has('minHeight') || changedProperties.has('minWidth') || changedProperties.has('noAutoClose') || changedProperties.has('noAutoFocus') || changedProperties.has('positionLocation') || changedProperties.has('positionSpan') || changedProperties.has('trapFocus')) { super.configure({ + maxHeight: this.maxHeight, + maxWidth: this.maxWidth, + minHeight: this.minHeight, + minWidth: this.minWidth, noAutoClose: this.noAutoClose, noAutoFocus: this.noAutoFocus, + noPointer: this.noPointer, + position: { location: this.positionLocation, span: this.positionSpan }, trapFocus: this.trapFocus }); } @@ -64,5 +158,34 @@ class Popover extends PopoverMixin(LitElement) { } } + #getContentContainer() { + return this.shadowRoot.querySelector('.test-content'); + } + + #handleContentScroll() { + console.log('handle content scroll'); + } + + #handleFooterSlotChange(e) { + this._hasFooter = e.target.assignedNodes().length !== 0; + } + + #handleHeaderSlotChange(e) { + this._hasHeader = e.target.assignedNodes().length !== 0; + } + + #handlePopoverClose() { + this.opened = false; + } + + #handlePopoverOpen() { + this.opened = true; + + const content = this.#getContentContainer(); + if (!this.noAutoFit && content) { + content.scrollTop ??= 0; + } + } + } customElements.define('d2l-test-popover', Popover);