From e2ab4c621925e2bb250bb361cf7900b8de497da0 Mon Sep 17 00:00:00 2001 From: Luiggi Date: Sun, 31 May 2026 22:27:08 +0200 Subject: [PATCH 1/2] feat(pagination): add molecule component --- src/components/atoms/select/types.ts | 10 +- .../pagination/Pagination.stories.tsx | 300 ++++++++++++++++++ .../molecules/pagination/Pagination.test.tsx | 290 +++++++++++++++++ .../molecules/pagination/Pagination.tsx | 183 +++++++++++ src/components/molecules/pagination/index.ts | 11 + src/components/molecules/pagination/types.ts | 207 ++++++++++++ .../molecules/pagination/usePagination.ts | 193 +++++++++++ src/index.ts | 10 + 8 files changed, 1202 insertions(+), 2 deletions(-) create mode 100644 src/components/molecules/pagination/Pagination.stories.tsx create mode 100644 src/components/molecules/pagination/Pagination.test.tsx create mode 100644 src/components/molecules/pagination/Pagination.tsx create mode 100644 src/components/molecules/pagination/index.ts create mode 100644 src/components/molecules/pagination/types.ts create mode 100644 src/components/molecules/pagination/usePagination.ts diff --git a/src/components/atoms/select/types.ts b/src/components/atoms/select/types.ts index 6624192f..23f137b1 100644 --- a/src/components/atoms/select/types.ts +++ b/src/components/atoms/select/types.ts @@ -282,9 +282,15 @@ export type SelectProps = NativeSelectTriggerProps & onOpenChange?: (isOpen: boolean) => void; /** @control text */ ariaLabel?: string; - /** @control text */ + /** + * @control text + * @default Clear selection + */ clearAriaLabel?: string; - /** @control text */ + /** + * @control text + * @default Loading options... + */ loadingLabel?: string; /** @control text */ placeholder?: string; diff --git a/src/components/molecules/pagination/Pagination.stories.tsx b/src/components/molecules/pagination/Pagination.stories.tsx new file mode 100644 index 00000000..cd50c099 --- /dev/null +++ b/src/components/molecules/pagination/Pagination.stories.tsx @@ -0,0 +1,300 @@ +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, + PaginationSummary +} from './Pagination'; + +/** + * ## Description + * Pagination renders accessible navigation controls for paged content. It provides a compound API for consumers that already own the page range model. + * + * ## Usage Guide + * Compose `Pagination`, `PaginationContent`, `PaginationItem`, page links, previous/next controls, ellipsis, and optional summary text. Use `href` for anchor navigation or omit it for button-based client-side pagination. + */ +const meta: Meta = { + title: 'Molecules/Pagination', + component: Pagination, + parameters: { + docs: { + autodocs: true + } + }, + tags: ['autodocs'] +}; + +export default meta; + +type Story = StoryObj; + +/** + * Default pagination with previous, numeric pages, current page, and next controls. + */ +export const Default: Story = { + render: () => ( + + + + + + + + 1 + + + + + 2 + + + + + 3 + + + + + + + + ) +}; + +/** + * Skipped page ranges use a non-interactive ellipsis indicator. + */ +export const WithEllipsis: Story = { + render: () => ( + + + + + + + 1 + + + + + + + 5 + + + + + + + 10 + + + + + + + ) +}; + +/** + * Summary text can sit beside or above pagination content depending on consumer layout. + */ +export const WithSummary: Story = { + render: () => ( +
+ + Showing 1-10 of 100 results + + + + + + + 1 + + + + 2 + + + + + + +
+ ) +}; + +/** + * Simple previous and next controls without numeric pages. + */ +export const PreviousNextOnly: Story = { + render: () => ( + + + + + + + + + + + ) +}; + +/** + * Boundary controls expose disabled semantics at the first or last page. + */ +export const DisabledBoundary: Story = { + render: () => ( + + + + + + + 1 + + + 2 + + + + + + + ) +}; + +/** + * Size variants are controlled by the root and propagated to child controls. + */ +export const Sizes: Story = { + render: () => ( +
+
+ Small + + + + + + + 1 + + + + 2 + + + + + + + +
+ +
+ Medium + + + + + + + 1 + + + + 2 + + + + + + + +
+ +
+ Large + + + + + + + 1 + + + + 2 + + + + + + + +
+
+ ) +}; + +/** + * Button mode supports client-side state pagination without URLs. + */ +export const ButtonMode: Story = { + render: () => ( + + + + + + + 1 + + + + 2 + + + + + + + + ) +}; + +/** + * Anchor mode supports URL-style pagination and browser navigation affordances. + */ +export const AnchorMode: Story = { + render: () => ( + + + + + + + 1 + + + + 2 + + + + + + + + ) +}; diff --git a/src/components/molecules/pagination/Pagination.test.tsx b/src/components/molecules/pagination/Pagination.test.tsx new file mode 100644 index 00000000..401b25e0 --- /dev/null +++ b/src/components/molecules/pagination/Pagination.test.tsx @@ -0,0 +1,290 @@ +import { fireEvent, render, renderHook, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { Pagination as PaginationFromIndex } from './index'; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, + PaginationSummary +} from './Pagination'; +import { + usePagination, + usePaginationControl, + usePaginationEllipsis, + usePaginationNext, + usePaginationPrevious +} from './usePagination'; + +describe('usePagination — logic', () => { + it('returns the default landmark label and medium size', () => { + const { result } = renderHook(() => usePagination({ children: Pages })); + + expect(result.current['aria-label']).toBe('Pagination'); + expect(result.current.size).toBe('md'); + }); + + it('keeps current page controls interactive in anchor mode', () => { + const handleClick = vi.fn(); + const { result } = renderHook(() => + usePaginationControl({ + href: '?page=2', + isCurrent: true, + onClick: handleClick, + children: '2' + }) + ); + + expect(result.current.isAnchor).toBe(true); + expect(result.current.isCurrent).toBe(true); + expect(result.current.isDisabled).toBe(false); + }); + + it('uses accessible defaults for previous, next, and ellipsis', () => { + const previous = renderHook(() => usePaginationPrevious({})); + const next = renderHook(() => usePaginationNext({})); + const ellipsis = renderHook(() => usePaginationEllipsis({})); + + expect(previous.result.current['aria-label']).toBe('Go to previous page'); + expect(previous.result.current.children).toBe('Previous'); + expect(next.result.current['aria-label']).toBe('Go to next page'); + expect(next.result.current.children).toBe('Next'); + expect(ellipsis.result.current.label).toBe('More pages'); + }); +}); + +describe('Pagination — component behavior', () => { + it('is exported from the molecule barrel', () => { + expect(PaginationFromIndex).toBe(Pagination); + }); + + it('renders a navigation landmark with the default accessible name', () => { + render( + + + + 1 + + + + ); + + expect(screen.getByRole('navigation', { name: 'Pagination' })).toBeInTheDocument(); + }); + + it('renders content as list and listitem structure', () => { + render( + + + + 1 + + + 2 + + + + ); + + expect(screen.getByRole('list')).toBeInTheDocument(); + expect(screen.getAllByRole('listitem')).toHaveLength(2); + }); + + it('marks the current page with aria-current and keeps it clickable', async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + + render( + + + + { + event.preventDefault(); + handleClick(event); + }} + > + 2 + + + + + ); + + const current = screen.getByRole('link', { name: '2' }); + expect(current).toHaveAttribute('aria-current', 'page'); + + await user.click(current); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('renders previous and next controls with accessible labels', () => { + render( + + + + + + + + + + + ); + + expect(screen.getByRole('link', { name: 'Go to previous page' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Go to next page' })).toBeInTheDocument(); + }); + + it('renders ellipsis as non-interactive text with screen-reader text', () => { + render( + + + + + + + + ); + + expect(screen.getByText('More pages')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'More pages' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'More pages' })).not.toBeInTheDocument(); + }); + + it('disabled button controls cannot be activated', async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + + render( + + + + + 1 + + + + + ); + + const button = screen.getByRole('button', { name: '1' }); + expect(button).toBeDisabled(); + + await user.click(button); + expect(handleClick).not.toHaveBeenCalled(); + }); + + it('disabled anchor controls expose disabled semantics and block handlers', () => { + const handleClick = vi.fn(); + + render( + + + + + + + + ); + + const link = screen.getByRole('link', { name: 'Go to next page' }); + expect(link).toHaveAttribute('aria-disabled', 'true'); + expect(link).toHaveAttribute('tabindex', '-1'); + + fireEvent.click(link); + expect(handleClick).not.toHaveBeenCalled(); + }); + + it('anchor mode renders valid href links', () => { + render( + + + + 4 + + + + ); + + expect(screen.getByRole('link', { name: '4' })).toHaveAttribute('href', '?page=4'); + }); + + it('button mode calls onClick', async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + + render( + + + + 5 + + + + ); + + await user.click(screen.getByRole('button', { name: '5' })); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('preserves semantic rendering across size variants', () => { + render( +
+ + + + 1 + + + + + + + 2 + + + +
+ ); + + expect(screen.getByRole('navigation', { name: 'Small pages' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument(); + expect(screen.getByRole('navigation', { name: 'Large pages' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '2' })).toBeInTheDocument(); + }); + + it('allows keyboard focus to reach enabled controls in DOM order', async () => { + const user = userEvent.setup(); + + render( + + Showing 1-10 of 100 results + + + + + + 1 + + + + + + + ); + + await user.tab(); + expect(screen.getByRole('button', { name: 'Go to previous page' })).toHaveFocus(); + await user.tab(); + expect(screen.getByRole('button', { name: '1' })).toHaveFocus(); + await user.tab(); + expect(screen.getByRole('button', { name: 'Go to next page' })).toHaveFocus(); + }); +}); diff --git a/src/components/molecules/pagination/Pagination.tsx b/src/components/molecules/pagination/Pagination.tsx new file mode 100644 index 00000000..d4a66a06 --- /dev/null +++ b/src/components/molecules/pagination/Pagination.tsx @@ -0,0 +1,183 @@ +import type { FC } from 'react'; +import type { + PaginationContentProps, + PaginationEllipsisProps, + PaginationItemProps, + PaginationLinkProps, + PaginationNextProps, + PaginationPreviousProps, + PaginationProps, + PaginationSummaryProps +} from './types'; +import { + PaginationSizeProvider, + usePagination, + usePaginationContent, + usePaginationControl, + usePaginationEllipsis, + usePaginationItem, + usePaginationNext, + usePaginationPrevious, + usePaginationSummary +} from './usePagination'; + +export const Pagination: FC = (props) => { + const { children, className, size, ...navProps } = usePagination(props); + + return ( + + + + ); +}; + +export const PaginationSummary: FC = (props) => { + const { children, className, ...summaryProps } = usePaginationSummary(props); + + return ( +

+ {children} +

+ ); +}; + +export const PaginationContent: FC = (props) => { + const { children, className, ...contentProps } = usePaginationContent(props); + + return ( +
    + {children} +
+ ); +}; + +export const PaginationItem: FC = (props) => { + const { children, className, ...itemProps } = usePaginationItem(props); + + return ( +
  • + {children} +
  • + ); +}; + +export const PaginationLink: FC = (props) => { + const control = usePaginationControl(props); + + if ('href' in props && typeof props.href === 'string') { + const { children, className, isCurrent, isDisabled, onClick, ...anchorProps } = props; + + return ( + + {children} + + ); + } + + const { children, className, isCurrent, isDisabled, onClick, ...buttonProps } = props; + + return ( + + ); +}; + +export const PaginationPrevious: FC = (props) => { + const control = usePaginationPrevious(props); + + if ('href' in props && typeof props.href === 'string') { + const { children, className, isDisabled, onClick, ...anchorProps } = props; + + return ( + + {control.children} + + ); + } + + const { children, className, isDisabled, onClick, ...buttonProps } = props; + + return ( + + ); +}; + +export const PaginationNext: FC = (props) => { + const control = usePaginationNext(props); + + if ('href' in props && typeof props.href === 'string') { + const { children, className, isDisabled, onClick, ...anchorProps } = props; + + return ( + + {control.children} + + ); + } + + const { children, className, isDisabled, onClick, ...buttonProps } = props; + + return ( + + ); +}; + +export const PaginationEllipsis: FC = (props) => { + const { className, label, ...ellipsisProps } = usePaginationEllipsis(props); + + return ( + + + {label} + + ); +}; diff --git a/src/components/molecules/pagination/index.ts b/src/components/molecules/pagination/index.ts new file mode 100644 index 00000000..37d8e606 --- /dev/null +++ b/src/components/molecules/pagination/index.ts @@ -0,0 +1,11 @@ +export { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, + PaginationSummary +} from './Pagination'; +export type * from './types'; diff --git a/src/components/molecules/pagination/types.ts b/src/components/molecules/pagination/types.ts new file mode 100644 index 00000000..e89b8bed --- /dev/null +++ b/src/components/molecules/pagination/types.ts @@ -0,0 +1,207 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import type { ComponentProps, HTMLAttributeAnchorTarget, MouseEvent, ReactNode } from 'react'; + +export const paginationRootVariants = cva('flex flex-col gap-3 bg-transparent font-sans', { + variants: { + size: { + sm: 'fs-small', + md: 'fs-base', + lg: 'fs-h6' + } + }, + defaultVariants: { + size: 'md' + } +}); + +export const paginationContentVariants = cva('flex list-none flex-row flex-wrap items-center gap-1 p-0 m-0', { + variants: { + size: { + sm: 'gap-1', + md: 'gap-1.5', + lg: 'gap-2' + } + }, + defaultVariants: { + size: 'md' + } +}); + +export const paginationItemVariants = cva('flex items-center'); + +export const paginationSummaryVariants = cva('m-0 text-text-secondary-light dark:text-text-secondary-dark', { + variants: { + size: { + sm: 'fs-small', + md: 'fs-base', + lg: 'fs-h6' + } + }, + defaultVariants: { + size: 'md' + } +}); + +export const paginationControlVariants = cva( + [ + 'inline-flex min-h-11 min-w-11 items-center justify-center gap-1 rounded-md border font-semibold no-underline', + 'border-border-light bg-background-light text-text-light dark:border-border-dark dark:bg-surface-dark dark:text-text-dark', + 'transition-[background-color,border-color,color,box-shadow,transform,opacity] duration-200 ease-out', + 'hover:border-brand-light-darkest hover:bg-red-tint-subtle hover:text-brand-light-darkest hover:no-underline', + 'dark:hover:border-brand-dark-light dark:hover:bg-red-tint-subtle dark:hover:text-brand-dark-light', + 'active:scale-[0.98]', + 'focus-visible:outline-none focus-visible:shadow-glow-focus-light dark:focus-visible:shadow-glow-focus-dark' + ], + { + variants: { + size: { + sm: 'px-2 fs-small', + md: 'px-3 fs-base', + lg: 'px-4 fs-h6' + }, + isCurrent: { + true: [ + 'border-brand-light-darkest bg-brand-light-darkest text-white', + 'hover:border-brand-light-darkest hover:bg-brand-light-darkest hover:text-white', + 'dark:border-brand-light-darkest dark:bg-brand-light-darkest dark:text-white', + 'dark:hover:border-brand-light-darkest dark:hover:bg-brand-light-darkest dark:hover:text-white' + ], + false: '' + }, + isDisabled: { + true: 'pointer-events-none cursor-not-allowed opacity-40', + false: 'cursor-pointer' + } + }, + defaultVariants: { + size: 'md', + isCurrent: false, + isDisabled: false + } + } +); + +export const paginationEllipsisVariants = cva( + 'inline-flex min-h-11 min-w-11 items-center justify-center rounded-md text-text-secondary-light dark:text-text-secondary-dark', + { + variants: { + size: { + sm: 'px-2 fs-small', + md: 'px-3 fs-base', + lg: 'px-4 fs-h6' + } + }, + defaultVariants: { + size: 'md' + } + } +); + +export type PaginationVariants = VariantProps; +export type PaginationSize = NonNullable; +export type PaginationControlClickHandler = (event: MouseEvent) => void; + +export type PaginationProps = Omit, 'children'> & { + /** @control object */ + children: ReactNode; + /** + * @control select + * @default md + */ + size?: PaginationSize; + /** + * @control text + * @default Pagination + */ + 'aria-label'?: string; + /** @control text */ + className?: string; +}; + +export type PaginationSummaryProps = ComponentProps<'p'>; +export type PaginationContentProps = ComponentProps<'ul'>; +export type PaginationItemProps = ComponentProps<'li'>; + +export type PaginationControlOwnProps = { + /** + * @control boolean + * @default false + */ + isCurrent?: boolean; + /** + * @control boolean + * @default false + */ + isDisabled?: boolean; + /** @control text */ + className?: string; +}; + +export type PaginationAnchorControlProps = Omit< + ComponentProps<'a'>, + 'aria-current' | 'children' | 'className' | 'href' | 'onClick' | 'ref' +> & + PaginationControlOwnProps & { + /** @control object */ + children: ReactNode; + /** @control text */ + href: string; + /** @control text */ + target?: HTMLAttributeAnchorTarget; + /** @control text */ + rel?: string; + onClick?: PaginationControlClickHandler; + }; + +export type PaginationButtonControlProps = Omit< + ComponentProps<'button'>, + 'aria-current' | 'children' | 'className' | 'disabled' | 'href' | 'onClick' | 'ref' | 'type' +> & + PaginationControlOwnProps & { + /** @control object */ + children: ReactNode; + href?: undefined; + onClick?: PaginationControlClickHandler; + }; + +export type PaginationLinkProps = PaginationAnchorControlProps | PaginationButtonControlProps; + +type PaginationDirectionalAnchorProps = Omit; + +type PaginationDirectionalButtonProps = Omit; + +export type PaginationPreviousProps = (PaginationDirectionalAnchorProps | PaginationDirectionalButtonProps) & { + /** + * @control object + * @default Previous + */ + children?: ReactNode; + /** + * @control text + * @default Go to previous page + */ + 'aria-label'?: string; +}; + +export type PaginationNextProps = (PaginationDirectionalAnchorProps | PaginationDirectionalButtonProps) & { + /** + * @control object + * @default Next + */ + children?: ReactNode; + /** + * @control text + * @default Go to next page + */ + 'aria-label'?: string; +}; + +export type PaginationEllipsisProps = Omit, 'children'> & { + /** + * @control text + * @default More pages + */ + 'aria-label'?: string; + /** @control text */ + className?: string; +}; diff --git a/src/components/molecules/pagination/usePagination.ts b/src/components/molecules/pagination/usePagination.ts new file mode 100644 index 00000000..e9867ad3 --- /dev/null +++ b/src/components/molecules/pagination/usePagination.ts @@ -0,0 +1,193 @@ +import type { MouseEvent } from 'react'; +import { createContext, useContext } from 'react'; +import { cn } from '@/lib/utils'; +import { + type PaginationContentProps, + type PaginationControlClickHandler, + type PaginationEllipsisProps, + type PaginationItemProps, + type PaginationLinkProps, + type PaginationNextProps, + type PaginationPreviousProps, + type PaginationProps, + type PaginationSize, + type PaginationSummaryProps, + paginationContentVariants, + paginationControlVariants, + paginationEllipsisVariants, + paginationItemVariants, + paginationRootVariants, + paginationSummaryVariants +} from './types'; + +const PaginationSizeContext = createContext('md'); + +export const PaginationSizeProvider = PaginationSizeContext.Provider; + +export const usePaginationSize = (): PaginationSize => useContext(PaginationSizeContext); + +type UsePaginationReturn = Omit & { + className: string; + size: PaginationSize; +}; + +export const usePagination = ({ + className, + size = 'md', + 'aria-label': ariaLabel, + ...props +}: PaginationProps): UsePaginationReturn => ({ + ...props, + 'aria-label': ariaLabel ?? 'Pagination', + className: cn(paginationRootVariants({ size }), className), + size +}); + +type UsePaginationContentReturn = PaginationContentProps & { + className: string; +}; + +export const usePaginationContent = ({ className, ...props }: PaginationContentProps): UsePaginationContentReturn => { + const size = usePaginationSize(); + + return { + ...props, + className: cn(paginationContentVariants({ size }), className) + }; +}; + +type UsePaginationItemReturn = PaginationItemProps & { + className: string; +}; + +export const usePaginationItem = ({ className, ...props }: PaginationItemProps): UsePaginationItemReturn => ({ + ...props, + className: cn(paginationItemVariants(), className) +}); + +type UsePaginationSummaryReturn = PaginationSummaryProps & { + className: string; +}; + +export const usePaginationSummary = ({ className, ...props }: PaginationSummaryProps): UsePaginationSummaryReturn => { + const size = usePaginationSize(); + + return { + ...props, + className: cn(paginationSummaryVariants({ size }), className) + }; +}; + +type UsePaginationControlReturn = { + className: string; + isAnchor: boolean; + isCurrent: boolean; + isDisabled: boolean; + onClick: (event: MouseEvent) => void; +}; + +export const usePaginationControl = ({ + className, + isCurrent = false, + isDisabled = false, + onClick, + ...props +}: PaginationLinkProps): UsePaginationControlReturn => { + const size = usePaginationSize(); + + return { + className: cn(paginationControlVariants({ size, isCurrent, isDisabled }), className), + isAnchor: 'href' in props && typeof props.href === 'string', + isCurrent, + isDisabled, + onClick: getControlClickHandler(isDisabled, onClick) + }; +}; + +type UsePaginationDirectionalControlReturn = { + 'aria-label': string; + children: PaginationPreviousProps['children']; + className: string; + isAnchor: boolean; + isDisabled: boolean; + onClick: (event: MouseEvent) => void; +}; + +export const usePaginationPrevious = ({ + children = 'Previous', + 'aria-label': ariaLabel, + ...props +}: PaginationPreviousProps): UsePaginationDirectionalControlReturn => { + const control = useDirectionalControl(props); + + return { + ...control, + 'aria-label': ariaLabel ?? 'Go to previous page', + children + }; +}; + +export const usePaginationNext = ({ + children = 'Next', + 'aria-label': ariaLabel, + ...props +}: PaginationNextProps): UsePaginationDirectionalControlReturn => { + const control = useDirectionalControl(props); + + return { + ...control, + 'aria-label': ariaLabel ?? 'Go to next page', + children + }; +}; + +type UsePaginationEllipsisReturn = PaginationEllipsisProps & { + className: string; + label: string; +}; + +export const usePaginationEllipsis = ({ + className, + 'aria-label': ariaLabel, + ...props +}: PaginationEllipsisProps): UsePaginationEllipsisReturn => { + const size = usePaginationSize(); + const label = ariaLabel ?? 'More pages'; + + return { + ...props, + className: cn(paginationEllipsisVariants({ size }), className), + label + }; +}; + +const useDirectionalControl = ({ + className, + isDisabled = false, + onClick, + ...props +}: Omit): Omit< + UsePaginationDirectionalControlReturn, + 'aria-label' | 'children' +> => { + const size = usePaginationSize(); + + return { + className: cn(paginationControlVariants({ size, isDisabled, isCurrent: false }), className), + isAnchor: 'href' in props && typeof props.href === 'string', + isDisabled, + onClick: getControlClickHandler(isDisabled, onClick) + }; +}; + +const getControlClickHandler = (isDisabled: boolean, onClick?: PaginationControlClickHandler) => { + return (event: MouseEvent) => { + if (isDisabled) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + onClick?.(event); + }; +}; diff --git a/src/index.ts b/src/index.ts index 73b925ee..2169070d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,16 @@ export { Tooltip } from './components/atoms/tooltip'; // ─── Molecules ─────────────────────────────────────────────────────────────── export { Accordion } from './components/molecules/accordion'; export { Breadcrumb } from './components/molecules/breadcrumb'; +export { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, + PaginationSummary +} from './components/molecules/pagination'; export { Snippet } from './components/molecules/snippet'; // ─── Organisms ─────────────────────────────────────────────────────────────── From 1633162df7454c9961e715f5e67b2c4f6172005a Mon Sep 17 00:00:00 2001 From: Luiggi Date: Mon, 1 Jun 2026 11:21:34 +0200 Subject: [PATCH 2/2] test(pagination): cover keyboard activation --- .../molecules/pagination/Pagination.test.tsx | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/components/molecules/pagination/Pagination.test.tsx b/src/components/molecules/pagination/Pagination.test.tsx index 401b25e0..e9c6f6d5 100644 --- a/src/components/molecules/pagination/Pagination.test.tsx +++ b/src/components/molecules/pagination/Pagination.test.tsx @@ -12,6 +12,7 @@ import { PaginationPrevious, PaginationSummary } from './Pagination'; +import type { PaginationControlClickHandler } from './types'; import { usePagination, usePaginationControl, @@ -234,6 +235,56 @@ describe('Pagination — component behavior', () => { expect(handleClick).toHaveBeenCalledTimes(1); }); + it('activates button mode with Enter and Space', async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + + render( + + + + 5 + + + + ); + + const button = screen.getByRole('button', { name: '5' }); + button.focus(); + + await user.keyboard('{Enter}'); + expect(handleClick).toHaveBeenCalledTimes(1); + + await user.keyboard(' '); + expect(handleClick).toHaveBeenCalledTimes(2); + }); + + it('activates anchor mode with Enter', async () => { + const user = userEvent.setup(); + const handleClick: PaginationControlClickHandler = vi.fn((event) => { + event.preventDefault(); + }); + + render( + + + + + 6 + + + + + ); + + const link = screen.getByRole('link', { name: '6' }); + link.focus(); + + await user.keyboard('{Enter}'); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + it('preserves semantic rendering across size variants', () => { render(