diff --git a/packages/components/pagination/README.md b/packages/components/pagination/README.md new file mode 100644 index 000000000..e0966aad5 --- /dev/null +++ b/packages/components/pagination/README.md @@ -0,0 +1,24 @@ +# @nextui-org/pagination + +A Quick description of the component + +> This is an internal utility, not intended for public usage. + +## Installation + +```sh +yarn add @nextui-org/pagination +# or +npm i @nextui-org/pagination +``` + +## Contribution + +Yes please! See the +[contributing guidelines](https://github.com/nextui-org/nextui/blob/master/CONTRIBUTING.md) +for details. + +## Licence + +This project is licensed under the terms of the +[MIT license](https://github.com/nextui-org/nextui/blob/master/LICENSE). \ No newline at end of file diff --git a/packages/components/pagination/__tests__/pagination.test.tsx b/packages/components/pagination/__tests__/pagination.test.tsx new file mode 100644 index 000000000..9d73d5b92 --- /dev/null +++ b/packages/components/pagination/__tests__/pagination.test.tsx @@ -0,0 +1,63 @@ +import * as React from "react"; +import {render} from "@testing-library/react"; + +import {Pagination} from "../src"; + +describe("Pagination", () => { + it("should render correctly", () => { + const wrapper = render(); + + expect(() => wrapper.unmount()).not.toThrow(); + }); + + it("ref should be forwarded", () => { + const ref = React.createRef(); + + render(); + expect(ref.current).not.toBeNull(); + }); + + it("should render correctly with controls", () => { + const wrapper = render(); + + const nextButton = wrapper.getByLabelText("next page button"); + const prevButton = wrapper.getByLabelText("previous page button"); + + expect(nextButton).not.toBeNull(); + expect(prevButton).not.toBeNull(); + }); + + it("should render correctly with no controls", () => { + const wrapper = render(); + + const nextButton = wrapper.queryByLabelText("next page button"); + const prevButton = wrapper.queryByLabelText("previous page button"); + + expect(nextButton).toBeNull(); + expect(prevButton).toBeNull(); + }); + + it("should show dots when total is greater than 10", () => { + const wrapper = render(); + + const dots = wrapper.queryAllByLabelText("dots element"); + + expect(dots).toHaveLength(1); + }); + + it("should show 2 dots when total is greater than 10 and the initialPage is in de middle", () => { + const wrapper = render(); + + const dots = wrapper.queryAllByLabelText("dots element"); + + expect(dots).toHaveLength(2); + }); + + it("the pagination highlight should have a aria-hidden attribute", () => { + const {container} = render(); + + const highlight = container.querySelector(".nextui-pagination-highlight"); + + expect(highlight).toHaveAttribute("aria-hidden", "true"); + }); +}); diff --git a/packages/components/pagination/clean-package.config.json b/packages/components/pagination/clean-package.config.json new file mode 100644 index 000000000..ea9967219 --- /dev/null +++ b/packages/components/pagination/clean-package.config.json @@ -0,0 +1,3 @@ +{ "replace": { "main": "dist/index.cjs.js", "module": "dist/index.esm.js", +"types": "dist/index.d.ts", "exports": { ".": { "import": "./dist/index.esm.js", +"require": "./dist/index.cjs.js" }, "./package.json": "./package.json" } } } \ No newline at end of file diff --git a/packages/components/pagination/package.json b/packages/components/pagination/package.json new file mode 100644 index 000000000..cfb8f2e0b --- /dev/null +++ b/packages/components/pagination/package.json @@ -0,0 +1,52 @@ +{ + "name": "@nextui-org/pagination", + "version": "1.0.0-beta.11", + "description": "The Pagination component allows you to display active page and navigate between multiple pages.", + "keywords": [ + "pagination" + ], + "author": "Junior Garcia ", + "homepage": "https://nextui.org", + "license": "MIT", + "main": "src/index.ts", + "sideEffects": false, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nextui-org/nextui.git", + "directory": "packages/components/pagination" + }, + "bugs": { + "url": "https://github.com/nextui-org/nextui/issues" + }, + "scripts": { + "build": "tsup src/index.ts --format=esm,cjs --dts", + "dev": "yarn build:fast -- --watch", + "clean": "rimraf dist .turbo", + "typecheck": "tsc --noEmit", + "build:fast": "tsup src/index.ts --format=esm,cjs", + "prepack": "clean-package", + "postpack": "clean-package restore" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "dependencies": { + "@nextui-org/system": "workspace:*", + "@nextui-org/shared-utils": "workspace:*", + "@nextui-org/shared-css": "workspace:*", + "@nextui-org/dom-utils": "workspace:*", + "@nextui-org/use-pagination": "workspace:*", + "@nextui-org/grid": "workspace:*", + "@react-aria/utils": "^3.14.0" + }, + "devDependencies": { + "clean-package": "2.1.1", + "react": "^17.0.2" + } +} diff --git a/packages/components/pagination/src/index.ts b/packages/components/pagination/src/index.ts new file mode 100644 index 000000000..67f749023 --- /dev/null +++ b/packages/components/pagination/src/index.ts @@ -0,0 +1,5 @@ +// export types +export type {PaginationProps} from "./pagination"; + +// export component +export {default as Pagination} from "./pagination"; diff --git a/packages/components/pagination/src/pagination-ellipsis.tsx b/packages/components/pagination/src/pagination-ellipsis.tsx new file mode 100644 index 000000000..028ceb3c7 --- /dev/null +++ b/packages/components/pagination/src/pagination-ellipsis.tsx @@ -0,0 +1,81 @@ +import {forwardRef, HTMLNextUIProps} from "@nextui-org/system"; +import {useDOMRef} from "@nextui-org/dom-utils"; +import {useState, MouseEvent} from "react"; +import {clsx, __DEV__} from "@nextui-org/shared-utils"; + +import PaginationItem from "./pagination-item"; +import {StyledPaginationEllipsis} from "./pagination.styles"; + +export interface PaginationEllipsisProps extends HTMLNextUIProps<"svg"> { + value?: string | number; + isBefore?: boolean; + onlyDots?: boolean; + animated?: boolean; + bordered?: boolean; + onClick?: (e: MouseEvent) => void; +} + +const PaginationEllipsis = forwardRef((props, ref) => { + const [showMore, setShowMore] = useState(false); + + const {className, value, animated, bordered, onlyDots, isBefore, onClick, ...otherProps} = props; + + const domRef = useDOMRef(ref); + + return ( + onClick?.(e)} + onMouseEnter={() => setShowMore(true)} + onMouseLeave={() => setShowMore(false)} + > + {showMore ? ( + + + + + ) : ( + + + + + + )} + + ); +}); + +if (__DEV__) { + PaginationEllipsis.displayName = "NextUI.PaginationEllipsis"; +} + +PaginationEllipsis.toString = () => ".nextui-pagination-ellipsis"; + +export default PaginationEllipsis; diff --git a/packages/components/pagination/src/pagination-highlight.tsx b/packages/components/pagination/src/pagination-highlight.tsx new file mode 100644 index 000000000..809255fea --- /dev/null +++ b/packages/components/pagination/src/pagination-highlight.tsx @@ -0,0 +1,67 @@ +import {useState, useEffect, useMemo} from "react"; +import {mergeProps} from "@react-aria/utils"; +import {forwardRef, HTMLNextUIProps} from "@nextui-org/system"; +import {useDOMRef} from "@nextui-org/dom-utils"; +import {clsx, __DEV__} from "@nextui-org/shared-utils"; + +import {StyledPaginationHighlight} from "./pagination.styles"; + +export interface PaginationHighlightProps extends HTMLNextUIProps<"div"> { + active: number; + rounded?: boolean; + animated?: boolean; + noMargin?: boolean; + shadow?: boolean; +} + +const PaginationHighlight = forwardRef((props, ref) => { + const {className, css, active, shadow, noMargin, rounded, ...otherProps} = props; + const [selfActive, setSelfActive] = useState(active); + const [moveClassName, setMoveClassName] = useState(""); + + const domRef = useDOMRef(ref); + + useEffect(() => { + if (active !== selfActive) { + setSelfActive(active); + setMoveClassName(`nextui-pagination-highlight--moving`); + const timer = setTimeout(() => { + setMoveClassName(""); + clearTimeout(timer); + }, 350); + } + }, [active]); + + const leftValue = useMemo( + () => + noMargin + ? `var(--nextui--paginationSize) * ${selfActive}` + : `var(--nextui--paginationSize) * ${selfActive} + ${selfActive * 4 + 2}px`, + [selfActive, noMargin], + ); + + return ( + + ); +}); + +if (__DEV__) { + PaginationHighlight.displayName = "NextUI.PaginationHighlight"; +} + +PaginationHighlight.toString = () => ".nextui-pagination-highlight"; + +export default PaginationHighlight; diff --git a/packages/components/pagination/src/pagination-icon.tsx b/packages/components/pagination/src/pagination-icon.tsx new file mode 100644 index 000000000..eb2baf7b6 --- /dev/null +++ b/packages/components/pagination/src/pagination-icon.tsx @@ -0,0 +1,62 @@ +import {MouseEvent} from "react"; +import {forwardRef, HTMLNextUIProps} from "@nextui-org/system"; +import {useDOMRef} from "@nextui-org/dom-utils"; +import {clsx, __DEV__} from "@nextui-org/shared-utils"; + +import PaginationItem from "./pagination-item"; +import {StyledPaginationIcon} from "./pagination.styles"; + +export interface PaginationIconProps extends HTMLNextUIProps<"svg"> { + isPrev?: boolean; + disabled?: boolean; + onlyDots?: boolean; + animated?: boolean; + bordered?: boolean; + onClick?: (e: MouseEvent) => void; +} + +const PaginationIcon = forwardRef((props, ref) => { + const {className, isPrev, disabled, onlyDots, animated, bordered, onClick, ...otherProps} = props; + + const domRef = useDOMRef(ref); + + return ( + "} + onClick={(e) => onClick?.(e)} + > + + + + + ); +}); + +if (__DEV__) { + PaginationIcon.displayName = "NextUI.PaginationIcon"; +} + +PaginationIcon.toString = () => ".nextui-pagination-icon"; + +export default PaginationIcon; diff --git a/packages/components/pagination/src/pagination-item.tsx b/packages/components/pagination/src/pagination-item.tsx new file mode 100644 index 000000000..98c16ffaf --- /dev/null +++ b/packages/components/pagination/src/pagination-item.tsx @@ -0,0 +1,93 @@ +import {useMemo, MouseEvent} from "react"; +import {forwardRef, HTMLNextUIProps} from "@nextui-org/system"; +import {useDOMRef} from "@nextui-org/dom-utils"; +import {clsx, __DEV__} from "@nextui-org/shared-utils"; +import {DOTS} from "@nextui-org/use-pagination"; + +import {StyledPaginationItem, StyledPaginationItemContent} from "./pagination.styles"; + +export interface PaginationItemProps extends HTMLNextUIProps<"button"> { + active?: boolean; + value?: string | number; + onlyDots?: boolean; + disabled?: boolean; + bordered?: boolean; + animated?: boolean; + preserveContent?: boolean; + onClick?: (e: MouseEvent) => void; +} + +const getItemAriaLabel = (page?: string | number) => { + if (!page) return; + switch (page) { + case DOTS: + return "dots element"; + case "<": + return "previous page button"; + case ">": + return "next page button"; + case "first": + return "first page button"; + case "last": + return "last page button"; + default: + return `${page} item`; + } +}; + +const PaginationItem = forwardRef((props, ref) => { + const { + children, + className, + active, + value, + animated, + bordered, + disabled, + onlyDots, + preserveContent = false, + onClick, + ...otherProps + } = props; + + const domRef = useDOMRef(ref); + + const ariaLabel = useMemo( + () => (active ? `${getItemAriaLabel(value)} active` : getItemAriaLabel(value)), + [value, active], + ); + + const clickHandler = (event: React.MouseEvent) => { + if (disabled) return; + onClick?.(event); + }; + + return ( + + + {children} + + + ); +}); + +if (__DEV__) { + PaginationItem.displayName = "NextUI.PaginationItem"; +} + +PaginationItem.toString = () => ".nextui-pagination-item"; + +export default PaginationItem; diff --git a/packages/components/pagination/src/pagination.animations.ts b/packages/components/pagination/src/pagination.animations.ts new file mode 100644 index 000000000..0842f162c --- /dev/null +++ b/packages/components/pagination/src/pagination.animations.ts @@ -0,0 +1,13 @@ +import {keyframes} from "@nextui-org/system"; + +export const scale = keyframes({ + "0%": { + transform: "scale(1)", + }, + "60%": { + transform: "scale($$paginationScaleTransform)", + }, + "100%": { + transform: "scale(1)", + }, +}); diff --git a/packages/components/pagination/src/pagination.styles.ts b/packages/components/pagination/src/pagination.styles.ts new file mode 100644 index 000000000..8398edf08 --- /dev/null +++ b/packages/components/pagination/src/pagination.styles.ts @@ -0,0 +1,366 @@ +import {styled} from "@nextui-org/system"; +import {sharedFocus} from "@nextui-org/shared-css"; + +import {scale} from "./pagination.animations"; + +export const StyledPaginationEllipsis = styled("svg", { + color: "currentColor", + stroke: "currentColor", + variants: { + isEllipsis: { + true: { + transform: "0deg", + }, + }, + isBefore: { + true: {}, + }, + }, + compoundVariants: [ + { + // isEllipsis && isBefore + isEllipsis: true, + isBefore: true, + css: { + transform: "rotate(180deg)", + }, + }, + ], +}); + +export const StyledPaginationIcon = styled("svg", { + transform: "rotate(180deg)", + variants: { + isPrev: { + true: { + transform: "rotate(0deg)", + }, + }, + }, +}); + +export const StyledPaginationItemContent = styled("span", { + position: "relative", + display: "inline-flex", + alignItems: "center", + top: 0, + left: 0, + zIndex: "$2", +}); + +export const StyledPaginationItem = styled( + "button", + { + border: "none", + position: "relative", + display: "inline-flex", + margin: "0 $$paginationItemMargin", + ai: "center", + jc: "center", + padding: 0, + boxSizing: "border-box", + tt: "capitalize", + us: "none", + whiteSpace: "nowrap", + ta: "center", + verticalAlign: "middle", + bs: "none", + outline: "none", + height: "$$paginationSize", + minWidth: "$$paginationSize", + fs: "inherit", + cursor: "pointer", + br: "$$paginationItemRadius", + color: "$text", + bg: "$accents0", + "@motion": { + transition: "none", + }, + "&:hover": { + bg: "$accents1", + }, + [`& ${StyledPaginationIcon}`]: { + size: "$$paginationFontSize", + }, + [`& ${StyledPaginationEllipsis}`]: { + size: "$$paginationFontSize", + }, + variants: { + active: { + true: { + fontWeight: "$bold", + cursor: "default", + boxShadow: "$sm", + [`& ${StyledPaginationItemContent}`]: { + color: "$white", + }, + }, + }, + disabled: { + true: { + color: "$accents5", + cursor: "not-allowed", + }, + }, + bordered: { + true: { + bg: "transparent", + border: "$$paginationItemBorderWeight solid $accents2", + }, + }, + onlyDots: { + true: {}, + }, + preserveContent: { + true: {}, + }, + animated: { + true: { + transition: "transform 0.25s ease 0s, background 0.25s ease 0s, box-shadow 0.25s ease 0s", + }, + false: { + transition: "none", + }, + }, + }, + compoundVariants: [ + // onlyDots && !preserveContent + { + onlyDots: true, + preserveContent: false, + css: { + [`& ${StyledPaginationItemContent}`]: { + display: "none", + }, + }, + }, + // animated && !disabled && !active + { + animated: true, + disabled: false, + active: false, + css: { + "&:active": { + transform: "scale($$paginationScaleTransform)", + fs: "calc($$paginationFontSize * 0.9)", + }, + }, + }, + ], + }, + sharedFocus, +); + +export const StyledPaginationHighlight = styled("div", { + position: "absolute", + contain: "strict", + top: "0px", + zIndex: "$1", + bg: "$$paginationColor", + br: "$$paginationItemRadius", + height: "$$paginationSize", + minWidth: "$$paginationSize", + animationName: `${scale}`, + animationDirection: "normal", + "&.nextui-pagination-highlight--moving": { + transform: "scale($$paginationScaleTransform)", + }, + "@motion": { + transition: "none", + "&.nextui-pagination-highlight--moving": { + transform: "scale(1)", + }, + }, + variants: { + animated: { + true: { + animationDuration: "350ms", + animationTimingFunction: "ease", + transition: "left 350ms ease 0s, transform 300ms ease 0s", + }, + false: { + animationDuration: "none", + animationTimingFunction: "none", + transition: "none", + "&.nextui-pagination-highlight--moving": { + transform: "scale(1)", + }, + }, + }, + noMargin: { + true: { + br: "$squared", + }, + }, + rounded: { + true: {}, + }, + shadow: { + true: { + normalShadowVar: "$$paginationShadowColor", + }, + }, + }, + compoundVariants: [ + { + // rounded && noMargin + rounded: true, + noMargin: true, + css: { + br: "$pill", + }, + }, + ], +}); + +export const StyledPagination = styled("nav", { + m: 0, + p: 0, + d: "inline-flex", + position: "relative", + fontVariant: "tabular-nums", + fontFeatureSettings: "tnum", + variants: { + color: { + default: { + $$paginationColor: "$colors$primary", + $$paginationShadowColor: "$colors$primaryShadow", + }, + primary: { + $$paginationColor: "$colors$primary", + $$paginationShadowColor: "$colors$primaryShadow", + }, + secondary: { + $$paginationColor: "$colors$secondary", + $$paginationShadowColor: "$colors$secondaryShadow", + }, + success: { + $$paginationColor: "$colors$success", + $$paginationShadowColor: "$colors$successShadow", + }, + warning: { + $$paginationColor: "$colors$warning", + $$paginationShadowColor: "$colors$warningShadow", + }, + error: { + $$paginationColor: "$colors$error", + $$paginationShadowColor: "$colors$errorShadow", + }, + gradient: { + $$paginationColor: "$colors$gradient", + $$paginationShadowColor: "$colors$primaryShadow", + }, + }, + size: { + xs: { + $$paginationWidth: "$space$10", + $$paginationFontSize: "$space$5", + fs: "$$paginationFontSize", + }, + sm: { + $$paginationWidth: "$space$12", + $$paginationFontSize: "$space$6", + fs: "$$paginationFontSize", + }, + md: { + $$paginationWidth: "$space$13", + $$paginationFontSize: "$space$7", + fs: "$$paginationFontSize", + }, + lg: { + $$paginationWidth: "$space$14", + $$paginationFontSize: "$space$8", + fs: "$$paginationFontSize", + }, + xl: { + $$paginationWidth: "$space$15", + $$paginationFontSize: "$space$9", + fs: "$$paginationFontSize", + }, + }, + borderWeight: { + light: { + $$paginationItemBorderWeight: "$borderWeights$light", + }, + normal: { + $$paginationItemBorderWeight: "$borderWeights$normal", + }, + bold: { + $$paginationItemBorderWeight: "$borderWeights$bold", + }, + extrabold: { + $$paginationItemBorderWeight: "$borderWeights$extrabold", + }, + black: { + $$paginationItemBorderWeight: "$borderWeights$black", + }, + }, + bordered: { + true: {}, + }, + onlyDots: { + true: { + $$paginationSize: "calc($$paginationWidth / 2)", + $$paginationItemRadius: "$radii$pill", + $$paginationScaleTransform: 1.05, + }, + false: { + $$paginationSize: "$$paginationWidth", + $$paginationScaleTransform: 1.1, + }, + }, + rounded: { + true: { + $$paginationItemRadius: "$radii$pill", + }, + false: { + $$paginationItemRadius: "$radii$squared", + }, + }, + noMargin: { + true: { + $$paginationItemRadius: "0px", + $$paginationItemMargin: "0", + [`& ${StyledPaginationItem}:first-of-type`]: { + btlr: "$squared", + bblr: "$squared", + }, + [`& ${StyledPaginationItem}:last-of-type`]: { + btrr: "$squared", + bbrr: "$squared", + }, + }, + false: { + $$paginationItemMargin: "$space$1", + }, + }, + }, + compoundVariants: [ + { + // bordered && noMargin + bordered: true, + noMargin: true, + css: { + [`& ${StyledPaginationItem}:not(:last-child)`]: { + borderRight: 0, + }, + }, + }, + { + // noMargin && rounded + noMargin: true, + rounded: true, + css: { + $$paginationItemRadius: "0px", + }, + }, + { + // !rounded && noMargin + rounded: false, + noMargin: true, + css: { + $$paginationItemRadius: "0px", + }, + }, + ], +}); diff --git a/packages/components/pagination/src/pagination.tsx b/packages/components/pagination/src/pagination.tsx new file mode 100644 index 000000000..874927a91 --- /dev/null +++ b/packages/components/pagination/src/pagination.tsx @@ -0,0 +1,127 @@ +import type {PaginationItemParam} from "@nextui-org/use-pagination"; + +import {useCallback} from "react"; +import {forwardRef} from "@nextui-org/system"; +import {useDOMRef} from "@nextui-org/dom-utils"; +import {DOTS} from "@nextui-org/use-pagination"; +import {clsx, __DEV__} from "@nextui-org/shared-utils"; + +import PaginationItem from "./pagination-item"; +import PaginationEllipsis from "./pagination-ellipsis"; +import PaginationHighlight from "./pagination-highlight"; +import PaginationIcon from "./pagination-icon"; +import {StyledPagination} from "./pagination.styles"; +import {UsePaginationProps, usePagination} from "./use-pagination"; + +export interface PaginationProps extends UsePaginationProps {} + +const Pagination = forwardRef((props, ref) => { + const { + className, + controls, + animated, + rounded, + bordered, + shadow, + onlyDots, + dotsJump, + noMargin, + loop, + total, + range, + active, + setPage, + onPrevious, + onNext, + ...otherProps + } = usePagination(props); + + const domRef = useDOMRef(ref); + + const renderItem = useCallback( + (value: PaginationItemParam, index: number) => { + if (value === DOTS) { + const isBefore = index < range.indexOf(active); + + return ( + + isBefore + ? setPage(active - dotsJump >= 1 ? active - dotsJump : 1) + : setPage(active + dotsJump <= total ? active + dotsJump : total) + } + /> + ); + } + + return ( + value !== active && setPage(value)} + > + {value} + + ); + }, + [total, onlyDots, active, bordered, animated], + ); + + return ( + + {controls && ( + + )} + + {range.map(renderItem)} + {controls && ( + + )} + + ); +}); + +if (__DEV__) { + Pagination.displayName = "NextUI.Pagination"; +} + +Pagination.toString = () => ".nextui-pagination"; + +export default Pagination; diff --git a/packages/components/pagination/src/use-pagination.ts b/packages/components/pagination/src/use-pagination.ts new file mode 100644 index 000000000..e72a3d5de --- /dev/null +++ b/packages/components/pagination/src/use-pagination.ts @@ -0,0 +1,144 @@ +import type {NormalColors, NormalSizes, NormalWeights} from "@nextui-org/shared-utils"; +import type {HTMLNextUIProps} from "@nextui-org/system"; + +import { + usePagination as useBasePagination, + UsePaginationProps as UseBasePaginationProps, +} from "@nextui-org/use-pagination"; + +export interface UsePaginationProps extends HTMLNextUIProps<"nav", UseBasePaginationProps> { + /** + * The pagination color. + * @default "default" + */ + color?: NormalColors; + /** + * The pagination size. + * @default "md" + */ + size?: NormalSizes; + /** + * The border weight of the bordered pagination. + * @default "normal" + */ + borderWeight?: NormalWeights; + /** + * Show only dots as pagination elements + * @default false + */ + onlyDots?: boolean; + /** + * Number of pages that are added or subtracted on the '...' button. + * @default 5 + */ + dotsJump?: number; + /** + * Whether the pagination is bordered. + * @default false + */ + bordered?: boolean; + /** + * Whether the pagination is rounded. + * @default false + */ + rounded?: boolean; + /** + * Whether to show a shadow effect. + * @default false + */ + shadow?: boolean; + /** + * Whether the pagination should have animations. + * @default true + */ + animated?: boolean; + /** + * Non disable next/previous controls + * @default false + */ + loop?: boolean; + /** + * Whether the pagination should have a margin. + * @default false + */ + noMargin?: boolean; + /** + * Whether the pagination should display controls (left/right arrows). + * @default true + */ + controls?: boolean; +} + +export function usePagination(props: UsePaginationProps) { + const { + color = "default", + size = "md", + borderWeight = "normal", + rounded = false, + noMargin = false, + bordered = false, + animated = true, + shadow = false, + onlyDots = false, + controls = true, + dotsJump = 5, + loop = false, + total = 1, + initialPage, + page, + siblings, + boundaries, + onChange, + + ...otherProps + } = props; + + const {range, active, setPage, previous, next, first, last} = useBasePagination({ + page, + initialPage, + siblings: onlyDots ? 10 : siblings, + boundaries: onlyDots ? 10 : boundaries, + total, + onChange, + }); + + const onNext = () => { + if (loop && active === total) { + return first(); + } + + return next(); + }; + + const onPrevious = () => { + if (loop && active === 1) { + return last(); + } + + return previous(); + }; + + return { + color, + size, + bordered, + shadow, + onlyDots, + controls, + dotsJump, + animated, + noMargin, + borderWeight, + loop, + total, + active, + rounded, + range, + setPage, + onNext, + onPrevious, + ...otherProps, + }; +} + +export type UsePaginationReturn = ReturnType; diff --git a/packages/components/pagination/stories/pagination.stories.tsx b/packages/components/pagination/stories/pagination.stories.tsx new file mode 100644 index 000000000..71d8d54b5 --- /dev/null +++ b/packages/components/pagination/stories/pagination.stories.tsx @@ -0,0 +1,161 @@ +import React from "react"; +import {Meta} from "@storybook/react"; +import {Grid} from "@nextui-org/grid"; + +import {Pagination} from "../src"; + +export default { + title: "Navigation/Pagination", + component: Pagination, +} as Meta; + +export const Default = () => ; + +export const Colors = () => ( + <> + + + + + + + + + + + + + + + + + + + +); + +export const Sizes = () => ( + <> + + + + + + + + + + + + + + + + +); + +export const Rounded = () => ( + <> + + + + + + + + + + + + + + + + +); + +export const Bordered = () => ( + <> + + + + + + + +); + +export const Shadow = () => ( + <> + + + + + + + + + + + + + + + + + + + +); + +export const OnlyDots = () => ( + <> + + + + + + + + + + + + + + + + +); + +export const Loop = () => ( + <> + + + + +); + +export const NoMargin = () => ( + <> + + + + +); + +export const NoControls = () => ( + <> + + + + +); + +export const NoAnimated = () => ( + <> + + + + +); diff --git a/packages/components/pagination/tsconfig.json b/packages/components/pagination/tsconfig.json new file mode 100644 index 000000000..80a8d5de0 --- /dev/null +++ b/packages/components/pagination/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "paths": { + "@stitches/react": ["../../../node_modules/@stitches/react"] + } + }, + "include": ["src", "index.ts"] +} diff --git a/packages/components/pagination/tsup.config.ts b/packages/components/pagination/tsup.config.ts new file mode 100644 index 000000000..8bcd44aa9 --- /dev/null +++ b/packages/components/pagination/tsup.config.ts @@ -0,0 +1,13 @@ +import {defineConfig} from "tsup"; +import {findUpSync} from "find-up"; + +export default defineConfig({ + clean: true, + minify: false, + treeshake: true, + format: ["cjs", "esm"], + outExtension(ctx) { + return {js: `.${ctx.format}.js`}; + }, + inject: process.env.JSX ? [findUpSync("react-shim.js")!] : undefined, +}); diff --git a/packages/core/react/package.json b/packages/core/react/package.json index 15c31fc7f..796f80583 100644 --- a/packages/core/react/package.json +++ b/packages/core/react/package.json @@ -58,7 +58,8 @@ "@nextui-org/snippet": "workspace:*", "@nextui-org/spacer": "workspace:*", "@nextui-org/text": "workspace:*", - "@nextui-org/user": "workspace:*" + "@nextui-org/user": "workspace:*", + "@nextui-org/pagination": "workspace:*" }, "peerDependencies": { "react": ">=16.8.0", diff --git a/packages/core/react/src/index.ts b/packages/core/react/src/index.ts index afa9eb031..3380dc528 100644 --- a/packages/core/react/src/index.ts +++ b/packages/core/react/src/index.ts @@ -9,3 +9,4 @@ export * from "@nextui-org/grid"; export * from "@nextui-org/text"; export * from "@nextui-org/link"; export * from "@nextui-org/code"; +export * from "@nextui-org/pagination"; diff --git a/packages/hooks/use-pagination/README.md b/packages/hooks/use-pagination/README.md new file mode 100644 index 000000000..ae918603c --- /dev/null +++ b/packages/hooks/use-pagination/README.md @@ -0,0 +1,24 @@ +# @nextui-org/use-pagination + +A Quick description of the component + +> This is an internal utility, not intended for public usage. + +## Installation + +```sh +yarn add @nextui-org/use-pagination +# or +npm i @nextui-org/use-pagination +``` + +## Contribution + +Yes please! See the +[contributing guidelines](https://github.com/nextui-org/nextui/blob/master/CONTRIBUTING.md) +for details. + +## Licence + +This project is licensed under the terms of the +[MIT license](https://github.com/nextui-org/nextui/blob/master/LICENSE). \ No newline at end of file diff --git a/packages/hooks/use-pagination/__tests__/use-pagination.test.tsx b/packages/hooks/use-pagination/__tests__/use-pagination.test.tsx new file mode 100644 index 000000000..064497429 --- /dev/null +++ b/packages/hooks/use-pagination/__tests__/use-pagination.test.tsx @@ -0,0 +1,102 @@ +import {renderHook, act} from "@testing-library/react-hooks"; + +import {usePagination} from "../src"; + +describe("usePagination", () => { + it("should work correctly", () => { + const {result} = renderHook(() => usePagination({total: 10})); + + act(() => result.current.setPage(5)); + expect(result.current.active).toBe(5); + }); + + it("should return correct initial state", () => { + const {result} = renderHook(() => usePagination({total: 10})); + + expect(result.current.range).toStrictEqual([1, 2, 3, 4, 5, "dots", 10]); + expect(result.current.active).toBe(1); + }); + + it("should not change range length between page changes", () => { + const {result} = renderHook(() => usePagination({total: 10})); + + [...new Array(10)].forEach(() => { + expect(result.current.range.length).toBe(7); + act(() => result.current.next()); + }); + }); + + it("should return the correct initial state with custom parameters", () => { + const {result} = renderHook(() => + usePagination({ + total: 20, + siblings: 2, + boundaries: 2, + initialPage: 7, + }), + ); + + expect(result.current.range).toStrictEqual([1, 2, "dots", 5, 6, 7, 8, 9, "dots", 19, 20]); + expect(result.current.active).toBe(7); + }); + + it("should work correctly with custom siblings", () => { + const {result} = renderHook(() => + usePagination({ + total: 20, + page: 7, + siblings: 2, + }), + ); + + expect(result.current.range).toStrictEqual([1, "dots", 5, 6, 7, 8, 9, "dots", 20]); + expect(result.current.active).toBe(7); + }); + + it("should work correctly without siblings", () => { + const {result} = renderHook(() => + usePagination({ + total: 20, + page: 7, + siblings: 0, + }), + ); + + expect(result.current.range).toStrictEqual([1, "dots", 7, "dots", 20]); + expect(result.current.active).toBe(7); + }); + + it("should work correctly with custom boundaries", () => { + const {result} = renderHook(() => + usePagination({ + total: 20, + page: 7, + boundaries: 2, + }), + ); + + expect(result.current.range).toStrictEqual([1, 2, "dots", 6, 7, 8, "dots", 19, 20]); + expect(result.current.active).toBe(7); + }); + + it("should work correctly without boundaries", () => { + const {result} = renderHook(() => + usePagination({ + total: 20, + page: 7, + boundaries: 0, + }), + ); + + expect(result.current.range).toStrictEqual(["dots", 6, 7, 8, "dots"]); + expect(result.current.active).toBe(7); + }); + + it("should call onChange function correctly", () => { + const onChange = jest.fn(); + const {result} = renderHook(() => usePagination({total: 10, onChange})); + + act(() => result.current.setPage(5)); + expect(onChange).toBeCalledWith(5); + }); +}); diff --git a/packages/hooks/use-pagination/clean-package.config.json b/packages/hooks/use-pagination/clean-package.config.json new file mode 100644 index 000000000..ea9967219 --- /dev/null +++ b/packages/hooks/use-pagination/clean-package.config.json @@ -0,0 +1,3 @@ +{ "replace": { "main": "dist/index.cjs.js", "module": "dist/index.esm.js", +"types": "dist/index.d.ts", "exports": { ".": { "import": "./dist/index.esm.js", +"require": "./dist/index.cjs.js" }, "./package.json": "./package.json" } } } \ No newline at end of file diff --git a/packages/hooks/use-pagination/package.json b/packages/hooks/use-pagination/package.json new file mode 100644 index 000000000..4bf88d5e3 --- /dev/null +++ b/packages/hooks/use-pagination/package.json @@ -0,0 +1,46 @@ +{ + "name": "@nextui-org/use-pagination", + "version": "1.0.0-beta.11", + "description": "State management hook for Pagination component, it lets you manage pagination with controlled and uncontrolled state", + "keywords": [ + "use-pagination" + ], + "author": "Junior Garcia ", + "homepage": "https://nextui.org", + "license": "MIT", + "main": "src/index.ts", + "sideEffects": false, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nextui-org/nextui.git", + "directory": "packages/hooks/use-pagination" + }, + "bugs": { + "url": "https://github.com/nextui-org/nextui/issues" + }, + "scripts": { + "build": "tsup src/index.ts --format=esm,cjs --dts", + "dev": "yarn build:fast -- --watch", + "clean": "rimraf dist .turbo", + "typecheck": "tsc --noEmit", + "build:fast": "tsup src/index.ts --format=esm,cjs", + "prepack": "clean-package", + "postpack": "clean-package restore" + }, + "dependencies": { + "@nextui-org/shared-utils": "workspace:*" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "devDependencies": { + "clean-package": "2.1.1", + "react": "^17.0.2" + } +} diff --git a/packages/hooks/use-pagination/src/index.ts b/packages/hooks/use-pagination/src/index.ts new file mode 100644 index 000000000..db388b2e5 --- /dev/null +++ b/packages/hooks/use-pagination/src/index.ts @@ -0,0 +1,119 @@ +import {useMemo, useCallback, useState, useEffect} from "react"; +import {range} from "@nextui-org/shared-utils"; + +export interface UsePaginationProps { + /** + * The total number of pages. + */ + total: number; + /** + * The selected page on initial render. + * @default 1 + */ + initialPage?: number; + /** + * The controlled selected page. + */ + page?: number; + /** + * The number of pages to show on each side of the current page. + * @default 1 + */ + siblings?: number; + /** + * The number of pages to show at the beginning and end of the pagination. + * @default 1 + */ + boundaries?: number; + /** + * Callback fired when the page changes. + */ + onChange?: (page: number) => void; +} + +export const DOTS = "dots"; +export type PaginationItemParam = number | typeof DOTS; + +export function usePagination(props: UsePaginationProps) { + const {page, total, siblings = 1, boundaries = 1, initialPage = 1, onChange} = props; + const [activePage, setActivePage] = useState(page || initialPage); + + const onChangeActivePage = (newPage: number) => { + setActivePage(newPage); + onChange && onChange(newPage); + }; + + useEffect(() => { + if (page && page !== activePage) { + setActivePage(page); + } + }, [page]); + + const setPage = useCallback( + (pageNumber: number) => { + if (pageNumber <= 0) { + onChangeActivePage(1); + } else if (pageNumber > total) { + onChangeActivePage(total); + } else { + onChangeActivePage(pageNumber); + } + }, + [total, activePage], + ); + + const next = () => setPage(activePage + 1); + const previous = () => setPage(activePage - 1); + const first = () => setPage(1); + const last = () => setPage(total); + + const paginationRange = useMemo((): PaginationItemParam[] => { + const totalPageNumbers = siblings * 2 + 3 + boundaries * 2; + + if (totalPageNumbers >= total) { + return range(1, total); + } + const leftSiblingIndex = Math.max(activePage - siblings, boundaries); + const rightSiblingIndex = Math.min(activePage + siblings, total - boundaries); + + /* + * We do not want to show dots if there is only one position left + * after/before the left/right page count as that would lead to a change if our Pagination + * component size which we do not want + */ + const shouldShowLeftDots = leftSiblingIndex > boundaries + 2; + const shouldShowRightDots = rightSiblingIndex < total - (boundaries + 1); + + if (!shouldShowLeftDots && shouldShowRightDots) { + const leftItemCount = siblings * 2 + boundaries + 2; + + return [...range(1, leftItemCount), DOTS, ...range(total - (boundaries - 1), total)]; + } + + if (shouldShowLeftDots && !shouldShowRightDots) { + const rightItemCount = boundaries + 1 + 2 * siblings; + + return [...range(1, boundaries), DOTS, ...range(total - rightItemCount, total)]; + } + + return [ + ...range(1, boundaries), + DOTS, + ...range(leftSiblingIndex, rightSiblingIndex), + DOTS, + ...range(total - boundaries + 1, total), + ]; + }, [total, siblings, activePage]); + + return { + range: paginationRange, + active: activePage, + setPage, + next, + previous, + first, + last, + }; +} + +export type UsePaginationReturn = ReturnType; diff --git a/packages/hooks/use-pagination/tsconfig.json b/packages/hooks/use-pagination/tsconfig.json new file mode 100644 index 000000000..46e3b466c --- /dev/null +++ b/packages/hooks/use-pagination/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src", "index.ts"] +} diff --git a/packages/hooks/use-pagination/tsup.config.ts b/packages/hooks/use-pagination/tsup.config.ts new file mode 100644 index 000000000..8bcd44aa9 --- /dev/null +++ b/packages/hooks/use-pagination/tsup.config.ts @@ -0,0 +1,13 @@ +import {defineConfig} from "tsup"; +import {findUpSync} from "find-up"; + +export default defineConfig({ + clean: true, + minify: false, + treeshake: true, + format: ["cjs", "esm"], + outExtension(ctx) { + return {js: `.${ctx.format}.js`}; + }, + inject: process.env.JSX ? [findUpSync("react-shim.js")!] : undefined, +}); diff --git a/packages/utilities/shared-utils/src/index.ts b/packages/utilities/shared-utils/src/index.ts index 0f221e6cc..441e9ec01 100644 --- a/packages/utilities/shared-utils/src/index.ts +++ b/packages/utilities/shared-utils/src/index.ts @@ -11,3 +11,4 @@ export * from "./dimensions"; export * from "./css-transition"; export * from "./functions"; export * from "./context"; +export * from "./numbers"; diff --git a/packages/utilities/shared-utils/src/numbers.ts b/packages/utilities/shared-utils/src/numbers.ts new file mode 100644 index 000000000..a3c680ba1 --- /dev/null +++ b/packages/utilities/shared-utils/src/numbers.ts @@ -0,0 +1,11 @@ +/** + * Returns an array of numbers, starting at `start` and ending at `end`. + * @param start number + * @param end number + * @returns number[] + */ +export function range(start: number, end: number) { + const length = end - start + 1; + + return Array.from({length}, (_, index) => index + start); +} diff --git a/plop/hook/src/index.ts.hbs b/plop/hook/src/index.ts.hbs index 9d352759d..73b8d424e 100644 --- a/plop/hook/src/index.ts.hbs +++ b/plop/hook/src/index.ts.hbs @@ -1,7 +1,7 @@ -export interface Use{{capitalize hookName}}Props {} +export interface {{capitalize hookName}}Props {} -export function {{camelCase hookName}}(props: Use{{capitalize hookName}}Props = {}) { +export function {{camelCase hookName}}(props: {{capitalize hookName}}Props = {}) { const {...otherProps} = props; return {...otherProps}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af8255b2b..1907f6930 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -637,6 +637,29 @@ importers: clean-package: 2.1.1 react: 17.0.2 + packages/components/pagination: + specifiers: + '@nextui-org/dom-utils': workspace:* + '@nextui-org/grid': workspace:* + '@nextui-org/shared-css': workspace:* + '@nextui-org/shared-utils': workspace:* + '@nextui-org/system': workspace:* + '@nextui-org/use-pagination': workspace:* + '@react-aria/utils': ^3.14.0 + clean-package: 2.1.1 + react: ^17.0.2 + dependencies: + '@nextui-org/dom-utils': link:../../utilities/dom-utils + '@nextui-org/grid': link:../grid + '@nextui-org/shared-css': link:../../utilities/shared-css + '@nextui-org/shared-utils': link:../../utilities/shared-utils + '@nextui-org/system': link:../../core/system + '@nextui-org/use-pagination': link:../../hooks/use-pagination + '@react-aria/utils': 3.14.0_react@17.0.2 + devDependencies: + clean-package: 2.1.1 + react: 17.0.2 + packages/components/row: specifiers: '@nextui-org/dom-utils': workspace:* @@ -742,6 +765,7 @@ importers: '@nextui-org/image': workspace:* '@nextui-org/link': workspace:* '@nextui-org/loading': workspace:* + '@nextui-org/pagination': workspace:* '@nextui-org/row': workspace:* '@nextui-org/snippet': workspace:* '@nextui-org/spacer': workspace:* @@ -766,6 +790,7 @@ importers: '@nextui-org/image': link:../../components/image '@nextui-org/link': link:../../components/link '@nextui-org/loading': link:../../components/loading + '@nextui-org/pagination': link:../../components/pagination '@nextui-org/row': link:../../components/row '@nextui-org/snippet': link:../../components/snippet '@nextui-org/spacer': link:../../components/spacer @@ -804,6 +829,17 @@ importers: clean-package: 2.1.1 react: 17.0.2 + packages/hooks/use-pagination: + specifiers: + '@nextui-org/shared-utils': workspace:* + clean-package: 2.1.1 + react: ^17.0.2 + dependencies: + '@nextui-org/shared-utils': link:../../utilities/shared-utils + devDependencies: + clean-package: 2.1.1 + react: 17.0.2 + packages/hooks/use-real-shape: specifiers: '@nextui-org/dom-utils': workspace:*