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
240 changes: 240 additions & 0 deletions src/Components/Pagination/Pagination.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import type { Meta, StoryObj } from '@storybook/react';

import { Pagination } from './Pagination';

const meta: Meta<typeof Pagination> = {
component: Pagination,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
align: {
control: { type: 'select' },
options: ['start', 'center', 'end'],
},
buttonStyle: {
control: { type: 'select' },
options: ['fill', 'outline', 'link'],
},
paginationButtonAs: {
control: { type: 'select' },
options: ['a', 'button'],
},
},
};

export default meta;

type Story = StoryObj<typeof Pagination>;

// Mock pagination data for different scenarios
const createMockData = (currentPage: number, totalPages: number, showEllipsis = false) => {
const links = [];

// Previous link
links.push({
label: '&laquo; Previous',
url: currentPage > 1 ? `/page/${currentPage - 1}` : null,
active: false,
page: null,
});

// Page links
if (showEllipsis && totalPages > 7) {
// Complex pagination with ellipsis
if (currentPage <= 4) {
for (let i = 1; i <= 5; i++) {
links.push({
label: i.toString(),
url: `/page/${i}`,
active: i === currentPage,
page: i,
});
}
links.push({ label: '...', url: null, active: false, page: null });
links.push({
label: totalPages.toString(),
url: `/page/${totalPages}`,
active: false,
page: totalPages,
});
} else if (currentPage >= totalPages - 3) {
links.push({
label: '1',
url: '/page/1',
active: false,
page: 1,
});
links.push({ label: '...', url: null, active: false, page: null });
for (let i = totalPages - 4; i <= totalPages; i++) {
links.push({
label: i.toString(),
url: `/page/${i}`,
active: i === currentPage,
page: i,
});
}
} else {
links.push({
label: '1',
url: '/page/1',
active: false,
page: 1,
});
links.push({ label: '...', url: null, active: false, page: null });
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
links.push({
label: i.toString(),
url: `/page/${i}`,
active: i === currentPage,
page: i,
});
}
links.push({ label: '...', url: null, active: false, page: null });
links.push({
label: totalPages.toString(),
url: `/page/${totalPages}`,
active: false,
page: totalPages,
});
}
} else {
// Simple pagination without ellipsis
for (let i = 1; i <= totalPages; i++) {
links.push({
label: i.toString(),
url: `/page/${i}`,
active: i === currentPage,
page: i,
});
}
}

// Next link
links.push({
label: 'Next &raquo;',
url: currentPage < totalPages ? `/page/${currentPage + 1}` : null,
active: false,
page: null,
});

return {
data: Array.from({ length: 10 }, (_, i) => ({
id: (currentPage - 1) * 10 + i + 1,
name: `Item ${(currentPage - 1) * 10 + i + 1}`,
})),
links: links.filter(link => !link.label.includes('&laquo;') && !link.label.includes('&raquo;')),
current_page: currentPage,
last_page: totalPages,
first_page_url: '/page/1',
last_page_url: `/page/${totalPages}`,
next_page_url: currentPage < totalPages ? `/page/${currentPage + 1}` : null,
prev_page_url: currentPage > 1 ? `/page/${currentPage - 1}` : null,
path: '/page',
per_page: 10,
total: totalPages * 10,
from: (currentPage - 1) * 10 + 1,
to: Math.min(currentPage * 10, totalPages * 10),
};
};

export const Basic: Story = {
args: {
data: createMockData(1, 5),
align: 'end',
showInfo: false,
buttonStyle: 'fill',
paginationButtonAs: 'a',
},
};

export const WithInfo: Story = {
args: {
data: createMockData(2, 5),
align: 'end',
showInfo: true,
buttonStyle: 'fill',
paginationButtonAs: 'a',
},
};

export const Centered: Story = {
args: {
data: createMockData(5, 8),
align: 'center',
showInfo: false,
buttonStyle: 'fill',
paginationButtonAs: 'a',
},
};

export const OutlineStyle: Story = {
args: {
data: createMockData(2, 6),
align: 'end',
showInfo: false,
buttonStyle: 'outline',
paginationButtonAs: 'a',
},
};

