@fragments-sdk/ui 0.8.6 → 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/AccountSettings.block.ts +1 -1
- package/src/blocks/ActivityFeed.block.ts +1 -1
- package/src/blocks/ChatInterface.block.ts +1 -1
- package/src/blocks/ChatMessages.block.ts +1 -1
- package/src/blocks/CheckoutForm.block.ts +1 -1
- package/src/blocks/CommandPalette.block.ts +34 -0
- package/src/blocks/ContactForm.block.ts +1 -1
- package/src/blocks/DashboardLayout.block.ts +1 -1
- package/src/blocks/DashboardPage.block.ts +1 -1
- package/src/blocks/DataTable.block.ts +1 -1
- package/src/blocks/EmptyState.block.ts +1 -1
- package/src/blocks/FAQSection.block.ts +1 -1
- package/src/blocks/FeatureGrid.block.ts +1 -1
- package/src/blocks/HeroSection.block.ts +1 -1
- package/src/blocks/LoginForm.block.ts +1 -1
- package/src/blocks/NavigationHeader.block.ts +1 -1
- package/src/blocks/PaginatedTable.block.ts +36 -0
- package/src/blocks/PricingComparison.block.ts +1 -1
- package/src/blocks/ProductCard.block.ts +1 -1
- package/src/blocks/RegistrationForm.block.ts +1 -1
- package/src/blocks/SettingsDrawer.block.ts +47 -0
- package/src/blocks/SettingsPanel.block.ts +1 -1
- package/src/blocks/ShoppingCart.block.ts +1 -1
- package/src/blocks/StatsCard.block.ts +1 -1
- package/src/blocks/ThinkingStates.block.ts +1 -1
- package/src/components/Accordion/Accordion.fragment.tsx +1 -1
- package/src/components/Alert/Alert.fragment.tsx +1 -1
- package/src/components/AppShell/AppShell.fragment.tsx +11 -11
- package/src/components/Avatar/Avatar.fragment.tsx +1 -1
- package/src/components/Badge/Badge.fragment.tsx +1 -1
- package/src/components/Box/Box.fragment.tsx +1 -1
- package/src/components/Breadcrumbs/Breadcrumbs.fragment.tsx +1 -1
- package/src/components/Button/Button.fragment.tsx +1 -1
- package/src/components/ButtonGroup/ButtonGroup.fragment.tsx +1 -1
- package/src/components/Card/Card.fragment.tsx +1 -1
- package/src/components/Chart/Chart.fragment.tsx +1 -1
- package/src/components/Checkbox/Checkbox.fragment.tsx +1 -1
- package/src/components/Chip/Chip.fragment.tsx +1 -1
- package/src/components/CodeBlock/CodeBlock.fragment.tsx +1 -1
- package/src/components/Collapsible/Collapsible.fragment.tsx +1 -1
- package/src/components/ColorPicker/ColorPicker.fragment.tsx +1 -1
- package/src/components/Combobox/Combobox.fragment.tsx +1 -1
- 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/ConversationList/ConversationList.fragment.tsx +1 -1
- package/src/components/DatePicker/DatePicker.fragment.tsx +10 -9
- package/src/components/Dialog/Dialog.fragment.tsx +1 -1
- 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/EmptyState/EmptyState.fragment.tsx +1 -1
- package/src/components/Field/Field.fragment.tsx +1 -1
- package/src/components/Fieldset/Fieldset.fragment.tsx +1 -1
- package/src/components/Form/Form.fragment.tsx +1 -1
- package/src/components/Grid/Grid.fragment.tsx +1 -1
- package/src/components/Header/Header.fragment.tsx +1 -1
- package/src/components/Icon/Icon.fragment.tsx +1 -1
- package/src/components/Image/Image.fragment.tsx +1 -1
- package/src/components/Input/Input.fragment.tsx +1 -1
- package/src/components/Link/Link.fragment.tsx +1 -1
- package/src/components/List/List.fragment.tsx +1 -1
- package/src/components/Listbox/Listbox.fragment.tsx +1 -1
- package/src/components/Loading/Loading.fragment.tsx +1 -1
- package/src/components/Markdown/Markdown.fragment.tsx +1 -1
- package/src/components/Menu/Menu.fragment.tsx +55 -5
- package/src/components/Menu/Menu.module.scss +21 -10
- package/src/components/Menu/Menu.test.tsx +126 -3
- package/src/components/Menu/index.tsx +85 -11
- package/src/components/Message/Message.fragment.tsx +1 -1
- package/src/components/Message/Message.module.scss +2 -1
- package/src/components/NavigationMenu/NavigationMenu.fragment.tsx +1 -1
- 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/Popover/Popover.fragment.tsx +1 -1
- package/src/components/Progress/Progress.fragment.tsx +1 -1
- package/src/components/Prompt/Prompt.fragment.tsx +1 -1
- package/src/components/RadioGroup/RadioGroup.fragment.tsx +1 -1
- package/src/components/ScrollArea/ScrollArea.fragment.tsx +1 -1
- package/src/components/Select/Select.fragment.tsx +1 -1
- package/src/components/Separator/Separator.fragment.tsx +1 -1
- package/src/components/Sidebar/Sidebar.fragment.tsx +2 -2
- package/src/components/Skeleton/Skeleton.fragment.tsx +1 -1
- package/src/components/Slider/Slider.fragment.tsx +1 -1
- package/src/components/Stack/Stack.fragment.tsx +1 -1
- package/src/components/Table/Table.fragment.tsx +1 -1
- package/src/components/TableOfContents/TableOfContents.fragment.tsx +1 -1
- package/src/components/Tabs/Tabs.fragment.tsx +1 -1
- package/src/components/Text/Text.fragment.tsx +1 -1
- package/src/components/Textarea/Textarea.fragment.tsx +1 -1
- package/src/components/Theme/Theme.fragment.tsx +1 -1
- package/src/components/ThinkingIndicator/ThinkingIndicator.fragment.tsx +1 -1
- package/src/components/Toast/Toast.fragment.tsx +1 -1
- package/src/components/Toggle/Toggle.fragment.tsx +1 -1
- package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +1 -1
- package/src/components/Tooltip/Tooltip.fragment.tsx +1 -1
- package/src/components/Tooltip/index.tsx +25 -1
- package/src/components/VisuallyHidden/VisuallyHidden.fragment.tsx +1 -1
- package/src/index.ts +34 -1
- package/src/styles/globals.scss +65 -7
- package/src/tokens/_computed.scss +1 -1
- package/src/tokens/_density.scss +1 -1
- package/src/tokens/_derive.scss +1 -1
- package/src/tokens/_index.scss +1 -1
- package/src/tokens/_mixins.scss +1 -1
- package/src/tokens/_palettes.scss +1 -1
- package/src/tokens/_radius.scss +1 -1
- package/src/tokens/_seeds.scss +1 -1
- package/src/tokens/_variables.scss +2 -2
|
@@ -37,6 +37,8 @@ export interface MenuItemProps {
|
|
|
37
37
|
className?: string;
|
|
38
38
|
icon?: React.ReactNode;
|
|
39
39
|
shortcut?: string;
|
|
40
|
+
/** When passed, renders a check indicator. `true` shows a checkmark, `false` reserves space. */
|
|
41
|
+
checked?: boolean;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export interface MenuCheckboxItemProps {
|
|
@@ -76,25 +78,39 @@ export interface MenuSeparatorProps {
|
|
|
76
78
|
className?: string;
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
export interface MenuSubmenuProps {
|
|
82
|
+
children: React.ReactNode;
|
|
83
|
+
open?: boolean;
|
|
84
|
+
defaultOpen?: boolean;
|
|
85
|
+
onOpenChange?: (open: boolean) => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface MenuSubmenuTriggerProps {
|
|
89
|
+
children: React.ReactNode;
|
|
90
|
+
disabled?: boolean;
|
|
91
|
+
className?: string;
|
|
92
|
+
icon?: React.ReactNode;
|
|
93
|
+
}
|
|
94
|
+
|
|
79
95
|
// ============================================
|
|
80
96
|
// Icons
|
|
81
97
|
// ============================================
|
|
82
98
|
|
|
83
|
-
function
|
|
99
|
+
function CheckmarkIcon() {
|
|
84
100
|
return (
|
|
85
101
|
<svg
|
|
86
102
|
xmlns="http://www.w3.org/2000/svg"
|
|
87
|
-
width="
|
|
88
|
-
height="
|
|
103
|
+
width="14"
|
|
104
|
+
height="14"
|
|
89
105
|
viewBox="0 0 24 24"
|
|
90
106
|
fill="none"
|
|
91
107
|
stroke="currentColor"
|
|
92
|
-
strokeWidth=
|
|
108
|
+
strokeWidth={3}
|
|
93
109
|
strokeLinecap="round"
|
|
94
110
|
strokeLinejoin="round"
|
|
95
111
|
aria-hidden="true"
|
|
96
112
|
>
|
|
97
|
-
<
|
|
113
|
+
<path d="M5 13l4 4L19 7" />
|
|
98
114
|
</svg>
|
|
99
115
|
);
|
|
100
116
|
}
|
|
@@ -187,7 +203,9 @@ function MenuItem({
|
|
|
187
203
|
className,
|
|
188
204
|
icon,
|
|
189
205
|
shortcut,
|
|
206
|
+
checked,
|
|
190
207
|
}: MenuItemProps) {
|
|
208
|
+
const hasChecked = checked !== undefined;
|
|
191
209
|
const classes = [
|
|
192
210
|
styles.item,
|
|
193
211
|
danger && styles.itemDanger,
|
|
@@ -200,6 +218,11 @@ function MenuItem({
|
|
|
200
218
|
onClick={onSelect}
|
|
201
219
|
className={classes}
|
|
202
220
|
>
|
|
221
|
+
{hasChecked && (
|
|
222
|
+
<span className={styles.checkIndicator}>
|
|
223
|
+
{checked ? <CheckmarkIcon /> : null}
|
|
224
|
+
</span>
|
|
225
|
+
)}
|
|
203
226
|
{icon && <span className={styles.itemIcon}>{icon}</span>}
|
|
204
227
|
<span className={styles.itemLabel}>{children}</span>
|
|
205
228
|
{shortcut && <span className={styles.itemShortcut}>{shortcut}</span>}
|
|
@@ -209,26 +232,38 @@ function MenuItem({
|
|
|
209
232
|
|
|
210
233
|
function MenuCheckboxItem({
|
|
211
234
|
children,
|
|
212
|
-
checked,
|
|
235
|
+
checked: checkedProp,
|
|
213
236
|
defaultChecked,
|
|
214
237
|
onCheckedChange,
|
|
215
238
|
disabled,
|
|
216
239
|
className,
|
|
217
240
|
}: MenuCheckboxItemProps) {
|
|
241
|
+
const isControlled = checkedProp !== undefined;
|
|
242
|
+
const [internalChecked, setInternalChecked] = React.useState(defaultChecked ?? false);
|
|
243
|
+
const visualChecked = isControlled ? checkedProp : internalChecked;
|
|
244
|
+
|
|
245
|
+
const handleCheckedChange = React.useCallback(
|
|
246
|
+
(value: boolean) => {
|
|
247
|
+
if (!isControlled) setInternalChecked(value);
|
|
248
|
+
onCheckedChange?.(value);
|
|
249
|
+
},
|
|
250
|
+
[isControlled, onCheckedChange],
|
|
251
|
+
);
|
|
252
|
+
|
|
218
253
|
const classes = [styles.item, styles.checkboxItem, className]
|
|
219
254
|
.filter(Boolean)
|
|
220
255
|
.join(' ');
|
|
221
256
|
|
|
222
257
|
return (
|
|
223
258
|
<BaseMenu.CheckboxItem
|
|
224
|
-
checked={
|
|
259
|
+
checked={checkedProp}
|
|
225
260
|
defaultChecked={defaultChecked}
|
|
226
|
-
onCheckedChange={
|
|
261
|
+
onCheckedChange={handleCheckedChange}
|
|
227
262
|
disabled={disabled}
|
|
228
263
|
className={classes}
|
|
229
264
|
>
|
|
230
|
-
<span className={styles.
|
|
231
|
-
<
|
|
265
|
+
<span className={styles.checkIndicator}>
|
|
266
|
+
{visualChecked ? <CheckmarkIcon /> : null}
|
|
232
267
|
</span>
|
|
233
268
|
<span className={styles.itemLabel}>{children}</span>
|
|
234
269
|
</BaseMenu.CheckboxItem>
|
|
@@ -264,7 +299,7 @@ function MenuRadioItem({
|
|
|
264
299
|
|
|
265
300
|
return (
|
|
266
301
|
<BaseMenu.RadioItem value={value} disabled={disabled} className={classes}>
|
|
267
|
-
<span className={styles.
|
|
302
|
+
<span className={styles.radioIndicator}>
|
|
268
303
|
<DotIcon />
|
|
269
304
|
</span>
|
|
270
305
|
<span className={styles.itemLabel}>{children}</span>
|
|
@@ -287,6 +322,41 @@ function MenuSeparator({ className }: MenuSeparatorProps) {
|
|
|
287
322
|
return <BaseMenu.Separator className={classes} />;
|
|
288
323
|
}
|
|
289
324
|
|
|
325
|
+
function MenuSubmenu({
|
|
326
|
+
children,
|
|
327
|
+
open,
|
|
328
|
+
defaultOpen,
|
|
329
|
+
onOpenChange,
|
|
330
|
+
}: MenuSubmenuProps) {
|
|
331
|
+
return (
|
|
332
|
+
<BaseMenu.SubmenuRoot
|
|
333
|
+
open={open}
|
|
334
|
+
defaultOpen={defaultOpen}
|
|
335
|
+
onOpenChange={onOpenChange as any}
|
|
336
|
+
>
|
|
337
|
+
{children}
|
|
338
|
+
</BaseMenu.SubmenuRoot>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function MenuSubmenuTrigger({
|
|
343
|
+
children,
|
|
344
|
+
disabled,
|
|
345
|
+
className,
|
|
346
|
+
icon,
|
|
347
|
+
}: MenuSubmenuTriggerProps) {
|
|
348
|
+
const classes = [styles.item, styles.submenuTrigger, className]
|
|
349
|
+
.filter(Boolean)
|
|
350
|
+
.join(' ');
|
|
351
|
+
|
|
352
|
+
return (
|
|
353
|
+
<BaseMenu.SubmenuTrigger disabled={disabled} className={classes}>
|
|
354
|
+
{icon && <span className={styles.itemIcon}>{icon}</span>}
|
|
355
|
+
<span className={styles.itemLabel}>{children}</span>
|
|
356
|
+
</BaseMenu.SubmenuTrigger>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
290
360
|
// ============================================
|
|
291
361
|
// Export compound component
|
|
292
362
|
// ============================================
|
|
@@ -301,6 +371,8 @@ export const Menu = Object.assign(MenuRoot, {
|
|
|
301
371
|
Group: MenuGroup,
|
|
302
372
|
GroupLabel: MenuGroupLabel,
|
|
303
373
|
Separator: MenuSeparator,
|
|
374
|
+
Submenu: MenuSubmenu,
|
|
375
|
+
SubmenuTrigger: MenuSubmenuTrigger,
|
|
304
376
|
});
|
|
305
377
|
|
|
306
378
|
// Re-export individual components
|
|
@@ -315,4 +387,6 @@ export {
|
|
|
315
387
|
MenuGroup,
|
|
316
388
|
MenuGroupLabel,
|
|
317
389
|
MenuSeparator,
|
|
390
|
+
MenuSubmenu,
|
|
391
|
+
MenuSubmenuTrigger,
|
|
318
392
|
};
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
.content {
|
|
38
38
|
background-color: var(--fui-color-accent, $fui-color-accent);
|
|
39
|
-
color: var(--fui-
|
|
39
|
+
color: var(--fui-text-inverse, $fui-text-inverse);
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
.content {
|
|
69
69
|
background-color: var(--fui-color-danger-bg, $fui-color-danger-bg);
|
|
70
70
|
border: 1px solid var(--fui-color-danger, $fui-color-danger);
|
|
71
|
+
color: var(--fui-color-danger-text, $fui-color-danger-text);
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
@@ -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
|
+
});
|