UNPKG

@shopify/polaris

Version:

Shopify’s admin product component library

257 lines (217 loc) • 6.91 kB
import {tokens} from '@shopify/polaris-tokens'; import {debounce} from '../debounce'; import {dataPolarisTopBar, scrollable} from '../../components/shared'; import {stackedContent} from '../breakpoints'; import {getRectForNode, Rect} from '../geometry'; interface StickyItem { /** Node of the sticky element */ stickyNode: HTMLElement; /** Placeholder element */ placeHolderNode: HTMLElement; /** Element outlining the fixed position boundaries */ boundingElement?: HTMLElement | null; /** Offset vertical spacing from the top of the scrollable container */ offset: boolean; /** Should the element remain in a fixed position when the layout is stacked (smaller screens) */ disableWhenStacked: boolean; /** Method to handle positioning */ handlePositioning( stick: boolean, top?: number, left?: number, width?: string | number, ): void; } const SIXTY_FPS = 1000 / 60; export class StickyManager { private stickyItems: StickyItem[] = []; private stuckItems: StickyItem[] = []; private container: Document | HTMLElement | null = null; private topBarOffset = 0; private handleResize = debounce( () => { this.manageStickyItems(); }, SIXTY_FPS, {leading: true, trailing: true, maxWait: SIXTY_FPS}, ); private handleScroll = debounce( () => { this.manageStickyItems(); }, SIXTY_FPS, {leading: true, trailing: true, maxWait: SIXTY_FPS}, ); constructor(container?: Document | HTMLElement) { if (container) { this.setContainer(container); } } registerStickyItem(stickyItem: StickyItem): void { this.stickyItems.push(stickyItem); } unregisterStickyItem(nodeToRemove: HTMLElement) { const nodeIndex = this.stickyItems.findIndex( ({stickyNode}) => nodeToRemove === stickyNode, ); this.stickyItems.splice(nodeIndex, 1); } setContainer(el: Document | HTMLElement) { this.container = el; if (isDocument(el)) { this.setTopBarOffset(el); } this.container.addEventListener('scroll', this.handleScroll); window.addEventListener('resize', this.handleResize); this.manageStickyItems(); } removeScrollListener() { if (this.container) { this.container.removeEventListener('scroll', this.handleScroll); window.removeEventListener('resize', this.handleResize); } } private manageStickyItems() { if (this.stickyItems.length <= 0) { return; } const scrollTop = this.container ? scrollTopFor(this.container) : 0; const containerTop = getRectForNode(this.container).top + this.topBarOffset; this.stickyItems.forEach((stickyItem) => { const {handlePositioning} = stickyItem; const {sticky, top, left, width} = this.evaluateStickyItem( stickyItem, scrollTop, containerTop, ); this.updateStuckItems(stickyItem, sticky); handlePositioning(sticky, top, left, width); }); } private evaluateStickyItem( stickyItem: StickyItem, scrollTop: number, containerTop: number, ): { sticky: boolean; top: number; left: number; width: string | number; } { const { stickyNode, placeHolderNode, boundingElement, offset, disableWhenStacked, } = stickyItem; if (disableWhenStacked && stackedContent().matches) { return { sticky: false, top: 0, left: 0, width: 'auto', }; } const stickyOffset = offset ? this.getOffset(stickyNode) + parseInt(tokens.spacing['space-5'].value, 10) : this.getOffset(stickyNode); const scrollPosition = scrollTop + stickyOffset; const placeHolderNodeCurrentTop = placeHolderNode.getBoundingClientRect().top - containerTop + scrollTop; const top = containerTop + stickyOffset; const width = placeHolderNode.getBoundingClientRect().width; const left = placeHolderNode.getBoundingClientRect().left; let sticky: boolean; if (boundingElement == null) { sticky = scrollPosition >= placeHolderNodeCurrentTop; } else { const stickyItemHeight = stickyNode.getBoundingClientRect().height || stickyNode.firstElementChild?.getBoundingClientRect().height || 0; const stickyItemBottomPosition = boundingElement.getBoundingClientRect().bottom - stickyItemHeight + scrollTop - containerTop; sticky = scrollPosition >= placeHolderNodeCurrentTop && scrollPosition < stickyItemBottomPosition; } return { sticky, top, left, width, }; } private updateStuckItems(item: StickyItem, sticky: boolean) { const {stickyNode} = item; if (sticky && !this.isNodeStuck(stickyNode)) { this.addStuckItem(item); } else if (!sticky && this.isNodeStuck(stickyNode)) { this.removeStuckItem(item); } } private addStuckItem(stickyItem: StickyItem) { this.stuckItems.push(stickyItem); } private removeStuckItem(stickyItem: StickyItem) { const {stickyNode: nodeToRemove} = stickyItem; const nodeIndex = this.stuckItems.findIndex( ({stickyNode}) => nodeToRemove === stickyNode, ); this.stuckItems.splice(nodeIndex, 1); } private getOffset(node: HTMLElement) { if (this.stuckItems.length === 0) { return 0; } let offset = 0; let count = 0; const stuckNodesLength = this.stuckItems.length; const nodeRect = getRectForNode(node); while (count < stuckNodesLength) { const stuckNode = this.stuckItems[count].stickyNode; if (stuckNode !== node) { const stuckNodeRect = getRectForNode(stuckNode); if (!horizontallyOverlaps(nodeRect, stuckNodeRect)) { offset += getRectForNode(stuckNode).height; } } else { break; } count++; } return offset; } private isNodeStuck(node: HTMLElement): boolean { const nodeFound = this.stuckItems.findIndex( ({stickyNode}) => node === stickyNode, ); return nodeFound >= 0; } private setTopBarOffset(container: Document) { const topbarElement = container.querySelector( `:not(${scrollable.selector}) ${dataPolarisTopBar.selector}`, ); this.topBarOffset = topbarElement ? topbarElement.clientHeight : 0; } } function isDocument(node: HTMLElement | Document): node is Document { return node === document; } function scrollTopFor(container: HTMLElement | Document) { return isDocument(container) ? document.body.scrollTop || document.documentElement.scrollTop : container.scrollTop; } function horizontallyOverlaps(rect1: Rect, rect2: Rect) { const rect1Left = rect1.left; const rect1Right = rect1.left + rect1.width; const rect2Left = rect2.left; const rect2Right = rect2.left + rect2.width; return rect2Right < rect1Left || rect1Right < rect2Left; }