export const LinkStyle: Story = {
args: {
data: createMockData(1, 4),
align: 'end',
showInfo: false,
buttonStyle: 'link',
paginationButtonAs: 'a',
},
};

export const FirstPage: Story = {
args: {
data: createMockData(1, 5),
align: 'end',
showInfo: true,
buttonStyle: 'fill',
paginationButtonAs: 'a',
},
};

export const LastPage: Story = {
args: {
data: createMockData(5, 5),
align: 'end',
showInfo: true,
buttonStyle: 'fill',
paginationButtonAs: 'a',
},
};

export const MiddlePage: Story = {
args: {
data: createMockData(7, 12),
align: 'end',
showInfo: false,
buttonStyle: 'fill',
paginationButtonAs: 'a',
},
};

export const AsButtons: Story = {
args: {
data: createMockData(3, 7),
align: 'center',
showInfo: false,
buttonStyle: 'outline',
paginationButtonAs: 'button',
},
};

export const Minimal: Story = {
args: {
data: createMockData(1, 3),
align: 'start',
showInfo: false,
buttonStyle: 'link',
paginationButtonAs: 'a',
},
};
129 changes: 129 additions & 0 deletions src/Components/Pagination/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { Button } from '../Button';

interface Link {
label: string;
url: string | null;
active: boolean;
page: number | null;
}

interface Pagination<T = Record<string, unknown>> {
data: T[];
links: Link[];
current_page: number;
last_page: number;
first_page_url?: string;
last_page_url?: string;
next_page_url?: string | null;
prev_page_url?: string | null;
path?: string;
per_page: number;
total: number;
from: number;
to: number;
}
Comment on lines +10 to +24
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Type naming and Laravel nullability; export types for consumers.

  • Rename the Pagination interface to PaginationData to avoid confusion with the component.
  • Make from/to nullable to match Laravel responses.
  • Optionally export Link and PaginationData.
