From ad6ba3a98b4475e47fe66822fcacd98e23cc6e12 Mon Sep 17 00:00:00 2001 From: Abdoulaye NDOYE <46305144+NDOY3M4N@users.noreply.github.com> Date: Thu, 7 Dec 2023 23:37:31 +0000 Subject: [PATCH] feat: add pagination components (#223) Co-authored-by: Hunter Johnston Co-authored-by: Hunter Johnston <64506580+huntabyte@users.noreply.github.com> --- .changeset/breezy-coins-smell.md | 5 + content/components/pagination.md | 33 ++++++ src/components/demos/index.ts | 1 + src/components/demos/pagination-demo.svelte | 36 +++++++ src/config/navigation.ts | 5 + .../api-reference/extended-types/index.ts | 6 ++ .../api-reference/extended-types/page-item.ts | 14 +++ src/content/api-reference/helpers.ts | 16 ++- src/content/api-reference/index.ts | 3 + src/content/api-reference/pagination.ts | 102 ++++++++++++++++++ src/lib/bits/index.ts | 1 + src/lib/bits/pagination/_export.types.ts | 11 ++ src/lib/bits/pagination/_types.ts | 36 +++++++ .../components/pagination-next-button.svelte | 35 ++++++ .../components/pagination-page.svelte | 38 +++++++ .../components/pagination-prev-button.svelte | 35 ++++++ .../pagination/components/pagination.svelte | 45 ++++++++ src/lib/bits/pagination/ctx.ts | 28 +++++ src/lib/bits/pagination/index.ts | 6 ++ src/lib/bits/pagination/types.ts | 41 +++++++ src/lib/shared/index.ts | 4 +- src/lib/types.ts | 1 + src/tests/pagination/Pagination.spec.ts | 74 +++++++++++++ src/tests/pagination/PaginationTest.svelte | 37 +++++++ 24 files changed, 610 insertions(+), 3 deletions(-) create mode 100644 .changeset/breezy-coins-smell.md create mode 100644 content/components/pagination.md create mode 100644 src/components/demos/pagination-demo.svelte create mode 100644 src/content/api-reference/extended-types/page-item.ts create mode 100644 src/content/api-reference/pagination.ts create mode 100644 src/lib/bits/pagination/_export.types.ts create mode 100644 src/lib/bits/pagination/_types.ts create mode 100644 src/lib/bits/pagination/components/pagination-next-button.svelte create mode 100644 src/lib/bits/pagination/components/pagination-page.svelte create mode 100644 src/lib/bits/pagination/components/pagination-prev-button.svelte create mode 100644 src/lib/bits/pagination/components/pagination.svelte create mode 100644 src/lib/bits/pagination/ctx.ts create mode 100644 src/lib/bits/pagination/index.ts create mode 100644 src/lib/bits/pagination/types.ts create mode 100644 src/tests/pagination/Pagination.spec.ts create mode 100644 src/tests/pagination/PaginationTest.svelte diff --git a/.changeset/breezy-coins-smell.md b/.changeset/breezy-coins-smell.md new file mode 100644 index 000000000..ba74332fd --- /dev/null +++ b/.changeset/breezy-coins-smell.md @@ -0,0 +1,5 @@ +--- +"bits-ui": patch +--- + +feat: add `Pagination` component diff --git a/content/components/pagination.md b/content/components/pagination.md new file mode 100644 index 000000000..2c15f9b1a --- /dev/null +++ b/content/components/pagination.md @@ -0,0 +1,33 @@ +--- +title: Pagination +description: Facilitates navigation between pages. +--- + + + + + + + + + +## Structure + +```svelte + + + + + {#each pages as page (page.key)} + + {/each} + + +``` + + diff --git a/src/components/demos/index.ts b/src/components/demos/index.ts index 45bc67dc7..8ed9dae6f 100644 --- a/src/components/demos/index.ts +++ b/src/components/demos/index.ts @@ -16,6 +16,7 @@ export { default as DropdownMenuDemo } from "./dropdown-menu-demo.svelte"; export { default as LabelDemo } from "./label-demo.svelte"; export { default as LinkPreviewDemo } from "./link-preview-demo.svelte"; export { default as MenubarDemo } from "./menubar-demo.svelte"; +export { default as PaginationDemo } from "./pagination-demo.svelte"; export { default as PopoverDemo } from "./popover-demo.svelte"; export { default as ProgressDemo } from "./progress-demo.svelte"; export { default as RadioGroupDemo } from "./radio-group-demo.svelte"; diff --git a/src/components/demos/pagination-demo.svelte b/src/components/demos/pagination-demo.svelte new file mode 100644 index 000000000..99baaad53 --- /dev/null +++ b/src/components/demos/pagination-demo.svelte @@ -0,0 +1,36 @@ + + + +

+ Showing {range.start} - {range.end} +

+
+ + + + {#each pages as page (page.key)} + {#if page.type === "ellipsis"} +
+ +
+ {:else} + + {page.value} + + {/if} + {/each} + + + +
+
diff --git a/src/config/navigation.ts b/src/config/navigation.ts index 661782a13..0f1104823 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -145,6 +145,11 @@ export const navigation: Navigation = { href: "/docs/components/menubar", items: [] }, + { + title: "Pagination", + href: "/docs/components/pagination", + items: [] + }, { title: "Popover", href: "/docs/components/popover", diff --git a/src/content/api-reference/extended-types/index.ts b/src/content/api-reference/extended-types/index.ts index 58b4ca8fb..4bd847774 100644 --- a/src/content/api-reference/extended-types/index.ts +++ b/src/content/api-reference/extended-types/index.ts @@ -1,6 +1,7 @@ import type { PropType } from "@/types"; import rawFocusProp from "@/content/api-reference/extended-types/focus-target.js?raw"; import rawMonthProp from "@/content/api-reference/extended-types/months.js?raw"; +import rawPageItemProp from "@/content/api-reference/extended-types/page-item.js?raw"; export const monthsPropType: PropType = { type: "Month[]", @@ -11,3 +12,8 @@ export const focusProp: PropType = { type: "FocusProp", definition: rawFocusProp }; + +export const pageItemProp: PropType = { + type: "PageItem", + definition: rawPageItemProp +}; diff --git a/src/content/api-reference/extended-types/page-item.ts b/src/content/api-reference/extended-types/page-item.ts new file mode 100644 index 000000000..f7f7e9518 --- /dev/null +++ b/src/content/api-reference/extended-types/page-item.ts @@ -0,0 +1,14 @@ +export type Page = { + type: "page"; + /** The page number the `PageItem` represents */ + value: number; +}; + +export type Ellipsis = { + type: "ellipsis"; +}; + +export type PageItem = (Page | Ellipsis) & { + /** Unique key for the item, for svelte #each block */ + key: string; +}; diff --git a/src/content/api-reference/helpers.ts b/src/content/api-reference/helpers.ts index 1a4b02639..abf4ea1d4 100644 --- a/src/content/api-reference/helpers.ts +++ b/src/content/api-reference/helpers.ts @@ -92,7 +92,7 @@ export function portalProp(compName = "content") { } export function union(...types: string[]): string { - return types.join(" | ").replaceAll("<", "<").replaceAll(">", ">"); + return escape(types.join(" | ")); } export function enums(...values: string[]): string { @@ -102,3 +102,17 @@ export function enums(...values: string[]): string { export function seeFloating(content: string, link: string) { return `${content} [Floating UI reference](${link}).`; } + +const entities = [ + [//g, ">"], + [/{/g, "{"], + [/}/g, "}"] +] as const; + +export function escape(str: string): string { + for (let i = 0; i < entities.length; i += 1) { + str = str.replace(entities[i][0], entities[i][1]); + } + return str; +} diff --git a/src/content/api-reference/index.ts b/src/content/api-reference/index.ts index 91b6ee055..86550c219 100644 --- a/src/content/api-reference/index.ts +++ b/src/content/api-reference/index.ts @@ -17,6 +17,7 @@ import { dropdownMenu } from "./dropdown-menu"; import { label } from "./label"; import { linkPreview } from "./link-preview"; import { popover } from "./popover"; +import { pagination } from "./pagination"; import { menubar } from "./menubar"; import { progress } from "./progress"; import { radioGroup } from "./radio-group"; @@ -50,6 +51,7 @@ export const bits = [ "link-preview", "label", "menubar", + "pagination", "popover", "progress", "radio-group", @@ -92,6 +94,7 @@ export const apiSchemas: Record = { label, "link-preview": linkPreview, menubar, + pagination, popover, progress, "radio-group": radioGroup, diff --git a/src/content/api-reference/pagination.ts b/src/content/api-reference/pagination.ts new file mode 100644 index 000000000..e79225e9b --- /dev/null +++ b/src/content/api-reference/pagination.ts @@ -0,0 +1,102 @@ +import type { APISchema } from "@/types"; +import type * as Pagination from "$lib/bits/pagination/_types.js"; +import * as C from "@/content/constants.js"; +import { asChild } from "@/content/api-reference/helpers.js"; +import { builderAndAttrsSlotProps } from "./helpers"; +import { pageItemProp } from "./extended-types"; + +export const root: APISchema = { + title: "Root", + description: "The root pagination component which contains all other pagination components.", + props: { + count: { + type: C.NUMBER, + description: "The total number of items.", + required: true + }, + perPage: { + type: C.NUMBER, + description: "The number of items per page.", + default: "1" + }, + siblingCount: { + type: C.NUMBER, + description: "The number of page triggers to show on either side of the current page.", + default: "1" + }, + page: { + type: C.NUMBER, + description: + "The selected page. You can bind this to a variable to control the selected page from outside the component." + }, + onPageChange: { + type: { + type: C.FUNCTION, + definition: "(page: number) => void" + }, + description: "A function called when the selected page changes." + }, + asChild + } +}; + +export const page: APISchema = { + title: "Page", + description: "A button that triggers a page change.", + props: { + page: { + type: pageItemProp, + description: "The page item this component represents." + }, + asChild + }, + slotProps: { + ...builderAndAttrsSlotProps + }, + dataAttributes: [ + { + name: "selected", + description: "Present on the current page element." + }, + { + name: "pagination-page", + description: "Present on the page trigger element." + } + ] +}; + +export const prevButton: APISchema = { + title: "PrevButton", + description: "The previous button of the pagination.", + props: { + asChild + }, + slotProps: { + ...builderAndAttrsSlotProps + }, + dataAttributes: [ + { + name: "pagination-prev-button", + description: "Present on the previous button element." + } + ] +}; + +export const nextButton: APISchema = { + title: "NextButton", + description: "The next button of the pagination.", + props: { + asChild + }, + slotProps: { + ...builderAndAttrsSlotProps + }, + dataAttributes: [ + { + name: "pagination-next-button", + description: "Present on the next button element." + } + ] +}; + +export const pagination = [root, page, prevButton, nextButton]; diff --git a/src/lib/bits/index.ts b/src/lib/bits/index.ts index 8da939033..e7ab00e63 100644 --- a/src/lib/bits/index.ts +++ b/src/lib/bits/index.ts @@ -16,6 +16,7 @@ export * as DropdownMenu from "./dropdown-menu/index.js"; export * as LinkPreview from "./link-preview/index.js"; export * as Label from "./label/index.js"; export * as Menubar from "./menubar/index.js"; +export * as Pagination from "./pagination/index.js"; export * as Popover from "./popover/index.js"; export * as Progress from "./progress/index.js"; export * as RadioGroup from "./radio-group/index.js"; diff --git a/src/lib/bits/pagination/_export.types.ts b/src/lib/bits/pagination/_export.types.ts new file mode 100644 index 000000000..d280eac57 --- /dev/null +++ b/src/lib/bits/pagination/_export.types.ts @@ -0,0 +1,11 @@ +export type { + Props as PaginationProps, + PrevButtonProps as PaginationPrevButtonProps, + NextButtonProps as PaginationNextButtonProps, + PageProps as PaginationPageProps, + // + Events as PaginationEvents, + PrevButtonEvents as PaginationPrevButtonEvents, + NextButtonEvents as PaginationNextButtonEvents, + PageEvents as PaginationPageEvents +} from "./types.js"; diff --git a/src/lib/bits/pagination/_types.ts b/src/lib/bits/pagination/_types.ts new file mode 100644 index 000000000..e1fa5994b --- /dev/null +++ b/src/lib/bits/pagination/_types.ts @@ -0,0 +1,36 @@ +/** + * We define prop types without the HTMLAttributes here so that we can use them + * to type-check our API documentation, which requires we document each prop, + * but we don't want to document the HTML attributes. + */ + +import type { AsChild, Expand, OnChangeFn } from "$lib/internal"; +import type { CreatePaginationProps, Page } from "@melt-ui/svelte"; + +type OmitPaginationProps = Omit; + +type Props = Expand< + OmitPaginationProps & { + /** + * The selected page. This updates as the users selects new pages. + * + * You can bind this to a value to programmatically control the value state. + */ + page?: number; + + /** + * A callback function called when the page changes. + */ + onPageChange?: OnChangeFn; + } & AsChild +>; + +type PageProps = { + page: Page; +} & AsChild; + +type PrevButtonProps = AsChild; + +type NextButtonProps = AsChild; + +export type { Props, PrevButtonProps, NextButtonProps, PageProps }; diff --git a/src/lib/bits/pagination/components/pagination-next-button.svelte b/src/lib/bits/pagination/components/pagination-next-button.svelte new file mode 100644 index 000000000..08638f930 --- /dev/null +++ b/src/lib/bits/pagination/components/pagination-next-button.svelte @@ -0,0 +1,35 @@ + + +{#if asChild} + +{:else} + +{/if} diff --git a/src/lib/bits/pagination/components/pagination-page.svelte b/src/lib/bits/pagination/components/pagination-page.svelte new file mode 100644 index 000000000..1d67ec7b8 --- /dev/null +++ b/src/lib/bits/pagination/components/pagination-page.svelte @@ -0,0 +1,38 @@ + + +{#if asChild} + +{:else} + +{/if} diff --git a/src/lib/bits/pagination/components/pagination-prev-button.svelte b/src/lib/bits/pagination/components/pagination-prev-button.svelte new file mode 100644 index 000000000..a2dbaa227 --- /dev/null +++ b/src/lib/bits/pagination/components/pagination-prev-button.svelte @@ -0,0 +1,35 @@ + + +{#if asChild} + +{:else} + +{/if} diff --git a/src/lib/bits/pagination/components/pagination.svelte b/src/lib/bits/pagination/components/pagination.svelte new file mode 100644 index 000000000..df86087c9 --- /dev/null +++ b/src/lib/bits/pagination/components/pagination.svelte @@ -0,0 +1,45 @@ + + +{#if asChild} + +{:else} +
+ +
+{/if} diff --git a/src/lib/bits/pagination/ctx.ts b/src/lib/bits/pagination/ctx.ts new file mode 100644 index 000000000..97b66cb1b --- /dev/null +++ b/src/lib/bits/pagination/ctx.ts @@ -0,0 +1,28 @@ +import { + createPagination, + type CreatePaginationProps, + type Pagination as PaginationReturn +} from "@melt-ui/svelte"; +import { getContext, setContext } from "svelte"; +import { createBitAttrs, getOptionUpdater, removeUndefined } from "$lib/internal"; + +const NAME = "pagination"; +const PARTS = ["root", "prev-button", "next-button", "page"] as const; + +export const getAttrs = createBitAttrs(NAME, PARTS); + +type GetReturn = PaginationReturn; + +export function setCtx(props: CreatePaginationProps) { + const pagination = createPagination(removeUndefined(props)); + setContext(NAME, pagination); + + return { + ...pagination, + updateOption: getOptionUpdater(pagination.options) + }; +} + +export function getCtx() { + return getContext(NAME); +} diff --git a/src/lib/bits/pagination/index.ts b/src/lib/bits/pagination/index.ts new file mode 100644 index 000000000..ac6a6eb16 --- /dev/null +++ b/src/lib/bits/pagination/index.ts @@ -0,0 +1,6 @@ +export { default as Root } from "./components/pagination.svelte"; +export { default as PrevButton } from "./components/pagination-prev-button.svelte"; +export { default as NextButton } from "./components/pagination-next-button.svelte"; +export { default as Page } from "./components/pagination-page.svelte"; + +export * from "./types.js"; diff --git a/src/lib/bits/pagination/types.ts b/src/lib/bits/pagination/types.ts new file mode 100644 index 000000000..ffe042b15 --- /dev/null +++ b/src/lib/bits/pagination/types.ts @@ -0,0 +1,41 @@ +import type { HTMLButtonAttributes } from "svelte/elements"; +import type { CustomEventHandler } from "$lib"; +import type * as I from "./_types.js"; +import type { HTMLDivAttributes } from "$lib/internal/types.js"; + +type Props = I.Props & HTMLDivAttributes; + +type PrevButtonProps = I.PrevButtonProps & HTMLButtonAttributes; + +type NextButtonProps = I.NextButtonProps & HTMLButtonAttributes; + +type PageProps = I.PageProps & HTMLButtonAttributes; + +/** + * Events + */ +type ButtonEvents = { + click: CustomEventHandler; +}; + +type PrevButtonEvents = ButtonEvents; + +type NextButtonEvents = ButtonEvents; + +type PageEvents = ButtonEvents; + +type Events = { + keydown: CustomEventHandler; +}; + +export type { + Props, + PageProps, + PrevButtonProps, + NextButtonProps, + // + Events, + PageEvents, + PrevButtonEvents, + NextButtonEvents +}; diff --git a/src/lib/shared/index.ts b/src/lib/shared/index.ts index 88fda7420..2e9044ac5 100644 --- a/src/lib/shared/index.ts +++ b/src/lib/shared/index.ts @@ -1,5 +1,5 @@ import type { DateValue } from "@internationalized/date"; -import type { Month } from "@melt-ui/svelte"; +import type { Month, Page, PageItem, Ellipsis } from "@melt-ui/svelte"; export type Selected = { value: Value; @@ -22,4 +22,4 @@ export type SegmentPart = | "timeZoneName" | "literal"; -export type { Month }; +export type { Month, Page, PageItem, Ellipsis }; diff --git a/src/lib/types.ts b/src/lib/types.ts index b8de9884a..f2f85456f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -17,6 +17,7 @@ export type * from "$lib/bits/dropdown-menu/_export.types.js"; export type * from "$lib/bits/label/_export.types.js"; export type * from "$lib/bits/link-preview/_export.types.js"; export type * from "$lib/bits/menubar/_export.types.js"; +export type * from "$lib/bits/pagination/_export.types.js"; export type * from "$lib/bits/popover/_export.types.js"; export type * from "$lib/bits/progress/_export.types.js"; export type * from "$lib/bits/radio-group/_export.types.js"; diff --git a/src/tests/pagination/Pagination.spec.ts b/src/tests/pagination/Pagination.spec.ts new file mode 100644 index 000000000..0f44f381c --- /dev/null +++ b/src/tests/pagination/Pagination.spec.ts @@ -0,0 +1,74 @@ +// NOTE: these tests were shamelessly copied from melt-ui 🥲 +import { render } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { axe } from "jest-axe"; +import type { Pagination } from "$lib"; +import PaginationTest from "./PaginationTest.svelte"; +import { isHTMLElement } from "@melt-ui/svelte/internal/helpers"; + +function setup(props: Pagination.Props = { count: 100 }) { + const user = userEvent.setup(); + const returned = render(PaginationTest, { ...props }); + + const root = returned.getByTestId("root"); + const range = returned.getByTestId("range"); + const prev = returned.getByTestId("prev-button"); + const next = returned.getByTestId("next-button"); + + return { + root, + range, + prev, + next, + user, + ...returned + }; +} + +function getPageButton(el: HTMLElement, page: number) { + const btn = el.querySelector(`[data-value="${page}"]`); + if (!isHTMLElement(btn)) { + throw new Error(`Page button ${page} not found`); + } + return btn; +} + +function getValue(el: HTMLElement) { + return el.querySelector("[data-selected]")?.getAttribute("data-value"); +} + +describe("Pagination", () => { + test("No accessibility violations", async () => { + const { container } = render(PaginationTest); + expect(await axe(container)).toHaveNoViolations(); + }); + + test("Previous and Next button should work accordingly", async () => { + const { root, prev, next } = setup(); + + await expect(getValue(root)).toBe("1"); + await prev.click(); + await expect(getValue(root)).toBe("1"); + await next.click(); + await expect(getValue(root)).toBe("2"); + await next.click(); + await expect(getValue(root)).toBe("3"); + await prev.click(); + await expect(getValue(root)).toBe("2"); + }); + + test("Should change on clicked button", async () => { + const { getByTestId } = await render(PaginationTest); + + const root = getByTestId("root"); + const page2 = getPageButton(root, 2); + + await expect(getValue(root)).toBe("1"); + await page2.click(); + await expect(getValue(root)).toBe("2"); + + const page10 = getPageButton(root, 10); + await page10.click(); + await expect(getValue(root)).toBe("10"); + }); +}); diff --git a/src/tests/pagination/PaginationTest.svelte b/src/tests/pagination/PaginationTest.svelte new file mode 100644 index 000000000..e7c244038 --- /dev/null +++ b/src/tests/pagination/PaginationTest.svelte @@ -0,0 +1,37 @@ + + +
+ +

Showing items {range.start} - {range.end}

+
+ + + + {#each pages as page (page.key)} + {#if page.type === "ellipsis"} + ... + {:else if page.type === "page"} + + {page.value} + + {/if} + {/each} + + →; + +
+
+