@fragments-sdk/ui 0.8.7 → 0.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fragments.json +1 -1
- package/package.json +2 -2
- package/src/assets/fragments-logo.tsx +9 -8
- package/src/blocks/CommandPalette.block.ts +34 -0
- package/src/blocks/PaginatedTable.block.ts +36 -0
- package/src/blocks/SettingsDrawer.block.ts +47 -0
- package/src/components/Command/Command.fragment.tsx +237 -0
- package/src/components/Command/Command.module.scss +153 -0
- package/src/components/Command/Command.test.tsx +363 -0
- package/src/components/Command/index.tsx +502 -0
- package/src/components/Drawer/Drawer.fragment.tsx +206 -0
- package/src/components/Drawer/Drawer.module.scss +215 -0
- package/src/components/Drawer/Drawer.test.tsx +227 -0
- package/src/components/Drawer/index.tsx +239 -0
- package/src/components/Pagination/Pagination.fragment.tsx +152 -0
- package/src/components/Pagination/Pagination.module.scss +109 -0
- package/src/components/Pagination/Pagination.test.tsx +171 -0
- package/src/components/Pagination/index.tsx +360 -0
- package/src/components/Tooltip/index.tsx +25 -1
- package/src/index.ts +34 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { defineFragment } from '@fragments-sdk/cli/core';
|
|
3
|
+
import { Pagination } from '.';
|
|
4
|
+
|
|
5
|
+
export default defineFragment({
|
|
6
|
+
component: Pagination,
|
|
7
|
+
|
|
8
|
+
meta: {
|
|
9
|
+
name: 'Pagination',
|
|
10
|
+
description: 'Page navigation for paginated data. Supports controlled/uncontrolled, page counts, and edge/sibling customization.',
|
|
11
|
+
category: 'navigation',
|
|
12
|
+
status: 'stable',
|
|
13
|
+
tags: ['pagination', 'paging', 'pages', 'navigation'],
|
|
14
|
+
since: '0.8.2',
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
usage: {
|
|
18
|
+
when: [
|
|
19
|
+
'Navigating through paginated data sets',
|
|
20
|
+
'Table or list pagination controls',
|
|
21
|
+
'Search results with multiple pages',
|
|
22
|
+
'Any content split across multiple pages',
|
|
23
|
+
],
|
|
24
|
+
whenNot: [
|
|
25
|
+
'Small lists that fit on one page',
|
|
26
|
+
'Infinite scroll patterns (use IntersectionObserver)',
|
|
27
|
+
'Tab-based navigation (use Tabs)',
|
|
28
|
+
'Step-by-step wizards (use Stepper)',
|
|
29
|
+
],
|
|
30
|
+
guidelines: [
|
|
31
|
+
'Place below the content being paginated',
|
|
32
|
+
'Use edgeCount to always show first/last pages',
|
|
33
|
+
'Use siblingCount to control how many pages surround the current page',
|
|
34
|
+
'Pair with Table component for data table pagination',
|
|
35
|
+
],
|
|
36
|
+
accessibility: [
|
|
37
|
+
'Uses nav element with aria-label="Pagination"',
|
|
38
|
+
'aria-current="page" marks the active page',
|
|
39
|
+
'Previous/Next buttons have descriptive aria-labels',
|
|
40
|
+
'Disabled buttons at boundaries prevent invalid navigation',
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
props: {
|
|
45
|
+
totalPages: {
|
|
46
|
+
type: 'number',
|
|
47
|
+
description: 'Total number of pages',
|
|
48
|
+
required: true,
|
|
49
|
+
},
|
|
50
|
+
page: {
|
|
51
|
+
type: 'number',
|
|
52
|
+
description: 'Controlled current page (1-indexed)',
|
|
53
|
+
},
|
|
54
|
+
defaultPage: {
|
|
55
|
+
type: 'number',
|
|
56
|
+
description: 'Default page (uncontrolled)',
|
|
57
|
+
default: '1',
|
|
58
|
+
},
|
|
59
|
+
onPageChange: {
|
|
60
|
+
type: 'function',
|
|
61
|
+
description: 'Called when page changes',
|
|
62
|
+
},
|
|
63
|
+
edgeCount: {
|
|
64
|
+
type: 'number',
|
|
65
|
+
description: 'Number of pages shown at edges',
|
|
66
|
+
default: '1',
|
|
67
|
+
},
|
|
68
|
+
siblingCount: {
|
|
69
|
+
type: 'number',
|
|
70
|
+
description: 'Number of pages shown around current',
|
|
71
|
+
default: '1',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
relations: [
|
|
76
|
+
{ component: 'Table', relationship: 'sibling', note: 'Commonly paired for table pagination' },
|
|
77
|
+
{ component: 'Listbox', relationship: 'alternative', note: 'Use Listbox for small sets of options' },
|
|
78
|
+
],
|
|
79
|
+
|
|
80
|
+
contract: {
|
|
81
|
+
propsSummary: [
|
|
82
|
+
'totalPages: number - total page count (required)',
|
|
83
|
+
'page: number - controlled current page (1-indexed)',
|
|
84
|
+
'defaultPage: number - initial page (default: 1)',
|
|
85
|
+
'onPageChange: (page) => void - page change handler',
|
|
86
|
+
'edgeCount: number - pages at edges (default: 1)',
|
|
87
|
+
'siblingCount: number - pages around current (default: 1)',
|
|
88
|
+
],
|
|
89
|
+
scenarioTags: [
|
|
90
|
+
'navigation.pagination',
|
|
91
|
+
'data.table',
|
|
92
|
+
'search.results',
|
|
93
|
+
],
|
|
94
|
+
a11yRules: ['A11Y_NAV_LABEL', 'A11Y_CURRENT_PAGE'],
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
ai: {
|
|
98
|
+
compositionPattern: 'compound',
|
|
99
|
+
subComponents: ['Previous', 'Next', 'Items', 'Item', 'Ellipsis'],
|
|
100
|
+
requiredChildren: ['Items'],
|
|
101
|
+
commonPatterns: [
|
|
102
|
+
'<Pagination totalPages={totalPages} page={currentPage} onPageChange={setPage}><Pagination.Previous /><Pagination.Items /><Pagination.Next /></Pagination>',
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
variants: [
|
|
107
|
+
{
|
|
108
|
+
name: 'Default',
|
|
109
|
+
description: 'Basic pagination with 10 pages',
|
|
110
|
+
render: () => (
|
|
111
|
+
<Pagination totalPages={10} defaultPage={1}>
|
|
112
|
+
<Pagination.Previous />
|
|
113
|
+
<Pagination.Items />
|
|
114
|
+
<Pagination.Next />
|
|
115
|
+
</Pagination>
|
|
116
|
+
),
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'With Edge Pages',
|
|
120
|
+
description: 'Shows 2 pages at each edge',
|
|
121
|
+
render: () => (
|
|
122
|
+
<Pagination totalPages={20} defaultPage={10} edgeCount={2} siblingCount={1}>
|
|
123
|
+
<Pagination.Previous />
|
|
124
|
+
<Pagination.Items />
|
|
125
|
+
<Pagination.Next />
|
|
126
|
+
</Pagination>
|
|
127
|
+
),
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'Compact',
|
|
131
|
+
description: 'No siblings, minimal display',
|
|
132
|
+
render: () => (
|
|
133
|
+
<Pagination totalPages={20} defaultPage={10} siblingCount={0}>
|
|
134
|
+
<Pagination.Previous />
|
|
135
|
+
<Pagination.Items />
|
|
136
|
+
<Pagination.Next />
|
|
137
|
+
</Pagination>
|
|
138
|
+
),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'Controlled',
|
|
142
|
+
description: 'Controlled pagination at page 3',
|
|
143
|
+
render: () => (
|
|
144
|
+
<Pagination totalPages={5} page={3}>
|
|
145
|
+
<Pagination.Previous />
|
|
146
|
+
<Pagination.Items />
|
|
147
|
+
<Pagination.Next />
|
|
148
|
+
</Pagination>
|
|
149
|
+
),
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
@use '../../tokens/variables' as *;
|
|
2
|
+
@use '../../tokens/mixins' as *;
|
|
3
|
+
|
|
4
|
+
// ============================================
|
|
5
|
+
// Navigation wrapper
|
|
6
|
+
// ============================================
|
|
7
|
+
|
|
8
|
+
.pagination {
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
justify-content: center;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ============================================
|
|
15
|
+
// List container
|
|
16
|
+
// ============================================
|
|
17
|
+
|
|
18
|
+
.list {
|
|
19
|
+
display: flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
gap: var(--fui-space-1, 0.25rem);
|
|
22
|
+
list-style: none;
|
|
23
|
+
margin: 0;
|
|
24
|
+
padding: 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ============================================
|
|
28
|
+
// Page item button
|
|
29
|
+
// ============================================
|
|
30
|
+
|
|
31
|
+
.item {
|
|
32
|
+
@include button-reset;
|
|
33
|
+
@include text-base;
|
|
34
|
+
|
|
35
|
+
display: inline-flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
justify-content: center;
|
|
38
|
+
min-width: 2rem;
|
|
39
|
+
height: 2rem;
|
|
40
|
+
padding: 0 var(--fui-space-2, 0.5rem);
|
|
41
|
+
border-radius: var(--fui-radius-md, 0.375rem);
|
|
42
|
+
font-size: var(--fui-font-size-sm, 0.875rem);
|
|
43
|
+
font-weight: var(--fui-font-weight-medium, 500);
|
|
44
|
+
color: var(--fui-text-primary);
|
|
45
|
+
cursor: pointer;
|
|
46
|
+
transition: background-color 0.1s ease, color 0.1s ease;
|
|
47
|
+
|
|
48
|
+
&:hover:not(:disabled) {
|
|
49
|
+
background-color: var(--fui-bg-hover);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
&:focus-visible {
|
|
53
|
+
@include focus-ring;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Active page
|
|
58
|
+
.itemActive {
|
|
59
|
+
background-color: var(--fui-color-accent, $fui-color-accent);
|
|
60
|
+
color: var(--fui-text-inverse, $fui-text-inverse);
|
|
61
|
+
|
|
62
|
+
&:hover:not(:disabled) {
|
|
63
|
+
background-color: var(--fui-color-accent-hover, $fui-color-accent-hover);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Disabled state (prev/next at boundaries)
|
|
68
|
+
.itemDisabled {
|
|
69
|
+
opacity: 0.5;
|
|
70
|
+
cursor: not-allowed;
|
|
71
|
+
pointer-events: none;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ============================================
|
|
75
|
+
// Nav buttons (Previous/Next)
|
|
76
|
+
// ============================================
|
|
77
|
+
|
|
78
|
+
.navButton {
|
|
79
|
+
svg {
|
|
80
|
+
width: 1rem;
|
|
81
|
+
height: 1rem;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ============================================
|
|
86
|
+
// Ellipsis
|
|
87
|
+
// ============================================
|
|
88
|
+
|
|
89
|
+
.ellipsis {
|
|
90
|
+
display: inline-flex;
|
|
91
|
+
align-items: center;
|
|
92
|
+
justify-content: center;
|
|
93
|
+
min-width: 2rem;
|
|
94
|
+
height: 2rem;
|
|
95
|
+
font-size: var(--fui-font-size-sm, 0.875rem);
|
|
96
|
+
color: var(--fui-text-secondary);
|
|
97
|
+
user-select: none;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ============================================
|
|
101
|
+
// Accessibility: High Contrast Mode
|
|
102
|
+
// ============================================
|
|
103
|
+
|
|
104
|
+
@media (prefers-contrast: more) {
|
|
105
|
+
.itemActive {
|
|
106
|
+
outline: 2px solid var(--fui-color-accent);
|
|
107
|
+
outline-offset: -2px;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Pagination } from './index';
|
|
4
|
+
|
|
5
|
+
function renderPagination(props: Partial<React.ComponentProps<typeof Pagination>> = {}) {
|
|
6
|
+
return render(
|
|
7
|
+
<Pagination totalPages={10} defaultPage={1} {...props}>
|
|
8
|
+
<Pagination.Previous />
|
|
9
|
+
<Pagination.Items />
|
|
10
|
+
<Pagination.Next />
|
|
11
|
+
</Pagination>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('Pagination', () => {
|
|
16
|
+
it('renders correct page range', () => {
|
|
17
|
+
renderPagination({ totalPages: 5 });
|
|
18
|
+
|
|
19
|
+
expect(screen.getByLabelText('Go to page 1')).toBeInTheDocument();
|
|
20
|
+
expect(screen.getByLabelText('Go to page 2')).toBeInTheDocument();
|
|
21
|
+
expect(screen.getByLabelText('Go to page 3')).toBeInTheDocument();
|
|
22
|
+
expect(screen.getByLabelText('Go to page 4')).toBeInTheDocument();
|
|
23
|
+
expect(screen.getByLabelText('Go to page 5')).toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('current page is highlighted', () => {
|
|
27
|
+
renderPagination({ defaultPage: 3, totalPages: 5 });
|
|
28
|
+
|
|
29
|
+
const page3 = screen.getByLabelText('Go to page 3');
|
|
30
|
+
expect(page3).toHaveAttribute('aria-current', 'page');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('click page changes selection', async () => {
|
|
34
|
+
const user = userEvent.setup();
|
|
35
|
+
const onPageChange = vi.fn();
|
|
36
|
+
renderPagination({ totalPages: 5, onPageChange });
|
|
37
|
+
|
|
38
|
+
await user.click(screen.getByLabelText('Go to page 3'));
|
|
39
|
+
expect(onPageChange).toHaveBeenCalledWith(3);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('Previous/Next buttons work', async () => {
|
|
43
|
+
const user = userEvent.setup();
|
|
44
|
+
const onPageChange = vi.fn();
|
|
45
|
+
renderPagination({ totalPages: 5, defaultPage: 3, onPageChange });
|
|
46
|
+
|
|
47
|
+
await user.click(screen.getByLabelText('Go to previous page'));
|
|
48
|
+
expect(onPageChange).toHaveBeenCalledWith(2);
|
|
49
|
+
|
|
50
|
+
await user.click(screen.getByLabelText('Go to next page'));
|
|
51
|
+
// After clicking prev (now on 2), clicking next goes to 3
|
|
52
|
+
expect(onPageChange).toHaveBeenCalledWith(3);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('Previous disabled on page 1', () => {
|
|
56
|
+
renderPagination({ defaultPage: 1, totalPages: 5 });
|
|
57
|
+
|
|
58
|
+
expect(screen.getByLabelText('Go to previous page')).toBeDisabled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('Next disabled on last page', () => {
|
|
62
|
+
renderPagination({ defaultPage: 5, totalPages: 5 });
|
|
63
|
+
|
|
64
|
+
expect(screen.getByLabelText('Go to next page')).toBeDisabled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('ellipsis renders for large ranges', () => {
|
|
68
|
+
renderPagination({ totalPages: 20, defaultPage: 10 });
|
|
69
|
+
|
|
70
|
+
const ellipses = document.querySelectorAll('[aria-hidden="true"]');
|
|
71
|
+
// Should have at least one ellipsis (excluding SVG icons)
|
|
72
|
+
const textEllipses = Array.from(ellipses).filter(
|
|
73
|
+
(el) => el.tagName !== 'svg' && el.textContent === '\u2026'
|
|
74
|
+
);
|
|
75
|
+
expect(textEllipses.length).toBeGreaterThan(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('edge count customization', () => {
|
|
79
|
+
renderPagination({ totalPages: 20, defaultPage: 10, edgeCount: 2 });
|
|
80
|
+
|
|
81
|
+
// With edgeCount=2, pages 1,2 and 19,20 should show
|
|
82
|
+
expect(screen.getByLabelText('Go to page 1')).toBeInTheDocument();
|
|
83
|
+
expect(screen.getByLabelText('Go to page 2')).toBeInTheDocument();
|
|
84
|
+
expect(screen.getByLabelText('Go to page 19')).toBeInTheDocument();
|
|
85
|
+
expect(screen.getByLabelText('Go to page 20')).toBeInTheDocument();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('sibling count customization', () => {
|
|
89
|
+
renderPagination({ totalPages: 20, defaultPage: 10, siblingCount: 2 });
|
|
90
|
+
|
|
91
|
+
// With siblingCount=2, pages 8,9,10,11,12 should show
|
|
92
|
+
expect(screen.getByLabelText('Go to page 8')).toBeInTheDocument();
|
|
93
|
+
expect(screen.getByLabelText('Go to page 9')).toBeInTheDocument();
|
|
94
|
+
expect(screen.getByLabelText('Go to page 10')).toBeInTheDocument();
|
|
95
|
+
expect(screen.getByLabelText('Go to page 11')).toBeInTheDocument();
|
|
96
|
+
expect(screen.getByLabelText('Go to page 12')).toBeInTheDocument();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('controlled mode (page prop)', async () => {
|
|
100
|
+
const user = userEvent.setup();
|
|
101
|
+
const onPageChange = vi.fn();
|
|
102
|
+
const { rerender } = render(
|
|
103
|
+
<Pagination totalPages={5} page={3} onPageChange={onPageChange}>
|
|
104
|
+
<Pagination.Previous />
|
|
105
|
+
<Pagination.Items />
|
|
106
|
+
<Pagination.Next />
|
|
107
|
+
</Pagination>
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(screen.getByLabelText('Go to page 3')).toHaveAttribute('aria-current', 'page');
|
|
111
|
+
|
|
112
|
+
await user.click(screen.getByLabelText('Go to page 5'));
|
|
113
|
+
expect(onPageChange).toHaveBeenCalledWith(5);
|
|
114
|
+
|
|
115
|
+
// Re-render with updated page prop
|
|
116
|
+
rerender(
|
|
117
|
+
<Pagination totalPages={5} page={5} onPageChange={onPageChange}>
|
|
118
|
+
<Pagination.Previous />
|
|
119
|
+
<Pagination.Items />
|
|
120
|
+
<Pagination.Next />
|
|
121
|
+
</Pagination>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(screen.getByLabelText('Go to page 5')).toHaveAttribute('aria-current', 'page');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('keyboard navigation (Tab between buttons)', async () => {
|
|
128
|
+
const user = userEvent.setup();
|
|
129
|
+
renderPagination({ totalPages: 3 });
|
|
130
|
+
|
|
131
|
+
const prevButton = screen.getByLabelText('Go to previous page');
|
|
132
|
+
prevButton.focus();
|
|
133
|
+
|
|
134
|
+
await user.tab();
|
|
135
|
+
// Focus should move to a page button
|
|
136
|
+
expect(document.activeElement?.getAttribute('aria-label')).toMatch(/Go to page/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('totalPages=0 renders empty nav', () => {
|
|
140
|
+
const { container } = renderPagination({ totalPages: 0 });
|
|
141
|
+
|
|
142
|
+
expect(container.querySelector('nav')).toBeInTheDocument();
|
|
143
|
+
expect(container.querySelector('ul')).not.toBeInTheDocument();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('totalPages=1 renders single page, no prev/next disabled correctly', () => {
|
|
147
|
+
renderPagination({ totalPages: 1 });
|
|
148
|
+
|
|
149
|
+
expect(screen.getByLabelText('Go to page 1')).toBeInTheDocument();
|
|
150
|
+
expect(screen.getByLabelText('Go to previous page')).toBeDisabled();
|
|
151
|
+
expect(screen.getByLabelText('Go to next page')).toBeDisabled();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('out-of-range controlled page clamps to valid range', () => {
|
|
155
|
+
render(
|
|
156
|
+
<Pagination totalPages={5} page={99}>
|
|
157
|
+
<Pagination.Previous />
|
|
158
|
+
<Pagination.Items />
|
|
159
|
+
<Pagination.Next />
|
|
160
|
+
</Pagination>
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(screen.getByLabelText('Go to page 5')).toHaveAttribute('aria-current', 'page');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('has no accessibility violations', async () => {
|
|
167
|
+
const { container } = renderPagination({ totalPages: 10, defaultPage: 5 });
|
|
168
|
+
|
|
169
|
+
await expectNoA11yViolations(container);
|
|
170
|
+
});
|
|
171
|
+
});
|