-interface Link {
+export interface Link {
   label: string;
   url: string | null;
   active: boolean;
   page: number | null;
 }
 
-interface Pagination<T = Record<string, unknown>> {
+export interface PaginationData<T = Record<string, unknown>> {
   data: T[];
   links: Link[];
   current_page: number;
   last_page: number;
   first_page_url?: string;
   last_page_url?: string;
   next_page_url?: string | null;
   prev_page_url?: string | null;
   path?: string;
   per_page: number;
   total: number;
-  from: number;
-  to: number;
+  from: number | null;
+  to: number | null;
 }
 
-interface PaginationProps<T = Record<string, unknown>> {
-  data: Pagination<T>;
+interface PaginationProps<T = Record<string, unknown>> {
+  data: PaginationData<T>;
   align?: 'start' | 'center' | 'end';
   showInfo?: boolean;
   buttonStyle?: 'fill' | 'outline' | 'link';
   paginationButtonAs?: 'a' | 'button';
 }

Also applies to: 26-33

🤖 Prompt for AI Agents
In src/Components/Pagination/Pagination.tsx around lines 10-24 (and also apply
same changes to lines 26-33), rename the interface Pagination to PaginationData
to avoid name collision with the component, change the types of from and to to
allow null (e.g., number | null) to match Laravel nullable fields, and export
the Link type and the new PaginationData interface so other modules can consume
them; update any local references to use the new PaginationData name.


interface PaginationProps<T = Record<string, unknown>> {
data: Pagination<T>;
align?: 'start' | 'center' | 'end';
showInfo?: boolean;
buttonStyle?: 'fill' | 'outline' | 'link';
paginationButtonAs?: 'a' | 'button';
}

export function Pagination<T = Record<string, unknown>>({
data,
align = 'end',
showInfo = false,
buttonStyle = 'fill',
paginationButtonAs = 'a',
}: PaginationProps<T>) {
if (!data || (!data.next_page_url && !data.prev_page_url)) return null;

const {
prev_page_url,
next_page_url,
from,
to,
total,
links,
} = data;

const pageLinks = links.filter(
link =>
!link.label.includes('&laquo;') &&
!link.label.includes('&raquo;') &&
link.label !== '...',
);

const getAlignStyle = () => {
if (align === 'end') return 'md:justify-end';
if (align === 'start') return 'md:justify-start';
if (align === 'center') return 'md:justify-center';
return 'md:justify-end';
};

const Info = ({ showInfo }: { showInfo: boolean }) => {
if (!showInfo) return <></>;

return (
<div className="mb-2 w-full text-center text-sm text-gray-600 sm:mb-0 md:text-left" aria-live="polite">
Showing {from} to {to} of {total} entries
</div>
);
};

return (
<div className="flex w-full flex-col items-center justify-center md:flex-row md:justify-between">
<Info showInfo={showInfo} />

<nav
className={`flex w-full flex-wrap items-center justify-center space-x-2 ${getAlignStyle()}`}
aria-label="Pagination Navigation"
>
<Button
variant="secondary"
style={buttonStyle}
size="medium"
disabled={!prev_page_url}
as={paginationButtonAs}
href={prev_page_url || '#'}
className={!prev_page_url ? 'pointer-events-none' : ''}
aria-label="Go to previous page"
>
Previous
</Button>

Comment on lines +84 to +96
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Disabled anchors: prevent keyboard activation and improve a11y.

Anchors with href="#" remain focusable/activatable by keyboard. Use aria-disabled and remove from tab order when no URL; avoid "#" altogether.

         <Button
           variant="secondary"
           style={buttonStyle}
           size="medium"
-          disabled={!prev_page_url}
-          as={paginationButtonAs}
-          href={prev_page_url || '#'}
-          className={!prev_page_url ? 'pointer-events-none' : ''}
+          disabled={!prev_page_url}
+          as={paginationButtonAs}
+          href={prev_page_url ?? undefined}
+          aria-disabled={!prev_page_url || undefined}
+          tabIndex={!prev_page_url ? -1 : undefined}
+          className={!prev_page_url ? 'pointer-events-none' : ''}
           aria-label="Go to previous page"
         >
           Previous
         </Button>
...
         <Button
           variant="secondary"
           style={buttonStyle}
           size="medium"
-          disabled={!next_page_url}
-          as={paginationButtonAs}
-          href={next_page_url || '#'}
-          className={!next_page_url ? 'pointer-events-none' : ''}
+          disabled={!next_page_url}
+          as={paginationButtonAs}
+          href={next_page_url ?? undefined}
+          aria-disabled={!next_page_url || undefined}
+          tabIndex={!next_page_url ? -1 : undefined}
+          className={!next_page_url ? 'pointer-events-none' : ''}
           aria-label="Go to next page"
         >
           Next
         </Button>

Also applies to: 114-126

🤖 Prompt for AI Agents
In src/Components/Pagination/Pagination.tsx around lines 84-96 (and similarly
for lines 114-126), the Button renders as an anchor with href="#" when no page
URL which keeps it focusable and keyboard-activatable; remove the placeholder
href when prev_page_url/next_page_url is falsy, add aria-disabled="true" and
tabIndex={-1} to remove it from the tab order, and keep the visual disabled
styling (e.g. pointer-events-none/className) so the element is not
keyboard-activated; ensure when a real URL exists you restore the href, remove
aria-disabled, and set a normal tabIndex.

{pageLinks.map((link, index) => (
<Button
key={index}
variant={link.active ? 'primary' : 'secondary'}
style={buttonStyle}
size="medium"
disabled={!link.url}
as={link.url ? 'a' : paginationButtonAs}
href={link.url || '#'}
className={!link.url ? 'pointer-events-none' : ''}
aria-label={`Go to page ${link.label}`}
aria-current={link.active ? 'page' : undefined}
>
{link.label}
</Button>
))}

<Button
variant="secondary"
style={buttonStyle}
size="medium"
disabled={!next_page_url}
as={paginationButtonAs}
href={next_page_url || '#'}
className={!next_page_url ? 'pointer-events-none' : ''}
aria-label="Go to next page"
>
Next
</Button>
</nav>
</div>
);
}
Loading