Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-tigers-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solid-primitives/pagination": patch
---

fix keyboard focus loss
10 changes: 8 additions & 2 deletions .storybook/ui/controls.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { JSX } from "solid-js";
import type { JSX } from "@solidjs/web";
import { colors, font, radii } from "./tokens.js";

export const Button = (props: {
onClick?: () => void;
"aria-current"?: "page";
"aria-label"?: string;
onClick?: (ev: MouseEvent) => void;
onKeyUp?: (ev: KeyboardEvent) => void;
children: JSX.Element;
variant?: "primary" | "secondary" | "outline" | "ghost";
color?: string;
Expand All @@ -12,9 +15,12 @@ export const Button = (props: {
ref?: HTMLButtonElement | ((el: HTMLButtonElement) => void);
}) => (
<button
aria-label={props["aria-label"]}
aria-current={props["aria-current"]}
ref={props.ref}
type={props.type ?? "button"}
onClick={props.onClick}
onKeyUp={props.onKeyUp}
disabled={props.disabled}
style={{
padding: "0.5rem 1.1rem",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ export function richText(string: string, tags: RichTextTags): JSX.Element {
parts.push(render(inner));
} else {
if (DEV)
// oxlint-disable-next-line no-console
console.warn(
`[@solid-primitives/i18n] richText: no renderer for tag "<${tag}>" was provided, rendering its contents as plain text.`,
);
Expand Down
21 changes: 14 additions & 7 deletions packages/pagination/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
createEffect,
createMemo,
createSignal,
flush,
onCleanup,
} from "solid-js";
import { isServer, type JSX } from "@solidjs/web";
Expand Down Expand Up @@ -88,7 +89,7 @@ export type PaginationOptions = {
};

export type PaginationProps = {
"aria-current"?: boolean;
"aria-current"?: "page";
"aria-label"?: string;
disabled?: boolean;
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>;
Expand Down Expand Up @@ -167,13 +168,19 @@ export const createPagination = (

// Clamp page to valid range reactively — handles page count decreasing below current page
const page: Accessor<number> = createMemo(() => Math.max(1, Math.min(rawPage(), opts().pages)));

const goPage = (p: number | ((p: number) => number), ev: KeyboardEvent) => {
// select the parent before we might get detached
let parent = ev.currentTarget, current;
while (parent instanceof HTMLElement && parent.childElementCount < 3) parent = parent.parentNode;
setPage(p);
if ("currentTarget" in ev)
(ev.currentTarget as HTMLElement).parentNode
?.querySelector<HTMLElement>('[aria-current="true"]')
?.focus();
flush();
// select the current page
if (parent) {
while (parent instanceof HTMLElement && !(current = parent.querySelector('[aria-current="page"]')))
parent = parent.parentNode;
current instanceof HTMLElement && current.focus();
}
};

const onKeyUp = (pageNo: number, ev: KeyboardEvent) =>
Expand Down Expand Up @@ -205,7 +212,7 @@ export const createPagination = (
},
{
"aria-current": {
get: () => (page() === pageNo ? "true" : undefined),
get: () => (page() === pageNo ? "page" : undefined),
set: noop,
enumerable: true,
},
Expand Down
118 changes: 61 additions & 57 deletions packages/pagination/stories/pagination.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createSignal, For, Show } from "solid-js";
import { createMemo, createSignal, Errored, For, Show } from "solid-js";
import preview from "../../../.storybook/preview.js";
import { createInfiniteScroll, createPagination, createSegment } from "../src/index.js";
import { createInfiniteScroll, createPagination, createSegment, type PaginationOptions } from "../src/index.js";
import readme from "../README.md?raw";
import {
Badge,
Expand Down Expand Up @@ -30,31 +30,34 @@ const meta = preview.meta({
export default meta;

const PaginationBar = (barProps: {
props: ReturnType<typeof createPagination>[0];
options: PaginationOptions;
center?: boolean;
}) => (
<nav
style={{
display: "flex",
gap: "0.25rem",
"flex-wrap": "wrap",
...(barProps.center ? { "justify-content": "center" } : {}),
}}
>
<For each={barProps.props()}>
{props => (
<Button
onClick={props.onClick as () => void}
disabled={props.disabled}
variant={props["aria-current"] ? "primary" : "outline"}
style={{ "min-width": "2rem", "font-size": font.sizeSm, padding: "0.3rem 0.6rem" }}
>
{props.children as string}
</Button>
)}
</For>
</nav>
);
}) => {
const [props, page] = createPagination(() => barProps.options);
return (<>
<StatRow label="Current page" value={page()} />
<nav
style={{
display: "flex",
gap: "0.25rem",
"flex-wrap": "wrap",
...(barProps.center ? { "justify-content": "center" } : {}),
}}
>
<For each={props()}>
{props => (
<Button
{...props}
variant={props["aria-current"] ? "primary" : "outline"}
style={{ "min-width": "2rem", "font-size": font.sizeSm, padding: "0.3rem 0.6rem" }}
>
{props.children as string}
</Button>
)}
</For>
</nav>
</>);
}

export const CreatePaginationStory = meta.story({
name: "createPagination",
Expand All @@ -69,41 +72,42 @@ export const CreatePaginationStory = meta.story({
render: () => {
const [totalPages, setTotalPages] = createSignal(20);
const [maxVisible, setMaxVisible] = createSignal(7);
const [paginationProps, page, _setPage] = createPagination(() => ({
const paginationOptions = createMemo(() => ({
pages: totalPages(),
maxPages: maxVisible(),
}));

return (
<Container minWidth={400}>
<StatRow label="Current page" value={page()} />
<PaginationBar props={paginationProps} />
<Separator />
<Section title="Max visible">
<ButtonRow>
{([5, 7, 10] as const).map(n => (
<Button
variant={maxVisible() === n ? "primary" : "outline"}
onClick={() => setMaxVisible(n)}
>
{n}
</Button>
))}
</ButtonRow>
</Section>
<Section title="Total pages">
<ButtonRow>
{([10, 20, 50] as const).map(n => (
<Button
variant={totalPages() === n ? "primary" : "outline"}
onClick={() => setTotalPages(n)}
>
{n}
</Button>
))}
</ButtonRow>
</Section>
</Container>
<Errored fallback={(err, reset) => <p>Error: {err} <button type="reset" onClick={reset}>Reset</button></p>}>
<Container minWidth={400}>
<PaginationBar options={paginationOptions()} />
<Separator />
<Section title="Max visible">
<ButtonRow>
{([5, 7, 10] as const).map(n => (
<Button
variant={maxVisible() === n ? "primary" : "outline"}
onClick={() => setMaxVisible(n)}
>
{n}
</Button>
))}
</ButtonRow>
</Section>
<Section title="Total pages">
<ButtonRow>
{([10, 20, 50] as const).map(n => (
<Button
variant={totalPages() === n ? "primary" : "outline"}
onClick={() => setTotalPages(n)}
>
{n}
</Button>
))}
</ButtonRow>
</Section>
</Container>
</Errored>
);
},
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, test, expect } from "vitest";
import { createMemo, createRoot, createSignal, flush } from "solid-js";
import { createMemo, createRoot, createSignal, flush, For } from "solid-js";
import { render } from "@solidjs/web";
import {
createInfiniteScroll,
createPagination,
Expand Down Expand Up @@ -193,6 +194,37 @@ describe("createPagination", () => {
expect(page()).toBe(8);
dispose();
});

test("sets the focus after going to a new page", async () => {
const originalFocus = HTMLElement.prototype.focus;
let current = document.body;
HTMLElement.prototype.focus = function() {
current = this;
originalFocus.call(this);
};
const container = document.createElement('div');
document.body.appendChild(container);
const Pagination = () => {
const [pagination] = createPagination({ pages: 10, maxPages: 5, initialPage: 1 });
return <nav>
<For each={pagination()}>
{(props) => <button type="button" {...props} />}
</For>
</nav>;
};
const dispose = render(() => <Pagination />, container);
const activeButton = container?.querySelector('nav button[aria-current="page"]');
activeButton instanceof HTMLElement && activeButton.focus();
for (var i = 0; i < 5; i++) {
await new Promise(r => setTimeout(r, 15));
current?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, charCode: 0, code: "ArrowRight", key: "ArrowRight", keyCode: 39 }));
current?.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, charCode: 0, code: "ArrowRight", key: "ArrowRight", keyCode: 39 }));
await new Promise(r => setTimeout(r, 45));
expect(current.getAttribute("aria-current")).toBe("page");
}
queueMicrotask(dispose);
HTMLElement.prototype.focus = originalFocus;
});
});

describe("createSegment", () => {
Expand Down
Loading