Skip to content
Draft
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,22 @@ Then open `http://localhost:8000`.
| onChange | `(page: number, pageSize: number, info?: { recommendPage?: number }) => void` | - | Triggered when page or page size changes. The `info.recommendPage` is only provided when pageSize changes, indicating the recommended page number based on the new page size. |
| onShowSizeChange | `(current: number, size: number) => void` | - | Triggered when page size changes. |

## Custom item accessibility

Default page and jump anchors expose button semantics, and previous/next actions
use native buttons. The outer `li` keeps its list-item semantics. Focus, labels,
current-page state, and activation belong to the inner control.

When `itemRender` or an icon returns a native `button` or `a`, Pagination reuses
that control instead of wrapping it. Native buttons use browser Enter/Space
activation; links with `href` retain link behavior. Other custom content keeps
its existing delegated interaction for compatibility. Custom components should
provide their own accessible control semantics; Pagination cannot assume that
they forward DOM props or safely wrap them in another interactive element.

Custom styles that target the outer item's focus should also account for focus
on its child, for example with `:focus-within` or a child `:focus-visible` rule.

## Development

```bash
Expand Down
3 changes: 3 additions & 0 deletions assets/index.less
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
}

&:focus,
&:focus-within,
&:hover {
border-color: @primary-color;
transition: all 0.3s;
Expand All @@ -101,11 +102,13 @@
}

&:focus,
&:focus-within,
&:hover {
border-color: #40a9ff;
}

&:focus a,
&:focus-within a,
&:hover a {
color: #40a9ff;
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"@types/node": "^26.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"axe-core": "^4.14.0",
"cross-env": "^10.1.0",
"dumi": "^2.4.38",
"eslint": "^10.6.0",
Expand Down
5 changes: 3 additions & 2 deletions src/Options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ const Options: React.FC<OptionsProps> = (props) => {
setGoInputText('');
if (
e.relatedTarget &&
(e.relatedTarget.className.includes(`${rootPrefixCls}-item-link`) ||
e.relatedTarget.className.includes(`${rootPrefixCls}-item`))
e.relatedTarget.closest(
`.${rootPrefixCls}-item-link, .${rootPrefixCls}-item`,
)
) {
return;
}
Expand Down
17 changes: 7 additions & 10 deletions src/Pager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { clsx } from 'clsx';
import React from 'react';
import type { PaginationProps } from './interface';
import PaginationItem from './PaginationItem';

export interface PagerProps extends Pick<PaginationProps, 'itemRender'> {
rootPrefixCls: string;
Expand All @@ -11,6 +12,7 @@ export interface PagerProps extends Pick<PaginationProps, 'itemRender'> {
className?: string;
style?: React.CSSProperties;
showTitle: boolean;
disabled?: boolean;
onClick?: (page: number) => void;
onKeyPress?: (
e: React.KeyboardEvent<HTMLLIElement>,
Expand All @@ -28,6 +30,7 @@ const Pager: React.FC<PagerProps> = (props) => {
className,
style,
showTitle,
disabled,
onClick,
onKeyPress,
itemRender,
Expand All @@ -52,29 +55,23 @@ const Pager: React.FC<PagerProps> = (props) => {
onKeyPress(e, onClick, page);
};

const pager = itemRender(
page,
'page',
<a tabIndex={-1} aria-hidden="true" rel="nofollow">
{page}
</a>,
);
const pager = itemRender(page, 'page', <a rel="nofollow">{page}</a>);
const pagerLabel = `${pageLabel} ${page}`.trim();

return pager ? (
<li
<PaginationItem
title={showTitle ? String(page) : null}
className={cls}
style={style}
onClick={handleClick}
onKeyDown={handleKeyPress}
tabIndex={0}
role="button"
disabled={disabled || !page}
aria-label={pagerLabel}
aria-current={active ? 'page' : undefined}
>
{pager}
</li>
</PaginationItem>
) : null;
};

Expand Down
37 changes: 17 additions & 20 deletions src/Pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import zhCN from './locale/zh_CN';
import Options from './Options';
import type { PagerProps } from './Pager';
import Pager from './Pager';
import PaginationItem from './PaginationItem';

const defaultItemRender: PaginationProps['itemRender'] = (_, __, element) =>
element;
Expand Down Expand Up @@ -126,8 +127,6 @@ const Pagination: React.FC<PaginationProps> = (props) => {
) : (
<button
type="button"
tabIndex={-1}
aria-hidden="true"
title={title}
className={`${prefixCls}-item-link`}
/>
Expand Down Expand Up @@ -295,9 +294,7 @@ const Pagination: React.FC<PaginationProps> = (props) => {
showTitle ? prevPageTitle : undefined,
),
);
return React.isValidElement<HTMLButtonElement>(prevButton)
? React.cloneElement(prevButton, { disabled: !hasPrev })
: prevButton;
return prevButton;
}

function renderNext(nextPage: number) {
Expand All @@ -311,9 +308,7 @@ const Pagination: React.FC<PaginationProps> = (props) => {
showTitle ? nextPageTitle : undefined,
),
);
return React.isValidElement<HTMLButtonElement>(nextButton)
? React.cloneElement(nextButton, { disabled: !hasNext })
: nextButton;
return nextButton;
}

function handleGoTO(event: any) {
Expand Down Expand Up @@ -355,6 +350,7 @@ const Pagination: React.FC<PaginationProps> = (props) => {
onClick: handleChange,
onKeyPress: runIfEnterOrSpace,
showTitle,
disabled,
itemRender,
pageLabel: locale.page,
page: -1,
Expand Down Expand Up @@ -437,6 +433,7 @@ const Pagination: React.FC<PaginationProps> = (props) => {
<Pager
{...pagerProps}
key="noPager"
disabled
page={1}
className={`${prefixCls}-item-disabled`}
/>,
Expand Down Expand Up @@ -473,7 +470,7 @@ const Pagination: React.FC<PaginationProps> = (props) => {

if (showPrevNextJumpers) {
jumpPrev = isReactRenderable(jumpPrevContent) ? (
<li
<PaginationItem
key="prev"
onClick={jumpPrevHandle}
tabIndex={0}
Expand All @@ -482,15 +479,15 @@ const Pagination: React.FC<PaginationProps> = (props) => {
[`${prefixCls}-jump-prev-custom-icon`]:
isReactRenderable(jumpPrevIcon),
})}
role="button"
disabled={disabled}
aria-label={prevItemTitle}
>
{jumpPrevContent}
</li>
</PaginationItem>
) : null;

jumpNext = isReactRenderable(jumpNextContent) ? (
<li
<PaginationItem
key="next"
onClick={jumpNextHandle}
tabIndex={0}
Expand All @@ -499,11 +496,11 @@ const Pagination: React.FC<PaginationProps> = (props) => {
[`${prefixCls}-jump-next-custom-icon`]:
isReactRenderable(jumpNextIcon),
})}
role="button"
disabled={disabled}
aria-label={nextItemTitle}
>
{jumpNextContent}
</li>
</PaginationItem>
) : null;
}

Expand Down Expand Up @@ -572,7 +569,7 @@ const Pagination: React.FC<PaginationProps> = (props) => {
if (isReactRenderable(prev)) {
const prevDisabled = !hasPrev || !allPages;
prev = (
<li
<PaginationItem
onClick={prevHandle}
tabIndex={prevDisabled ? null : 0}
onKeyDown={runIfEnterPrev}
Expand All @@ -581,11 +578,11 @@ const Pagination: React.FC<PaginationProps> = (props) => {
})}
style={styles?.item}
aria-disabled={prevDisabled}
role="button"
disabled={disabled || prevDisabled}
aria-label={locale.prev_page}
>
{prev}
</li>
</PaginationItem>
);
}

Expand All @@ -602,7 +599,7 @@ const Pagination: React.FC<PaginationProps> = (props) => {
}

next = (
<li
<PaginationItem
onClick={nextHandle}
tabIndex={nextTabIndex}
onKeyDown={runIfEnterNext}
Expand All @@ -611,11 +608,11 @@ const Pagination: React.FC<PaginationProps> = (props) => {
})}
style={styles?.item}
aria-disabled={nextDisabled}
role="button"
disabled={disabled || nextDisabled}
aria-label={locale.next_page}
>
{next}
</li>
</PaginationItem>
);
}

Expand Down
90 changes: 90 additions & 0 deletions src/PaginationItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from 'react';

interface PaginationItemProps extends React.LiHTMLAttributes<HTMLLIElement> {
disabled?: boolean;
}

/** Keep the list item structural when itemRender supplies a native control. */
const PaginationItem: React.FC<PaginationItemProps> = ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-high-complexity-react-function (warning)

PaginationItem has cyclomatic complexity 16, cognitive complexity 15, and maximum nesting depth 2, so its React logic is hard to understand and change. Extract independent branches into components or hooks.

Fix → Extract independent render branches and state logic into focused components or hooks until the control flow is easy to follow.

Docs

children,
disabled,
onClick,
onKeyDown,
tabIndex,
'aria-label': ariaLabel,
'aria-current': ariaCurrent,
'aria-disabled': ariaDisabled,
...restProps
}) => {
const nativeControl =
React.isValidElement<React.HTMLProps<HTMLElement>>(children) &&
(children.type === 'button' || children.type === 'a');

if (!nativeControl) {
// Arbitrary components need not forward DOM props. Preserve their existing
// content and delegated interaction instead of wrapping interactive nodes.
return (
<li

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-noninteractive-element-interactions (warning)

Keyboard & screen reader users can't trigger this <li> because it isn't interactive, so use a button or link or add an interactive role.

Fix → Put interactions on a button or link, or add an interactive role.

Docs

{...restProps}
onClick={onClick}
onKeyDown={onKeyDown}
tabIndex={tabIndex}
aria-label={ariaLabel}
aria-current={ariaCurrent}
aria-disabled={ariaDisabled}
>
{React.isValidElement(children) &&
typeof children.type !== 'string' &&
ariaDisabled !== undefined
? React.cloneElement(
children as React.ReactElement<{ disabled?: boolean }>,
{ disabled },
)
: children}
</li>
);
}

const button = children.type === 'button';
const link = !button && !!children.props.href;
const controlDisabled = disabled || (button && children.props.disabled);
const control = React.cloneElement(children, {
'aria-hidden': undefined,
'aria-label': children.props['aria-label'] ?? ariaLabel,
'aria-current': ariaCurrent,
'aria-disabled': controlDisabled || ariaDisabled,
tabIndex: controlDisabled ? -1 : 0,
...(button ? { type: 'button', disabled: controlDisabled } : {}),
...(!button && !link
? {
role: 'button',
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {
children.props.onKeyDown?.(event);
if (!event.defaultPrevented) {
onKeyDown?.(event as React.KeyboardEvent<HTMLLIElement>);
}
},
}
: {}),
onClick: (event: React.MouseEvent<HTMLElement>) => {
if (controlDisabled) {
event.preventDefault();
return;
}
children.props.onClick?.(event);
if (!event.isPropagationStopped()) {
onClick?.(event as React.MouseEvent<HTMLLIElement>);
}
},
});

// Native buttons synthesize clicks for Enter/Space; handling keydown on the
// parent as well would activate the same action twice.
return (
<li {...restProps} aria-disabled={ariaDisabled}>
{control}
</li>
);
};

export default PaginationItem;
Loading
Loading