UNPKG

bits-ui

Version:

The headless components for Svelte.

265 lines (264 loc) 8.49 kB
import { attachRef } from "svelte-toolbelt"; import { Context } from "runed"; import { createBitsAttrs, getDataOrientation } from "../../internal/attrs.js"; import { getElemDirection } from "../../internal/locale.js"; import { kbd } from "../../internal/kbd.js"; import { getDirectionalKeys } from "../../internal/get-directional-keys.js"; import { useId } from "../../shared/index.js"; const paginationAttrs = createBitsAttrs({ component: "pagination", parts: ["root", "page", "prev", "next"], }); const PaginationRootContext = new Context("Pagination.Root"); export class PaginationRootState { static create(opts) { return PaginationRootContext.set(new PaginationRootState(opts)); } opts; attachment; totalPages = $derived.by(() => { if (this.opts.count.current === 0) return 1; return Math.ceil(this.opts.count.current / this.opts.perPage.current); }); range = $derived.by(() => { const start = (this.opts.page.current - 1) * this.opts.perPage.current; const end = Math.min(start + this.opts.perPage.current, this.opts.count.current); return { start: start + 1, end }; }); pages = $derived.by(() => getPageItems({ page: this.opts.page.current, totalPages: this.totalPages, siblingCount: this.opts.siblingCount.current, })); hasPrevPage = $derived.by(() => this.opts.page.current > 1); hasNextPage = $derived.by(() => this.opts.page.current < this.totalPages); constructor(opts) { this.opts = opts; this.attachment = attachRef(this.opts.ref); } setPage(page) { this.opts.page.current = page; } getPageTriggerNodes() { const node = this.opts.ref.current; if (!node) return []; return Array.from(node.querySelectorAll("[data-pagination-page]")); } getButtonNode(type) { const node = this.opts.ref.current; if (!node) return; return node.querySelector(paginationAttrs.selector(type)); } prevPage() { this.opts.page.current = Math.max(this.opts.page.current - 1, 1); } nextPage() { this.opts.page.current = Math.min(this.opts.page.current + 1, this.totalPages); } snippetProps = $derived.by(() => ({ pages: this.pages, range: this.range, currentPage: this.opts.page.current, })); props = $derived.by(() => ({ id: this.opts.id.current, "data-orientation": getDataOrientation(this.opts.orientation.current), [paginationAttrs.root]: "", ...this.attachment, })); } export class PaginationPageState { static create(opts) { return new PaginationPageState(opts, PaginationRootContext.get()); } opts; root; attachment; #isSelected = $derived.by(() => this.opts.page.current.value === this.root.opts.page.current); constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref); this.onclick = this.onclick.bind(this); this.onkeydown = this.onkeydown.bind(this); } onclick(e) { if (this.opts.disabled.current) return; if (e.button !== 0) return; this.root.setPage(this.opts.page.current.value); } onkeydown(e) { if (e.key === kbd.SPACE || e.key === kbd.ENTER) { e.preventDefault(); this.root.setPage(this.opts.page.current.value); } else { handleTriggerKeydown(e, this.opts.ref.current, this.root); } } props = $derived.by(() => ({ id: this.opts.id.current, "aria-label": `Page ${this.opts.page.current.value}`, "data-value": `${this.opts.page.current.value}`, "data-selected": this.#isSelected ? "" : undefined, [paginationAttrs.page]: "", // onclick: this.onclick, onkeydown: this.onkeydown, ...this.attachment, })); } export class PaginationButtonState { static create(opts) { return new PaginationButtonState(opts, PaginationRootContext.get()); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref); this.onclick = this.onclick.bind(this); this.onkeydown = this.onkeydown.bind(this); } #action() { this.opts.type === "prev" ? this.root.prevPage() : this.root.nextPage(); } #isDisabled = $derived.by(() => { if (this.opts.disabled.current) return true; if (this.opts.type === "prev") return !this.root.hasPrevPage; if (this.opts.type === "next") return !this.root.hasNextPage; return false; }); onclick(e) { if (this.opts.disabled.current) return; if (e.button !== 0) return; this.#action(); } onkeydown(e) { if (e.key === kbd.SPACE || e.key === kbd.ENTER) { e.preventDefault(); this.#action(); } else { handleTriggerKeydown(e, this.opts.ref.current, this.root); } } props = $derived.by(() => ({ id: this.opts.id.current, [paginationAttrs[this.opts.type]]: "", disabled: this.#isDisabled, // onclick: this.onclick, onkeydown: this.onkeydown, ...this.attachment, })); } // // HELPERS // /** * Shared logic for handling keyboard navigation on * pagination page triggers and prev/next buttons. * * * @param e - KeyboardEvent * @param node - The HTMLElement that triggered the event. * @param root - The root pagination state instance */ function handleTriggerKeydown(e, node, root) { if (!node || !root.opts.ref.current) return; const items = root.getPageTriggerNodes(); const nextButton = root.getButtonNode("next"); const prevButton = root.getButtonNode("prev"); if (prevButton) { items.unshift(prevButton); } if (nextButton) { items.push(nextButton); } const currentIndex = items.indexOf(node); const dir = getElemDirection(root.opts.ref.current); const { nextKey, prevKey } = getDirectionalKeys(dir, root.opts.orientation.current); const loop = root.opts.loop.current; const keyToIndex = { [nextKey]: currentIndex + 1, [prevKey]: currentIndex - 1, [kbd.HOME]: 0, [kbd.END]: items.length - 1, }; let itemIndex = keyToIndex[e.key]; if (itemIndex === undefined) return; e.preventDefault(); if (itemIndex < 0 && loop) { itemIndex = items.length - 1; } else if (itemIndex === items.length && loop) { itemIndex = 0; } const itemToFocus = items[itemIndex]; if (!itemToFocus) return; itemToFocus.focus(); } /** * Returns an array of page items used to render out the * pagination page triggers. * * Credit: https://github.com/melt-ui/melt-ui */ function getPageItems({ page = 1, totalPages, siblingCount = 1 }) { const pageItems = []; const pagesToShow = new Set([1, totalPages]); const firstItemWithSiblings = 3 + siblingCount; const lastItemWithSiblings = totalPages - 2 - siblingCount; if (firstItemWithSiblings > lastItemWithSiblings) { for (let i = 2; i <= totalPages - 1; i++) { pagesToShow.add(i); } } else if (page < firstItemWithSiblings) { for (let i = 2; i <= Math.min(firstItemWithSiblings, totalPages); i++) { pagesToShow.add(i); } } else if (page > lastItemWithSiblings) { for (let i = totalPages - 1; i >= Math.max(lastItemWithSiblings, 2); i--) { pagesToShow.add(i); } } else { for (let i = Math.max(page - siblingCount, 2); i <= Math.min(page + siblingCount, totalPages); i++) { pagesToShow.add(i); } } function addPage(value) { pageItems.push({ type: "page", value, key: `page-${value}` }); } function addEllipsis() { const id = useId(); pageItems.push({ type: "ellipsis", key: `ellipsis-${id}` }); } let lastNumber = 0; for (const p of Array.from(pagesToShow).sort((a, b) => a - b)) { if (p - lastNumber > 1) { addEllipsis(); } addPage(p); lastNumber = p; } return pageItems; }