diff --git a/README.md b/README.md index f058da0a..d1d38fd4 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,28 @@ 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 supplies a DOM element, Pagination merges its +control semantics (role, labels, tab order, and activation) directly onto that +element instead of wrapping it. Native buttons and links with `href` keep their +own keyboard activation; other DOM elements receive button semantics and +Enter/Space handling. Native buttons preserve their disabled state and default +to `type="button"` unless a type is explicitly supplied. + +Custom components, Fragments, and plain content retain delegated interaction on +the list item, since custom components need not forward DOM props. Custom +previous/next components continue to receive the `disabled` input. Authors are +responsible for accessible control semantics inside these custom renderers; +Pagination does not introduce an interactive wrapper around them. + +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 diff --git a/assets/index.less b/assets/index.less index 64ceac6d..325f0982 100644 --- a/assets/index.less +++ b/assets/index.less @@ -83,6 +83,7 @@ } &:focus, + &:focus-within, &:hover { border-color: @primary-color; transition: all 0.3s; @@ -101,11 +102,13 @@ } &:focus, + &:focus-within, &:hover { border-color: #40a9ff; } &:focus a, + &:focus-within a, &:hover a { color: #40a9ff; } @@ -180,6 +183,7 @@ } &:focus .@{pagination-prefix-cls}-item-link, + &:focus-within .@{pagination-prefix-cls}-item-link, &:hover .@{pagination-prefix-cls}-item-link { color: @primary-color; border-color: @primary-color; diff --git a/package.json b/package.json index 2768693b..fd7c42c3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/Options.tsx b/src/Options.tsx index aa570dc0..45bcb2c6 100644 --- a/src/Options.tsx +++ b/src/Options.tsx @@ -73,8 +73,9 @@ const Options: React.FC = (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; } diff --git a/src/Pager.tsx b/src/Pager.tsx index 5a512afd..b7b3b3c5 100644 --- a/src/Pager.tsx +++ b/src/Pager.tsx @@ -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 { rootPrefixCls: string; @@ -11,6 +12,7 @@ export interface PagerProps extends Pick { className?: string; style?: React.CSSProperties; showTitle: boolean; + disabled?: boolean; onClick?: (page: number) => void; onKeyPress?: ( e: React.KeyboardEvent, @@ -28,6 +30,7 @@ const Pager: React.FC = (props) => { className, style, showTitle, + disabled, onClick, onKeyPress, itemRender, @@ -52,29 +55,23 @@ const Pager: React.FC = (props) => { onKeyPress(e, onClick, page); }; - const pager = itemRender( - page, - 'page', - , - ); + const pager = itemRender(page, 'page', {page}); const pagerLabel = `${pageLabel} ${page}`.trim(); return pager ? ( -
  • {pager} -
  • + ) : null; }; diff --git a/src/Pagination.tsx b/src/Pagination.tsx index 2ca06bd8..f5140f8c 100644 --- a/src/Pagination.tsx +++ b/src/Pagination.tsx @@ -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; @@ -127,7 +128,6 @@ const Pagination: React.FC = (props) => {
  • -
    +
    Next
  • @@ -1959,135 +1736,115 @@ exports[`Example jumper 1`] = ` class="rc-pagination" >
  • @@ -3844,100 +3451,86 @@ exports[`Example showTitle 1`] = ` class="rc-pagination" >
  • @@ -3960,136 +3553,118 @@ exports[`Example showTotal 1`] = ` Total 455 items
  • + ), + nextIcon: ( + + ), + jumpPrevIcon: ( + + Previous jump icon + + ), + jumpNextIcon: ( + + Next jump icon + + ), +}; + +describe('accessible pagination controls', () => { + it.each([ + { total: 1000, defaultCurrent: 50 }, + { total: 1000, defaultCurrent: 50, ...antdIcons }, + { total: 1000, defaultCurrent: 1 }, + { total: 1000, defaultCurrent: 100 }, + { total: 1000, defaultCurrent: 50, disabled: true, ...antdIcons }, + { total: 1000, defaultCurrent: 1, simple: true }, + { total: 1000, defaultCurrent: 100, simple: { readOnly: true } }, + { total: 0 }, + { + total: 1000, + defaultCurrent: 50, + itemRender: (_, __, element) => element, + }, + { + total: 1000, + defaultCurrent: 50, + itemRender: (page) => , + }, + { + total: 1000, + defaultCurrent: 50, + itemRender: (page) => {page}, + }, + ])('passes axe for %j', async (props) => { + const { container } = render( {}} />); + await checkAccessibility(container); + container.querySelectorAll('li').forEach((item) => { + expect(item).not.toHaveAttribute('role', 'button'); + expect(item).not.toHaveAttribute('tabindex'); + }); + expect(container.querySelector('button button')).toBeNull(); + }); + + it.each(['prev', 'next', 'jump-prev', 'jump-next', 'item-51'])( + 'activates %s exactly once for each mouse/Enter/Space action', + async (type) => { + const onChange = jest.fn(); + const { container } = render( + , + ); + const control = container.querySelector( + `.rc-pagination-${type} > :is(button, a)`, + ); + control.focus(); + await userEvent.keyboard('{Enter}'); + expect(onChange).toHaveBeenCalledTimes(1); + await userEvent.keyboard(' '); + expect(onChange).toHaveBeenCalledTimes(type === 'item-51' ? 1 : 2); + // Selecting the current page is intentionally a no-op. + if (type !== 'item-51') { + await userEvent.click(control); + expect(onChange).toHaveBeenCalledTimes(3); + } + }, + ); + + it('has one focus target per item and exposes its current/disabled state', async () => { + const { container } = render( {}} />); + await userEvent.tab(); + expect(container.querySelector('.rc-pagination-item-1 a')).toHaveFocus(); + await userEvent.tab(); + expect(container.querySelector('.rc-pagination-item-2 a')).toHaveFocus(); + expect(container.querySelector('.rc-pagination-item-1 a')).toHaveAttribute( + 'aria-current', + 'page', + ); + expect( + container.querySelector('.rc-pagination-prev button'), + ).toHaveAttribute('aria-disabled', 'true'); + }); + + it('prevents navigation from a disabled custom link', () => { + const onChange = jest.fn(); + const { getByRole } = render( + {page}} + />, + ); + expect(fireEvent.click(getByRole('link', { name: '页 2' }))).toBe(false); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('keeps keyboard behavior for plain custom content', () => { + const onChange = jest.fn(); + const { container } = render( + {page}} + />, + ); + const control = container.querySelector('.rc-pagination-item-2 > span'); + expect(control).toHaveAttribute('tabindex', '0'); + fireEvent.keyDown(control, { key: 'Enter' }); + expect(onChange).toHaveBeenCalledTimes(1); + }); + it('composes custom click handlers without duplicate activation', async () => { + const onClick = jest.fn(); + const onChange = jest.fn(); + const { container } = render( + } + />, + ); + const control = container.querySelector( + '.rc-pagination-item-2 button', + ); + control.focus(); + await userEvent.keyboard('{Enter}'); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('respects custom controls that stop propagation or are disabled', async () => { + const onChange = jest.fn(); + const { container } = render( + ( + + )} + />, + ); + await userEvent.click( + container.querySelector('.rc-pagination-item-2 button'), + ); + expect(onChange).not.toHaveBeenCalled(); + expect( + container.querySelector('.rc-pagination-item-3 button'), + ).toBeDisabled(); + }); + it.each(['prev', 'next', 'jump-prev', 'jump-next'])( + 'retains Enter/Space handling for plain %s content', + (type) => { + const onChange = jest.fn(); + const { container } = render( + {page}} + />, + ); + const control = container.querySelector(`.rc-pagination-${type} > span`); + fireEvent.keyDown(control, { key: 'Enter' }); + expect(onChange).toHaveBeenCalledTimes(1); + fireEvent.keyDown(control, { key: ' ' }); + expect(onChange).toHaveBeenCalledTimes(2); + }, + ); + + it('allows a custom anchor to cancel delegated keyboard activation', () => { + const onChange = jest.fn(); + const onKeyDown = jest.fn((event: React.KeyboardEvent) => + event.preventDefault(), + ); + const { container } = render( + {page}} + />, + ); + fireEvent.keyDown(container.querySelector('.rc-pagination-item-2 a'), { + key: 'Enter', + }); + expect(onKeyDown).toHaveBeenCalledTimes(1); + expect(onChange).not.toHaveBeenCalled(); + }); + it('allows a custom control to stop delegated keyboard activation', () => { + const onChange = jest.fn(); + const { container } = render( + ( + event.stopPropagation()}>{page} + )} + />, + ); + fireEvent.keyDown(container.querySelector('.rc-pagination-item-2 a'), { + key: 'Enter', + }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('keeps a single tab stop when itemRender wraps the original element', () => { + const { container } = render( + {}} + itemRender={(_, type, element) => {element}} + />, + ); + // The unavailable Previous control keeps no tab stop on the first page. + expect( + container + .querySelector('.rc-pagination-prev') + .querySelectorAll('[tabindex="0"], button:not([tabindex="-1"])'), + ).toHaveLength(0); + const next = container.querySelector('.rc-pagination-next'); + const tabStops = next.querySelectorAll( + '[tabindex="0"], button:not([tabindex="-1"])', + ); + expect(tabStops).toHaveLength(1); + expect(tabStops[0]).toBe(next.querySelector('span')); + }); + + it('keeps interaction when itemRender returns a fragment', () => { + const onChange = jest.fn(); + const { container } = render( + <>{element}} + />, + ); + const item = container.querySelector('.rc-pagination-item-2'); + expect(item).toHaveAttribute('tabindex', '0'); + fireEvent.click(item.querySelector('a')); + expect(onChange).toHaveBeenCalledTimes(1); + // Selecting the current page is a no-op, so keyboard-check another page. + fireEvent.keyDown(container.querySelector('.rc-pagination-item-3'), { + key: 'Enter', + }); + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it('treats custom anchors with an empty href as links', () => { + const { container } = render( + {}} + itemRender={(page) => {page}} + />, + ); + const link = container.querySelector('.rc-pagination-item-2 a'); + expect(link).toHaveAttribute('href', ''); + expect(link).not.toHaveAttribute('role'); + }); + it('does not submit the quick-jump input when focusing a page control', async () => { + const onChange = jest.fn(); + const { getByRole } = render( + , + ); + await userEvent.type(getByRole('textbox'), '3'); + await userEvent.click(getByRole('button', { name: '页 2' })); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith(2, 10); + }); +}); diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 7cba57e2..6cb8614b 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -1,6 +1,7 @@ import type { RenderResult } from '@testing-library/react'; import { render, fireEvent } from '@testing-library/react'; import React from 'react'; +import userEvent from '@testing-library/user-event'; import Pagination from '../src'; import { resetWarned } from '@rc-component/util'; import { sizeChangerRender } from './commonUtil'; @@ -21,7 +22,7 @@ describe('Default Pagination', () => { it('onChange should be forbidden when total is default', () => { const pagers = $$('.rc-pagination-item'); - fireEvent.click(pagers[0]); + fireEvent.click(pagers[0].querySelector('button, a') || pagers[0]); expect(onChange).not.toHaveBeenCalled(); }); }); @@ -66,17 +67,19 @@ describe('Uncontrolled Pagination', () => { wrapper.container.querySelector('.rc-pagination-item-active'), ).toHaveTextContent('1'); expect($$('.rc-pagination-item')[0]).toHaveTextContent('1'); - expect($$('.rc-pagination-item')[0]).toHaveAttribute( - 'aria-current', - 'page', - ); + expect( + $$('.rc-pagination-item')[0].querySelector('button, a'), + ).toHaveAttribute('aria-current', 'page'); expect($$('.rc-pagination-item')[0]).toHaveAttribute('title', '1'); }); it('prev-button should be disabled', () => { const prevButton = wrapper.container.querySelector('.rc-pagination-prev'); expect(prevButton).toHaveClass('rc-pagination-disabled'); - expect(prevButton).toHaveAttribute('aria-disabled', 'true'); + expect(prevButton.querySelector('button, a')).toHaveAttribute( + 'aria-disabled', + 'true', + ); }); it('should hightlight current page and not highlight other page', () => @@ -94,18 +97,24 @@ describe('Uncontrolled Pagination', () => { it('next button should not be disabled', () => { const nextButton = wrapper.container.querySelector('.rc-pagination-next'); expect(nextButton).not.toHaveClass('rc-pagination-disabled'); - expect(nextButton).toHaveAttribute('aria-disabled', 'false'); + expect(nextButton.querySelector('button, a')).not.toHaveAttribute( + 'aria-disabled', + ); }); it('should expose role and label for navigation items', () => { - const prevButton = wrapper.container.querySelector('.rc-pagination-prev'); - const nextButton = wrapper.container.querySelector('.rc-pagination-next'); + const prevButton = wrapper.container.querySelector( + '.rc-pagination-prev button', + ); + const nextButton = wrapper.container.querySelector( + '.rc-pagination-next button', + ); const currentPage = wrapper.container.querySelector( - '.rc-pagination-item-active', + '.rc-pagination-item-active a', ); - expect(prevButton).toHaveAttribute('role', 'button'); + expect(prevButton.tagName).toBe('BUTTON'); expect(prevButton).toHaveAttribute('aria-label'); - expect(nextButton).toHaveAttribute('role', 'button'); + expect(nextButton.tagName).toBe('BUTTON'); expect(nextButton).toHaveAttribute('aria-label'); expect(currentPage).toHaveAttribute('role', 'button'); expect(currentPage).toHaveAttribute('aria-current', 'page'); @@ -116,7 +125,7 @@ describe('Uncontrolled Pagination', () => { expect(pagers).toHaveLength(3); const page2 = pagers[1]; expect(page2).toHaveClass('rc-pagination-item-2'); - fireEvent.click(page2); + fireEvent.click(page2.querySelector('button, a') || page2); expect( wrapper.container.querySelector('.rc-pagination-item-active'), ).toHaveTextContent('2'); @@ -126,7 +135,7 @@ describe('Uncontrolled Pagination', () => { it('should response next page', () => { const nextButton = wrapper.container.querySelector('.rc-pagination-next'); - fireEvent.click(nextButton); + fireEvent.click(nextButton.querySelector('button, a') || nextButton); expect( wrapper.container.querySelector('.rc-pagination-item-active'), ).toHaveTextContent('2'); @@ -197,9 +206,9 @@ describe('Uncontrolled Pagination', () => { ); expect(totalText).toHaveTextContent('1 - 10 of 25 items'); const nextButton = wrapper.container.querySelector('.rc-pagination-next'); - fireEvent.click(nextButton); + fireEvent.click(nextButton.querySelector('button, a') || nextButton); expect(totalText).toHaveTextContent('11 - 20 of 25 items'); - fireEvent.click(nextButton); + fireEvent.click(nextButton.querySelector('button, a') || nextButton); expect(totalText).toHaveTextContent('21 - 25 of 25 items'); }); @@ -213,9 +222,10 @@ describe('Uncontrolled Pagination', () => { warnSpy.mockRestore(); }); - it('should response keyboard event', () => { + it('should response keyboard event', async () => { const pagers = $$('.rc-pagination-item'); - fireEvent.keyDown(pagers[2], { key: 'Enter', keyCode: 13, which: 13 }); + pagers[2].querySelector('button, a').focus(); + await userEvent.keyboard('{Enter}'); expect( wrapper.container.querySelector('.rc-pagination-item-active'), ).toHaveTextContent('3'); @@ -250,7 +260,7 @@ describe('Controlled Pagination', () => { it('should not response mouse click', () => { const nextButton = wrapper.container.querySelector('.rc-pagination-next'); - fireEvent.click(nextButton); + fireEvent.click(nextButton.querySelector('button, a') || nextButton); expect( wrapper.container.querySelector('.rc-pagination-item-active'), ).toHaveTextContent('2'); @@ -611,7 +621,7 @@ describe('should emit onChange when total is string', () => { it('onChange should be called when click page', () => { const pagers = wrapper.container.querySelectorAll('.rc-pagination-item-3'); - fireEvent.click(pagers[0]); + fireEvent.click(pagers[0].querySelector('button, a') || pagers[0]); expect(onChange).toHaveBeenCalledWith(3, 10); }); }); @@ -619,7 +629,12 @@ describe('should emit onChange when total is string', () => { describe('keyboard support', () => { let wrapper: RenderResult; const onChange = jest.fn(); - const $ = (selector: string) => wrapper.container.querySelector(selector); + const $ = (selector: string) => + wrapper.container.querySelector(selector + ' > :is(button, a)'); + async function activate(control: Element, key: string) { + (control as HTMLElement).focus(); + await userEvent.keyboard(key); + } beforeEach(() => { wrapper = render( @@ -632,35 +647,35 @@ describe('keyboard support', () => { onChange.mockReset(); }); - it('should work for prev page', () => { + it('should work for prev page', async () => { const prevButton = $('li.rc-pagination-prev'); expect(prevButton).toBeTruthy(); fireEvent.click(prevButton); fireEvent.click(prevButton); - fireEvent.keyDown(prevButton, { key: 'Enter', keyCode: 13, which: 13 }); - fireEvent.keyDown(prevButton, { key: 'Enter', keyCode: 13, which: 13 }); + await activate(prevButton, '{Enter}'); + await activate(prevButton, '{Enter}'); expect(onChange).toHaveBeenLastCalledWith(46, 10); }); - it('should work for prev page with space key', () => { + it('should work for prev page with space key', async () => { const prevButton = $('li.rc-pagination-prev'); expect(prevButton).toBeTruthy(); - fireEvent.keyDown(prevButton, { key: ' ', keyCode: 32, which: 32 }); - fireEvent.keyDown(prevButton, { key: 'Spacebar', keyCode: 32, which: 32 }); + await activate(prevButton, ' '); + await activate(prevButton, ' '); expect(onChange).toHaveBeenLastCalledWith(48, 10); }); - it('should work for next page', () => { + it('should work for next page', async () => { const nextButton = $('li.rc-pagination-next'); expect(nextButton).toBeTruthy(); - fireEvent.keyDown(nextButton, { key: 'Enter', keyCode: 13, which: 13 }); - fireEvent.keyDown(nextButton, { key: 'Enter', keyCode: 13, which: 13 }); + await activate(nextButton, '{Enter}'); + await activate(nextButton, '{Enter}'); fireEvent.click(nextButton); fireEvent.click(nextButton); @@ -668,69 +683,61 @@ describe('keyboard support', () => { expect(onChange).toHaveBeenLastCalledWith(54, 10); }); - it('should work for next page with space key', () => { + it('should work for next page with space key', async () => { const nextButton = $('li.rc-pagination-next'); expect(nextButton).toBeTruthy(); - fireEvent.keyDown(nextButton, { key: ' ', keyCode: 32, which: 32 }); - fireEvent.keyDown(nextButton, { key: 'Spacebar', keyCode: 32, which: 32 }); + await activate(nextButton, ' '); + await activate(nextButton, ' '); expect(onChange).toHaveBeenLastCalledWith(52, 10); }); - it('should work for next page with enter keyCode only', () => { + it('should work for next page with native Enter activation', async () => { const nextButton = $('li.rc-pagination-next'); expect(nextButton).toBeTruthy(); - fireEvent.keyDown(nextButton, { keyCode: 13, which: 13 }); + await activate(nextButton, '{Enter}'); expect(onChange).toHaveBeenLastCalledWith(51, 10); }); - it('should work for jump prev page', () => { + it('should work for jump prev page', async () => { const jumpPrevButton = $('li.rc-pagination-jump-prev'); expect(jumpPrevButton).toBeTruthy(); - fireEvent.keyDown(jumpPrevButton, { key: 'Enter', keyCode: 13, which: 13 }); + await activate(jumpPrevButton, '{Enter}'); fireEvent.click(jumpPrevButton); expect(onChange).toHaveBeenLastCalledWith(40, 10); }); - it('should work for jump prev page with space key', () => { + it('should work for jump prev page with space key', async () => { const jumpPrevButton = $('li.rc-pagination-jump-prev'); expect(jumpPrevButton).toBeTruthy(); - fireEvent.keyDown(jumpPrevButton, { key: ' ', keyCode: 32, which: 32 }); - fireEvent.keyDown(jumpPrevButton, { - key: 'Spacebar', - keyCode: 32, - which: 32, - }); + await activate(jumpPrevButton, ' '); + await activate(jumpPrevButton, ' '); expect(onChange).toHaveBeenLastCalledWith(40, 10); }); - it('should work for jump next page', () => { + it('should work for jump next page', async () => { const jumpNextButton = $('li.rc-pagination-jump-next'); expect(jumpNextButton).toBeTruthy(); fireEvent.click(jumpNextButton); - fireEvent.keyDown(jumpNextButton, { key: 'Enter', keyCode: 13, which: 13 }); + await activate(jumpNextButton, '{Enter}'); expect(onChange).toHaveBeenLastCalledWith(60, 10); }); - it('should work for jump next page with space key', () => { + it('should work for jump next page with space key', async () => { const jumpNextButton = $('li.rc-pagination-jump-next'); expect(jumpNextButton).toBeTruthy(); - fireEvent.keyDown(jumpNextButton, { key: ' ', keyCode: 32, which: 32 }); - fireEvent.keyDown(jumpNextButton, { - key: 'Spacebar', - keyCode: 32, - which: 32, - }); + await activate(jumpNextButton, ' '); + await activate(jumpNextButton, ' '); expect(onChange).toHaveBeenLastCalledWith(60, 10); }); diff --git a/tests/itemRender-compat.test.tsx b/tests/itemRender-compat.test.tsx new file mode 100644 index 00000000..977ec50c --- /dev/null +++ b/tests/itemRender-compat.test.tsx @@ -0,0 +1,356 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import Pagination from '../src'; +import type { PaginationProps } from '../src/interface'; + +const buttonItemRender: PaginationProps['itemRender'] = (page) => ( + +); + +describe('itemRender interaction compatibility', () => { + it.each([ + [1, 'prev'], + [5, 'next'], + ] as const)('keeps native %s-page %s controls disabled', (current, type) => { + const { container } = render( + {}} + itemRender={buttonItemRender} + />, + ); + expect( + container.querySelector(`.rc-pagination-${type} button`), + ).toBeDisabled(); + }); + + it('blocks custom keyboard callbacks before handling a disabled DOM control', () => { + const onKeyDown = jest.fn(); + const onClick = jest.fn(); + const onChange = jest.fn(); + const { container } = render( + ( + + {page} + + )} + />, + ); + const control = container.querySelector('.rc-pagination-item-2 span'); + fireEvent.keyDown(control, { key: 'Enter' }); + fireEvent.click(control); + expect(onKeyDown).not.toHaveBeenCalled(); + expect(onClick).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it.each([ + ['default', false], + ['default', true], + ['opaque', false], + ['opaque', true], + ] as const)( + 'allows Tab navigation after disabling a focused %s item (shift=%s)', + async (kind, shift) => { + const Label = () => page; + const onChange = jest.fn(); + const renderPagination = (disabled: boolean) => ( +
    + +
    + ); + const { container, rerender, getByTestId } = render( + renderPagination(false), + ); + const item = container.querySelector( + '.rc-pagination-item-2', + ); + const control = kind === 'opaque' ? item : item.querySelector('a'); + control.focus(); + expect(control).toHaveFocus(); + + rerender(renderPagination(true)); + expect(control).toHaveFocus(); + expect(control).toHaveAttribute('tabindex', '-1'); + // Disabling activation must still allow the focused item to be left. + expect(fireEvent.keyDown(control, { key: 'Enter' })).toBe(false); + expect(fireEvent.keyDown(control, { key: ' ' })).toBe(false); + expect(onChange).not.toHaveBeenCalled(); + + await userEvent.tab({ shift }); + expect(getByTestId(shift ? 'before' : 'after')).toHaveFocus(); + expect(onChange).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ['fragment', 'global'], + ['fragment', 'prev'], + ['fragment', 'next'], + ['opaque', 'global'], + ['opaque', 'prev'], + ['opaque', 'next'], + ] as const)( + 'blocks child activation for a disabled %s renderer (%s)', + (kind, mode) => { + const onClick = jest.fn(); + const onKeyDown = jest.fn(); + const onChange = jest.fn(); + const Control = () => ( + + ); + const { container } = render( + + kind === 'fragment' ? ( + <> + + + ) : ( + + ) + } + />, + ); + const button = container.querySelector( + `.rc-pagination-${mode === 'global' ? 'item-2' : mode} button`, + ); + expect(fireEvent.click(button)).toBe(false); + expect(fireEvent.keyDown(button, { key: 'Enter' })).toBe(false); + expect(fireEvent.keyDown(button, { key: ' ' })).toBe(false); + expect(onClick).not.toHaveBeenCalled(); + expect(onKeyDown).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + expect(fireEvent.keyDown(button, { key: 'Tab' })).toBe(true); + expect(onKeyDown).toHaveBeenCalledTimes(1); + }, + ); + + it.each([false, true])( + 'allows focus to leave a native button after disabling (shift=%s)', + async (shift) => { + const onChange = jest.fn(); + const renderPagination = (disabled: boolean) => ( +
    + + + +
    + ); + const { container, rerender } = render(renderPagination(false)); + const button = container.querySelector( + '.rc-pagination-item-2 button', + ); + button.focus(); + expect(button).toHaveFocus(); + rerender(renderPagination(true)); + expect(button).toBeDisabled(); + await userEvent.tab({ shift }); + expect( + container + .querySelector('.rc-pagination') + .contains(document.activeElement), + ).toBe(false); + expect(onChange).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ['prev', 49], + ['next', 51], + ['jump-prev', 45], + ['jump-next', 55], + ['item-51', 51], + ] as const)( + 'keeps mouse and keyboard activation for an opaque %s component', + async (type, page) => { + const Label = ({ value }: { value: number }) => {value}; + const onChange = jest.fn(); + const { container } = render( +