INIT
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m11s

This commit is contained in:
eewing
2026-02-18 15:17:47 -06:00
parent e74c106f85
commit ec317eb17c
11532 changed files with 1631690 additions and 1 deletions
@@ -0,0 +1,38 @@
<script lang="ts">
import { boxWith, mergeProps } from "svelte-toolbelt";
import type { PaginationNextButtonProps } from "../types.js";
import { PaginationButtonState } from "../pagination.svelte.js";
import { createId } from "../../../internal/create-id.js";
const uid = $props.id();
let {
id = createId(uid),
child,
children,
ref = $bindable(null),
type = "button",
disabled = false,
...restProps
}: PaginationNextButtonProps = $props();
const nextButtonState = PaginationButtonState.create({
type: "next",
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
disabled: boxWith(() => Boolean(disabled)),
});
const mergedProps = $derived(mergeProps(restProps, nextButtonState.props, { type }));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}
@@ -0,0 +1,4 @@
import type { PaginationNextButtonProps } from "../types.js";
declare const PaginationNextButton: import("svelte").Component<PaginationNextButtonProps, {}, "ref">;
type PaginationNextButton = ReturnType<typeof PaginationNextButton>;
export default PaginationNextButton;
@@ -0,0 +1,43 @@
<script lang="ts">
import { boxWith, mergeProps } from "svelte-toolbelt";
import type { PaginationPageProps } from "../types.js";
import { PaginationPageState } from "../pagination.svelte.js";
import { createId } from "../../../internal/create-id.js";
const uid = $props.id();
let {
id = createId(uid),
page,
child,
children,
type = "button",
ref = $bindable(null),
disabled = false,
...restProps
}: PaginationPageProps = $props();
const pageState = PaginationPageState.create({
id: boxWith(() => id),
page: boxWith(() => page),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
disabled: boxWith(() => Boolean(disabled)),
});
const mergedProps = $derived(mergeProps(restProps, pageState.props, { type }));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{#if children}
{@render children?.()}
{:else}
{page.value}
{/if}
</button>
{/if}
@@ -0,0 +1,4 @@
import type { PaginationPageProps } from "../types.js";
declare const PaginationPage: import("svelte").Component<PaginationPageProps, {}, "ref">;
type PaginationPage = ReturnType<typeof PaginationPage>;
export default PaginationPage;
@@ -0,0 +1,38 @@
<script lang="ts">
import { boxWith, mergeProps } from "svelte-toolbelt";
import type { PaginationPrevButtonProps } from "../types.js";
import { PaginationButtonState } from "../pagination.svelte.js";
import { createId } from "../../../internal/create-id.js";
const uid = $props.id();
let {
id = createId(uid),
child,
children,
ref = $bindable(null),
type = "button",
disabled = false,
...restProps
}: PaginationPrevButtonProps = $props();
const prevButtonState = PaginationButtonState.create({
type: "prev",
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
disabled: boxWith(() => Boolean(disabled)),
});
const mergedProps = $derived(mergeProps(restProps, prevButtonState.props, { type }));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}
@@ -0,0 +1,4 @@
import type { PaginationPrevButtonProps } from "../types.js";
declare const PaginationPrevButton: import("svelte").Component<PaginationPrevButtonProps, {}, "ref">;
type PaginationPrevButton = ReturnType<typeof PaginationPrevButton>;
export default PaginationPrevButton;
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
import { boxWith, mergeProps } from "svelte-toolbelt";
import type { PaginationRootProps } from "../types.js";
import { PaginationRootState } from "../pagination.svelte.js";
import { noop } from "../../../internal/noop.js";
import { createId } from "../../../internal/create-id.js";
const uid = $props.id();
let {
id = createId(uid),
count,
perPage = 1,
page = $bindable(1),
ref = $bindable(null),
siblingCount = 1,
onPageChange = noop,
loop = false,
orientation = "horizontal",
child,
children,
...restProps
}: PaginationRootProps = $props();
const rootState = PaginationRootState.create({
id: boxWith(() => id),
count: boxWith(() => count),
perPage: boxWith(() => perPage),
page: boxWith(
() => page,
(v) => {
page = v;
onPageChange?.(v);
}
),
loop: boxWith(() => loop),
siblingCount: boxWith(() => siblingCount),
orientation: boxWith(() => orientation),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
});
const mergedProps = $derived(mergeProps(restProps, rootState.props));
</script>
{#if child}
{@render child({ props: mergedProps, ...rootState.snippetProps })}
{:else}
<div {...mergedProps}>
{@render children?.(rootState.snippetProps)}
</div>
{/if}
@@ -0,0 +1,4 @@
import type { PaginationRootProps } from "../types.js";
declare const Pagination: import("svelte").Component<PaginationRootProps, {}, "ref" | "page">;
type Pagination = ReturnType<typeof Pagination>;
export default Pagination;
+5
View File
@@ -0,0 +1,5 @@
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 type { PaginationRootProps as RootProps, PaginationPrevButtonProps as PrevButtonProps, PaginationNextButtonProps as NextButtonProps, PaginationPageProps as PageProps, } from "./types.js";
+4
View File
@@ -0,0 +1,4 @@
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";
+1
View File
@@ -0,0 +1 @@
export * as Pagination from "./exports.js";
+1
View File
@@ -0,0 +1 @@
export * as Pagination from "./exports.js";
+90
View File
@@ -0,0 +1,90 @@
import { type ReadableBoxedValues, type WritableBoxedValues } from "svelte-toolbelt";
import type { Page, PageItem } from "./types.js";
import type { BitsKeyboardEvent, BitsMouseEvent, RefAttachment, WithRefOpts } from "../../internal/types.js";
import { type Orientation } from "../../shared/index.js";
interface PaginationRootStateOpts extends WithRefOpts, ReadableBoxedValues<{
count: number;
perPage: number;
siblingCount: number;
orientation: Orientation;
loop: boolean;
}>, WritableBoxedValues<{
page: number;
}> {
}
export declare class PaginationRootState {
static create(opts: PaginationRootStateOpts): PaginationRootState;
readonly opts: PaginationRootStateOpts;
readonly attachment: RefAttachment;
readonly totalPages: number;
readonly range: {
start: number;
end: number;
};
readonly pages: PageItem[];
readonly hasPrevPage: boolean;
readonly hasNextPage: boolean;
constructor(opts: PaginationRootStateOpts);
setPage(page: number): void;
getPageTriggerNodes(): HTMLElement[];
getButtonNode(type: "prev" | "next"): HTMLElement | null | undefined;
prevPage(): void;
nextPage(): void;
readonly snippetProps: {
pages: PageItem[];
range: {
start: number;
end: number;
};
currentPage: number;
};
readonly props: {
readonly id: string;
readonly "data-orientation": Orientation;
};
}
interface PaginationPageStateOpts extends WithRefOpts, ReadableBoxedValues<{
page: Page;
disabled: boolean;
}> {
}
export declare class PaginationPageState {
#private;
static create(opts: PaginationPageStateOpts): PaginationPageState;
readonly opts: PaginationPageStateOpts;
readonly root: PaginationRootState;
readonly attachment: RefAttachment;
constructor(opts: PaginationPageStateOpts, root: PaginationRootState);
onclick(e: BitsMouseEvent): void;
onkeydown(e: BitsKeyboardEvent): void;
readonly props: {
readonly id: string;
readonly "aria-label": `Page ${number}`;
readonly "data-value": `${number}`;
readonly "data-selected": "" | undefined;
readonly onclick: (e: BitsMouseEvent) => void;
readonly onkeydown: (e: BitsKeyboardEvent) => void;
};
}
interface PaginationButtonStateOpts extends WithRefOpts, ReadableBoxedValues<{
disabled: boolean;
}> {
type: "prev" | "next";
}
export declare class PaginationButtonState {
#private;
static create(opts: PaginationButtonStateOpts): PaginationButtonState;
readonly opts: PaginationButtonStateOpts;
readonly root: PaginationRootState;
readonly attachment: RefAttachment;
constructor(opts: PaginationButtonStateOpts, root: PaginationRootState);
onclick(e: BitsMouseEvent): void;
onkeydown(e: BitsKeyboardEvent): void;
readonly props: {
readonly id: string;
readonly disabled: boolean;
readonly onclick: (e: BitsMouseEvent) => void;
readonly onkeydown: (e: BitsKeyboardEvent) => void;
};
}
export {};
+264
View File
@@ -0,0 +1,264 @@
import { attachRef } from "svelte-toolbelt";
import { Context } from "runed";
import { createBitsAttrs } 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": 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;
}
+76
View File
@@ -0,0 +1,76 @@
import type { OnChangeFn, WithChild, Without } from "../../internal/types.js";
import type { BitsPrimitiveButtonAttributes, BitsPrimitiveDivAttributes } from "../../shared/attributes.js";
type PaginationSnippetProps = {
pages: PageItem[];
range: {
start: number;
end: number;
};
currentPage: number;
};
export type PaginationRootPropsWithoutHTML = WithChild<{
/**
* The total number of items to be paginated.
*/
count: number;
/**
* The number of items per page.
*
* @defaultValue 1
*/
perPage?: number;
/**
* The number of visible items before and after the current page.
*
* @defaultValue 1
*/
siblingCount?: number;
/**
* The current page number.
*
* @defaultValue 1
*/
page?: number;
/**
* A callback function called when the page changes.
*/
onPageChange?: OnChangeFn<number>;
/**
* Whether keyboard navigation should loop back to the
* first or last page trigger when reaching either end.
*
* @defaultValue false
*/
loop?: boolean;
/**
* The orientation of the pagination component. Used to
* determine how keyboard navigation should work between
* pages.
*
* @defaultValue "horizontal"
*/
orientation?: "horizontal" | "vertical";
}, PaginationSnippetProps>;
export type PaginationRootProps = PaginationRootPropsWithoutHTML & Without<BitsPrimitiveDivAttributes, PaginationRootPropsWithoutHTML>;
export type PaginationPagePropsWithoutHTML = WithChild<{
page: Page;
}>;
export type PaginationPageProps = PaginationPagePropsWithoutHTML & Without<BitsPrimitiveButtonAttributes, PaginationPagePropsWithoutHTML>;
export type PaginationPrevButtonPropsWithoutHTML = WithChild;
export type PaginationPrevButtonProps = PaginationPrevButtonPropsWithoutHTML & Without<BitsPrimitiveButtonAttributes, PaginationPrevButtonPropsWithoutHTML>;
export type PaginationNextButtonPropsWithoutHTML = WithChild;
export type PaginationNextButtonProps = PaginationNextButtonPropsWithoutHTML & Without<BitsPrimitiveButtonAttributes, PaginationNextButtonPropsWithoutHTML>;
export type Page = {
type: "page";
value: number;
};
export type Ellipsis = {
type: "ellipsis";
};
export type PageItem = (Page | Ellipsis) & {
/**
* A unique key to be used as the key in a svelte `#each` block.
*/
key: string;
};
export {};
+1
View File
@@ -0,0 +1 @@
export {};