From 933a910ec78d5dce957851d83b85c5d25e220223 Mon Sep 17 00:00:00 2001 From: Matt Driscoll Date: Mon, 18 Sep 2023 16:47:29 -0700 Subject: [PATCH] feat(pagination): enable responsive layout (#7722) **Related Issue:** #6687 ## Summary - Makes pagination responsive - Uses resize observer - Makes ellipsis and page styles equivalent - Refactor page rendering logic to count ellipsis as well as pages. Next/previous buttons are not counted because they are always present. - Exports Breakpoints interface from helper - Modifies some e2e tests - Adds screenshot tests These are the proposed amount of items per breakpoint. (item numbers are pages + ellipsis) ```ts const maxItemBreakpoints = { large: 11, medium: 9, small: 7, xsmall: 5, }; ``` --- .../components/pagination/pagination.e2e.ts | 4 +- .../src/components/pagination/pagination.scss | 76 +++--- .../pagination/pagination.stories.ts | 64 ++++- .../src/components/pagination/pagination.tsx | 252 ++++++++++++------ .../src/components/pagination/resources.ts | 5 + .../src/demos/pagination.html | 2 +- .../src/utils/responsive.ts | 2 + 7 files changed, 287 insertions(+), 118 deletions(-) diff --git a/packages/calcite-components/src/components/pagination/pagination.e2e.ts b/packages/calcite-components/src/components/pagination/pagination.e2e.ts index 3705635c720..498117e9db3 100644 --- a/packages/calcite-components/src/components/pagination/pagination.e2e.ts +++ b/packages/calcite-components/src/components/pagination/pagination.e2e.ts @@ -54,7 +54,7 @@ describe("calcite-pagination", () => { it("should render start ellipsis when total pages is over 5 and the selected page more than 4 from the starting page", async () => { const page = await newE2EPage(); await page.setContent( - `` + `` ); const startEllipsis = await page.find(`calcite-pagination >>> .${CSS.ellipsis}.${CSS.ellipsisStart}`); @@ -63,7 +63,7 @@ describe("calcite-pagination", () => { it("should render end ellipsis when total pages is over 5 and the selected page more than 3 from the final page", async () => { const page = await newE2EPage(); await page.setContent( - `` + `` ); const endEllipsis = await page.find(`calcite-pagination >>> .${CSS.ellipsis}.${CSS.ellipsisEnd}`); expect(endEllipsis).not.toBeNull(); diff --git a/packages/calcite-components/src/components/pagination/pagination.scss b/packages/calcite-components/src/components/pagination/pagination.scss index 2eb1e441255..3836098ca3d 100644 --- a/packages/calcite-components/src/components/pagination/pagination.scss +++ b/packages/calcite-components/src/components/pagination/pagination.scss @@ -1,45 +1,46 @@ +:host { + @apply flex; + writing-mode: horizontal-tb; +} + :host([scale="s"]) { - --calcite-pagination-spacing-internal: theme("spacing.1") theme("spacing.2"); & .previous, & .next, - & .page { - @apply text-n2h h-6; - } - .previous, - .next { - @apply px-1; + & .page, + & .ellipsis { + @apply text-n2h h-6 px-1; + min-inline-size: theme("width.6"); } } :host([scale="m"]) { - --calcite-pagination-spacing-internal: theme("spacing.2") theme("spacing.3"); & .previous, & .next, - & .page { - @apply text-n1h h-8; - } - .previous, - .next { - @apply px-2; + & .page, + & .ellipsis { + @apply text-n1h h-8 px-2; + min-inline-size: theme("width.8"); } } :host([scale="l"]) { - --calcite-pagination-spacing-internal: theme("spacing.3") theme("spacing.4"); & .previous, & .next, - & .page { + & .page, + & .ellipsis { @apply text-0h h-11; + min-inline-size: theme("width.11"); } - .previous, - .next { - @apply px-4; + + & .previous, + & .next { + @apply px-2.5; } -} -:host { - @apply flex; - writing-mode: horizontal-tb; + & .page, + & .ellipsis { + @apply px-3; + } } // focus styles @@ -52,22 +53,34 @@ .previous, .next, -.page { - @apply text-0h +.page, +.ellipsis { + @apply p-0 + m-0 + text-0h text-color-3 font-inherit box-border flex - cursor-pointer items-center border-none border-opacity-0 + justify-center + align-baseline bg-transparent; +} + +.previous, +.next, +.page { + @apply cursor-pointer; border-block: 2px solid transparent; + &:hover { @apply text-color-1 transition-default; } } + .page { &:hover { @apply border-b-color-2; @@ -76,6 +89,7 @@ @apply text-color-1 border-b-color-brand font-medium; } } + .previous, .next { &:hover { @@ -92,15 +106,5 @@ } } } -.next { - margin-inline-end: theme("spacing.0"); -} -.page, -.ellipsis { - padding: var(--calcite-pagination-spacing-internal); -} -.ellipsis { - @apply text-color-3 flex items-end; -} @include base-component(); diff --git a/packages/calcite-components/src/components/pagination/pagination.stories.ts b/packages/calcite-components/src/components/pagination/pagination.stories.ts index 9ca48a58eba..6b1c9bb7bb0 100644 --- a/packages/calcite-components/src/components/pagination/pagination.stories.ts +++ b/packages/calcite-components/src/components/pagination/pagination.stories.ts @@ -1,6 +1,6 @@ import { number, select } from "@storybook/addon-knobs"; -import { locales } from "../../utils/locale"; -import { modesDarkDefault } from "../../../.storybook/utils"; +import { locales, numberingSystems } from "../../utils/locale"; +import { createBreakpointStories, modesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; import { html } from "../../../support/formatting"; import { storyFilters } from "../../../.storybook/helpers"; @@ -17,16 +17,76 @@ export default { }; export const simple = (): string => html` + `; +const getResponsiveTemplate = ({ + totalItems, + pageSize, + type, +}: { + totalItems: number; + pageSize: number; + type: "first" | "last" | "middle"; +}) => { + return html` + + `; +}; + +export const responsiveLargeNumberFirstPage_TestOnly = (): string => + createBreakpointStories(getResponsiveTemplate({ totalItems: 150000, pageSize: 100, type: "first" })); + +export const responsiveLargeNumberMiddlePage_TestOnly = (): string => + createBreakpointStories(getResponsiveTemplate({ totalItems: 150000, pageSize: 100, type: "middle" })); + +export const responsiveLargeNumberLastPage_TestOnly = (): string => + createBreakpointStories(getResponsiveTemplate({ totalItems: 150000, pageSize: 100, type: "last" })); + +export const responsiveSmallNumberFirstPage_TestOnly = (): string => + createBreakpointStories(getResponsiveTemplate({ totalItems: 50, pageSize: 10, type: "first" })); + +export const responsiveSmallNumberMiddlePage_TestOnly = (): string => + createBreakpointStories(getResponsiveTemplate({ totalItems: 50, pageSize: 10, type: "middle" })); + +export const responsiveSmallNumberLastPage_TestOnly = (): string => + createBreakpointStories(getResponsiveTemplate({ totalItems: 50, pageSize: 10, type: "last" })); + +export const responsiveTinyNumberFirstPage_TestOnly = (): string => + createBreakpointStories(getResponsiveTemplate({ totalItems: 12, pageSize: 1, type: "first" })); + +export const responsiveTinyNumberMiddlePage_TestOnly = (): string => + createBreakpointStories(getResponsiveTemplate({ totalItems: 12, pageSize: 1, type: "middle" })); + +export const responsiveTinyNumberLastPage_TestOnly = (): string => + createBreakpointStories(getResponsiveTemplate({ totalItems: 12, pageSize: 1, type: "last" })); + export const darkModeFrenchLocaleAndLargeScaleGetsMediumChevron_TestOnly = (): string => html` + entries.forEach(this.resizeHandler) + ); //-------------------------------------------------------------------------- // @@ -151,20 +173,25 @@ export class Pagination connectedCallback(): void { connectLocalized(this); connectMessages(this); + this.resizeObserver?.observe(this.el); } async componentWillLoad(): Promise { - await setUpMessages(this); + const [, breakpoints] = await Promise.all([setUpMessages(this), getBreakpoints()]); + this.breakpoints = breakpoints; setUpLoadableComponent(this); + this.handleTotalPages(); } componentDidLoad(): void { setComponentLoaded(this); + this.setMaxItemsToBreakpoint(this.el.clientWidth); } disconnectedCallback(): void { disconnectLocalized(this); disconnectMessages(this); + this.resizeObserver?.disconnect(); } // -------------------------------------------------------------------------- @@ -198,80 +225,164 @@ export class Pagination // // -------------------------------------------------------------------------- + private setMaxItemsToBreakpoint(width: number): void { + const { breakpoints } = this; + + if (!breakpoints || !width) { + return; + } + + if (width >= breakpoints.width.medium) { + this.maxItems = maxItemBreakpoints.large; + return; + } + + if (width >= breakpoints.width.small) { + this.maxItems = maxItemBreakpoints.medium; + return; + } + + if (width >= breakpoints.width.xsmall) { + this.maxItems = maxItemBreakpoints.small; + return; + } + + this.maxItems = maxItemBreakpoints.xsmall; + } + + private resizeHandler = ({ contentRect: { width } }: ResizeObserverEntry): void => + this.setMaxItemsToBreakpoint(width); + private getLastStart(): number { - const { totalItems, pageSize } = this; + const { totalItems, pageSize, totalPages } = this; const lastStart = - totalItems % pageSize === 0 - ? totalItems - pageSize - : Math.floor(totalItems / pageSize) * pageSize; + totalItems % pageSize === 0 ? totalItems - pageSize : Math.floor(totalPages) * pageSize; return lastStart + 1; } - private previousClicked = (): void => { - this.previousPage().then(); + private previousClicked = async (): Promise => { + await this.previousPage(); this.emitUpdate(); }; - private nextClicked = (): void => { - this.nextPage(); + private nextClicked = async (): Promise => { + await this.nextPage(); this.emitUpdate(); }; - private showLeftEllipsis() { - return Math.floor(this.startItem / this.pageSize) > 3; + private showStartEllipsis() { + return ( + this.totalPages > this.maxItems && + Math.floor(this.startItem / this.pageSize) > + this.maxItems - firstAndLastPageCount - ellipsisCount + ); } - private showRightEllipsis() { - return (this.totalItems - this.startItem) / this.pageSize > 3; + private showEndEllipsis() { + return ( + this.totalPages > this.maxItems && + (this.totalItems - this.startItem) / this.pageSize > + this.maxItems - firstAndLastPageCount - (ellipsisCount - 1) + ); } private emitUpdate() { this.calcitePaginationChange.emit(); } + private handlePageClick = (event: Event) => { + const target = event.target as HTMLButtonElement; + this.startItem = parseInt(target.value, 10); + this.emitUpdate(); + }; + //-------------------------------------------------------------------------- // // Render Methods // //-------------------------------------------------------------------------- - renderPages(): VNode[] { + renderEllipsis(type: "start" | "end"): VNode { + return ( + + … + + ); + } + + renderItems(): VNode[] { + const { totalItems, pageSize, startItem, maxItems, totalPages } = this; + const items: VNode[] = []; + + const renderFirstPage = totalItems > pageSize; + const renderStartEllipsis = this.showStartEllipsis(); + const renderEndEllipsis = this.showEndEllipsis(); const lastStart = this.getLastStart(); + + if (renderFirstPage) { + items.push(this.renderPage(1)); + } + + if (renderStartEllipsis) { + items.push(this.renderEllipsis("start")); + } + + const remainingItems = + maxItems - + firstAndLastPageCount - + (renderEndEllipsis ? 1 : 0) - + (renderStartEllipsis ? 1 : 0); + let end: number; let nextStart: number; // if we don't need ellipses render the whole set - if (this.totalItems / this.pageSize <= maxPagesDisplayed) { - nextStart = 1 + this.pageSize; - end = lastStart - this.pageSize; + if (totalPages - 1 <= remainingItems) { + nextStart = 1 + pageSize; + end = lastStart - pageSize; } else { // if we're within max pages of page 1 - if (this.startItem / this.pageSize < maxPagesDisplayed - 1) { - nextStart = 1 + this.pageSize; - end = 1 + 4 * this.pageSize; + if (startItem / pageSize < remainingItems) { + nextStart = 1 + pageSize; + end = 1 + remainingItems * pageSize; } else { // if we're within max pages of last page - if (this.startItem + 3 * this.pageSize >= this.totalItems) { - nextStart = lastStart - 4 * this.pageSize; - end = lastStart - this.pageSize; + if (startItem + remainingItems * pageSize >= totalItems) { + nextStart = lastStart - remainingItems * pageSize; + end = lastStart - pageSize; } else { - nextStart = this.startItem - this.pageSize; - end = this.startItem + this.pageSize; + // if we're within the center pages + nextStart = startItem - pageSize * ((remainingItems - 1) / 2); + end = startItem + pageSize * ((remainingItems - 1) / 2); } } } - const pages = []; - while (nextStart <= end) { - pages.push(nextStart); - nextStart = nextStart + this.pageSize; + for (let i = 0; i < remainingItems && nextStart <= end; i++) { + items.push(this.renderPage(nextStart)); + nextStart = nextStart + pageSize; } - return pages.map((page) => this.renderPage(page)); + if (renderEndEllipsis) { + items.push(this.renderEllipsis("end")); + } + + items.push(this.renderPage(lastStart)); + + return items; } renderPage(start: number): VNode { - const page = Math.floor(start / this.pageSize) + (this.pageSize === 1 ? 0 : 1); + const { pageSize } = this; + const page = Math.floor(start / pageSize) + (pageSize === 1 ? 0 : 1); + numberStringFormatter.numberFormatOptions = { locale: this.effectiveLocale, numberingSystem: this.numberingSystem, @@ -288,37 +399,28 @@ export class Pagination [CSS.page]: true, [CSS.selected]: selected, }} - onClick={() => { - this.startItem = start; - this.emitUpdate(); - }} + onClick={this.handlePageClick} + value={start} > {displayedPage} ); } - renderLeftEllipsis(): VNode { - if (this.totalItems / this.pageSize > maxPagesDisplayed && this.showLeftEllipsis()) { - return ; - } - } - - renderRightEllipsis(): VNode { - if (this.totalItems / this.pageSize > maxPagesDisplayed && this.showRightEllipsis()) { - return ; - } - } - render(): VNode { - const { totalItems, pageSize, startItem } = this; + const { totalItems, pageSize, startItem, messages, scale } = this; + const prevDisabled = pageSize === 1 ? startItem <= pageSize : startItem < pageSize; + const nextDisabled = pageSize === 1 ? startItem + pageSize > totalItems : startItem + pageSize > totalItems; + + const iconScale = scale === "l" ? "m" : "s"; + return ( - {totalItems > pageSize ? this.renderPage(1) : null} - {this.renderLeftEllipsis()} - {this.renderPages()} - {this.renderRightEllipsis()} - {this.renderPage(this.getLastStart())} + {this.renderItems()} ); diff --git a/packages/calcite-components/src/components/pagination/resources.ts b/packages/calcite-components/src/components/pagination/resources.ts index b63f6ea55ff..ef98871a5af 100644 --- a/packages/calcite-components/src/components/pagination/resources.ts +++ b/packages/calcite-components/src/components/pagination/resources.ts @@ -8,3 +8,8 @@ export const CSS = { ellipsisStart: "ellipsis--start", ellipsisEnd: "ellipsis--end", }; + +export const ICONS = { + next: "chevronRight", + previous: "chevronLeft", +}; diff --git a/packages/calcite-components/src/demos/pagination.html b/packages/calcite-components/src/demos/pagination.html index 70f6713f879..dc670ea4ced 100644 --- a/packages/calcite-components/src/demos/pagination.html +++ b/packages/calcite-components/src/demos/pagination.html @@ -9,7 +9,7 @@