/* * The MIT License (MIT) * * Copyright (c) 2015 - present Instructure, Inc. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import { cloneElement, Children, Component, ReactElement, ReactNode } from 'react' import { View } from '@instructure/ui-view' import { testable } from '@instructure/ui-testable' import { omitProps, withDeterministicId } from '@instructure/ui-react-utils' import { hasVisibleChildren } from '@instructure/ui-a11y-utils' import { findTabbable, getActiveElement } from '@instructure/ui-dom-utils' import { withStyle } from '@instructure/emotion' import { PaginationButton } from './PaginationButton' import { PaginationArrowButton } from './PaginationArrowButton' import { PaginationPageInput } from './PaginationPageInput' import generateStyle from './styles' import generateComponentTheme from './theme' import type { PaginationPageProps } from './PaginationButton/props' import type { PaginationArrowDirections } from './PaginationArrowButton/props' import { propTypes, allowedProps } from './props' import type { PaginationProps, PaginationSnapshot, ChildPage } from './props' /** This is an [].findIndex optimized to work on really big, but sparse, arrays */ const fastFindIndex = ( arr: ChildPage[], fn: (page: ChildPage) => boolean | undefined ) => Number(Object.keys(arr).find((k) => fn(arr[Number(k)]))) const childrenArray = (props: PaginationProps) => { const { children } = props if (!children) { return [] } return Array.isArray(children) ? (children as React.ReactElement[]) : ([children] as React.ReactElement[]) } function propsHaveCompactView(props: PaginationProps) { if (props.children) return props.variant === 'compact' && childrenArray(props).length > 5 return props.variant === 'compact' && props.totalPageNumber! > 5 } type ArrowConfig = { pageIndex: number label: string shouldEnableIcon: boolean handleButtonRef: (el: Element | null) => void } /** --- category: components --- **/ @withDeterministicId() @withStyle(generateStyle, generateComponentTheme) @testable() class Pagination extends Component { static readonly componentId = 'Pagination' static propTypes = propTypes static allowedProps = allowedProps static defaultProps = { disabled: false, withFirstAndLastButton: false, showDisabledButtons: false, variant: 'full', as: 'div', labelNumberInput: (numberOfPages: number) => `of ${numberOfPages}`, screenReaderLabelNumberInput: ( currentPage: number, numberOfPages: number ) => `Select page (${currentPage} of ${numberOfPages})`, shouldHandleFocus: true, totalPageNumber: 0, currentPage: 1, siblingCount: 1, boundaryCount: 1, ellipsis: '…', renderPageIndicator: (page: number) => page, margin: 'space8' } static Page = PaginationButton static Navigation = PaginationArrowButton private readonly _labelId: string private _firstButton: HTMLButtonElement | null = null private _prevButton: HTMLButtonElement | null = null private _nextButton: HTMLButtonElement | null = null private _lastButton: HTMLButtonElement | null = null ref: Element | null = null currentPageRef: PaginationButton | null = null constructor(props: PaginationProps) { super(props) this._labelId = props.deterministicId!() } get _root() { console.warn( '_root property is deprecated and will be removed in v9, please use ref instead' ) return this.ref } get inputMode() { return this.props.variant === 'input' } get childPages() { return childrenArray(this.props) } get withFirstAndLastButton() { return this.inputMode || this.props.withFirstAndLastButton } get showDisabledButtons() { return this.inputMode || this.props.showDisabledButtons } getSnapshotBeforeUpdate(): PaginationSnapshot { const activeElement = getActiveElement() if ( activeElement === this._firstButton || activeElement === this._prevButton || activeElement === this._nextButton || activeElement === this._lastButton ) { return { lastFocusedButton: activeElement as HTMLButtonElement } } else { return { lastFocusedButton: undefined } } } componentDidMount() { this.props.makeStyles?.() } componentDidUpdate( prevProps: PaginationProps, _prevState: unknown, snapshot: PaginationSnapshot ) { this.props.makeStyles?.() // set focus on the currently active page if ( this.props.currentPage !== prevProps.currentPage && document.activeElement !== this._firstButton && document.activeElement !== this._prevButton && document.activeElement !== this._nextButton && document.activeElement !== this._lastButton ) { // @ts-expect-error fix typing this.currentPageRef?.ref?.focus?.() } if ( !this.props.shouldHandleFocus || (!propsHaveCompactView(prevProps) && !propsHaveCompactView(this.props)) ) { return } else { this.focusElementAfterUpdate(snapshot) } } focusElementAfterUpdate(snapshot: PaginationSnapshot) { const { lastFocusedButton } = snapshot if (lastFocusedButton) { const focusable = findTabbable(this.ref) const direction = lastFocusedButton.dataset.direction // By default we want to focus the previously focused button let nextFocusElement: Element = lastFocusedButton // In case the button is not focusable anymore // (disabled or not in the DOM), we focus to the next available page if (!focusable.includes(nextFocusElement)) { if (direction === 'first' || direction === 'prev') { nextFocusElement = focusable[0] } if (direction === 'next' || direction === 'last') { nextFocusElement = focusable[focusable.length - 1] } } ;(nextFocusElement as HTMLElement)?.focus() } } get compactView() { return propsHaveCompactView(this.props) } transferDisabledPropToChildren(children: PaginationProps['children']) { return children && this.props.disabled ? Children.map(children, (page) => cloneElement(page, { disabled: this.props.disabled }) ) : children } handleElementRef = (el: Element | null) => { this.ref = el if (el && typeof this.props.elementRef === 'function') { this.props.elementRef(el) } } handleInputRef = (el: HTMLInputElement | null) => { if (typeof this.props.inputRef === 'function') { this.props.inputRef(el) } } renderLabel() { const display = this.props.variant === 'full' ? 'inline-block' : 'block' const visibleLabel = hasVisibleChildren(this.props.label) return ( {this.props.label} ) } renderDefaultPageInput = () => { const { currentPage, totalPageNumber } = this.props return ( this.props.onPageChange?.(nextPageIndex + 1, currentPage!) } screenReaderLabel={this.props.screenReaderLabelNumberInput!} label={this.props.labelNumberInput} disabled={this.props.disabled} inputRef={this.handleInputRef} /> ) } renderPageInput(currentPageIndex: number) { if (!this.props.children) return this.renderDefaultPageInput() return ( ) } handleInputChange( event: | React.KeyboardEvent | React.MouseEvent | React.FocusEvent, pageIndex: number ) { this.childPages[pageIndex].props.onClick?.(event) } handleNavigation = (nextIndex: number, previousIndex: number) => { const { onPageChange } = this.props if (typeof onPageChange === 'function') { onPageChange(nextIndex, previousIndex) } } renderPagesInInterval = (from: number, to: number, currentPage: number) => { if (to - from > 1000) throw new Error('Pagination: too many pages (more than 1000)') const pages: ReactElement[] = [] for (let i = from; i <= to; i++) { pages.push( (i === currentPage ? (this.currentPageRef = e) : null)} key={i} onClick={() => this.handleNavigation(i, currentPage)} onMouseEnter={() => this.handleOnMouseEnter(i)} current={i === currentPage} {...(this.props.screenReaderLabelPageButton ? { screenReaderLabel: this.props.screenReaderLabelPageButton( i, this.props.totalPageNumber! ) } : {})} > {this.props.renderPageIndicator?.(i, currentPage)} ) } return pages } renderDefaultPages = () => { const { ellipsis, currentPage, totalPageNumber, siblingCount, boundaryCount, variant } = this.props const pages: ReactNode[] = [] if ( totalPageNumber! <= 2 * boundaryCount! || totalPageNumber! <= 1 + siblingCount! + boundaryCount! || variant === 'full' ) { return (
    {this.renderPagesInInterval(1, totalPageNumber!, currentPage!)}
) } if (currentPage! > boundaryCount! + siblingCount! + 1) { pages.push(this.renderPagesInInterval(1, boundaryCount!, currentPage!)) pages.push(
  • {ellipsis}
  • ) if ( currentPage! - siblingCount! > totalPageNumber! - boundaryCount! + 1 ) { pages.push( this.renderPagesInInterval( totalPageNumber! - boundaryCount! + 1, totalPageNumber!, currentPage! ) ) return
      {pages}
    } pages.push( this.renderPagesInInterval( currentPage! - siblingCount!, currentPage!, currentPage! ) ) } else { pages.push( this.renderPagesInInterval( 1, Math.max(currentPage!, boundaryCount!), currentPage! ) ) } if (currentPage! < totalPageNumber! - (siblingCount! + boundaryCount!)) { pages.push( this.renderPagesInInterval( Math.max(currentPage!, boundaryCount!) + 1, currentPage! + siblingCount!, currentPage! ) ) pages.push(
  • {ellipsis}
  • ) pages.push( this.renderPagesInInterval( totalPageNumber! - boundaryCount! + 1, totalPageNumber!, currentPage! ) ) } else { pages.push( this.renderPagesInInterval( currentPage! + 1, totalPageNumber!, currentPage! ) ) } return
      {pages}
    } renderPages(currentPageIndex: number) { if (!this.props.children) return this.renderDefaultPages() const allPages = this.childPages let visiblePages = allPages if (this.compactView) { const firstIndex = 0 const lastIndex = allPages.length - 1 const sliceStart = Math.min( lastIndex - 3, Math.max(currentPageIndex - 1, firstIndex) ) const sliceEnd = Math.min(currentPageIndex + 4, lastIndex) visiblePages = allPages.slice(sliceStart, sliceEnd) const firstPage = allPages[firstIndex] const lastPage = allPages[lastIndex] if (sliceStart - firstIndex > 1) visiblePages.unshift( ) if (sliceStart - firstIndex > 0) visiblePages.unshift(firstPage) if (lastIndex - sliceEnd + 1 > 1) visiblePages.push( ) if (lastIndex - sliceEnd + 1 > 0) visiblePages.push(lastPage) } return ( {this.transferDisabledPropToChildren(visiblePages)} ) } getArrowVariant( direction: PaginationArrowDirections, currentPageIndex: number, pagesCount: number ): ArrowConfig { switch (direction) { case 'first': return { pageIndex: 0, label: this.props.labelFirst || 'First Page', shouldEnableIcon: currentPageIndex > 1, handleButtonRef: (el) => { this._firstButton = el as HTMLButtonElement } } case 'prev': return { pageIndex: currentPageIndex - 1, label: this.props.labelPrev || 'Previous Page', shouldEnableIcon: currentPageIndex > 0, handleButtonRef: (el) => { this._prevButton = el as HTMLButtonElement } } case 'next': return { pageIndex: currentPageIndex + 1, label: this.props.labelNext || 'Next Page', shouldEnableIcon: currentPageIndex < pagesCount - 1, handleButtonRef: (el) => { this._nextButton = el as HTMLButtonElement } } case 'last': return { pageIndex: pagesCount - 1, label: this.props.labelLast || 'Last Page', shouldEnableIcon: currentPageIndex < pagesCount - 2, handleButtonRef: (el) => { this._lastButton = el as HTMLButtonElement } } } } renderDefaultArrowButton = (direction: PaginationArrowDirections) => { if ( !this.withFirstAndLastButton && (direction === 'first' || direction === 'last') ) { return null } // We don't display the arrows in "compact" variant under 6 items if (!(propsHaveCompactView(this.props) || this.inputMode)) { return null } const { totalPageNumber, currentPage } = this.props const { label, shouldEnableIcon, handleButtonRef } = this.getArrowVariant( direction, currentPage! - 1, totalPageNumber! ) const disabled = this.props.disabled || !shouldEnableIcon const onClick = () => { if (direction === 'first') { this.handleNavigation(1, currentPage!) } if (direction === 'prev') { this.handleNavigation(Math.max(currentPage! - 1, 1), currentPage!) } if (direction === 'next') { this.handleNavigation( Math.min(currentPage! + 1, totalPageNumber!), currentPage! ) } if (direction === 'last') { this.handleNavigation(totalPageNumber!, currentPage!) } } return shouldEnableIcon || this.showDisabledButtons ? ( ) : null } renderArrowButton( direction: PaginationArrowDirections, currentPageIndex: number ) { if (!this.props.children) return this.renderDefaultArrowButton(direction) const { childPages } = this // We don't display the arrows in "compact" variant under 6 items if (!(propsHaveCompactView(this.props) || this.inputMode)) { return null } if ( !this.withFirstAndLastButton && (direction === 'first' || direction === 'last') ) { return null } const { pageIndex, label, shouldEnableIcon, handleButtonRef } = this.getArrowVariant(direction, currentPageIndex, childPages.length) const page = childPages[pageIndex] const disabled = page?.props?.disabled || this.props.disabled || !shouldEnableIcon const onClick = page?.props?.onClick return shouldEnableIcon || this.showDisabledButtons ? ( ) : null } handleOnMouseEnter = (page: number) => { if (typeof this.props.onMouseEnter === 'function') { this.props.onMouseEnter(page) } } render() { const currentPageIndex = fastFindIndex( this.childPages, (p) => p && p.props && p.props.current ) const passthroughProps = View.omitViewProps( omitProps(this.props, Pagination.allowedProps), Pagination ) return ( {this.props.label && this.renderLabel()} {this.renderArrowButton('first', currentPageIndex)} {this.renderArrowButton('prev', currentPageIndex)} {this.inputMode ? this.renderPageInput(currentPageIndex) : this.renderPages(currentPageIndex)} {this.renderArrowButton('next', currentPageIndex)} {this.renderArrowButton('last', currentPageIndex)} ) } } export default Pagination export { Pagination, PaginationButton }