@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.
Files changed (116) hide show
  1. package/fragments.json +1 -1
  2. package/package.json +2 -2
  3. package/src/assets/fragments-logo.tsx +9 -8
  4. package/src/blocks/AccountSettings.block.ts +1 -1
  5. package/src/blocks/ActivityFeed.block.ts +1 -1
  6. package/src/blocks/ChatInterface.block.ts +1 -1
  7. package/src/blocks/ChatMessages.block.ts +1 -1
  8. package/src/blocks/CheckoutForm.block.ts +1 -1
  9. package/src/blocks/CommandPalette.block.ts +34 -0
  10. package/src/blocks/ContactForm.block.ts +1 -1
  11. package/src/blocks/DashboardLayout.block.ts +1 -1
  12. package/src/blocks/DashboardPage.block.ts +1 -1
  13. package/src/blocks/DataTable.block.ts +1 -1
  14. package/src/blocks/EmptyState.block.ts +1 -1
  15. package/src/blocks/FAQSection.block.ts +1 -1
  16. package/src/blocks/FeatureGrid.block.ts +1 -1
  17. package/src/blocks/HeroSection.block.ts +1 -1
  18. package/src/blocks/LoginForm.block.ts +1 -1
  19. package/src/blocks/NavigationHeader.block.ts +1 -1
  20. package/src/blocks/PaginatedTable.block.ts +36 -0
  21. package/src/blocks/PricingComparison.block.ts +1 -1
  22. package/src/blocks/ProductCard.block.ts +1 -1
  23. package/src/blocks/RegistrationForm.block.ts +1 -1
  24. package/src/blocks/SettingsDrawer.block.ts +47 -0
  25. package/src/blocks/SettingsPanel.block.ts +1 -1
  26. package/src/blocks/ShoppingCart.block.ts +1 -1
  27. package/src/blocks/StatsCard.block.ts +1 -1
  28. package/src/blocks/ThinkingStates.block.ts +1 -1
  29. package/src/components/Accordion/Accordion.fragment.tsx +1 -1
  30. package/src/components/Alert/Alert.fragment.tsx +1 -1
  31. package/src/components/AppShell/AppShell.fragment.tsx +11 -11
  32. package/src/components/Avatar/Avatar.fragment.tsx +1 -1
  33. package/src/components/Badge/Badge.fragment.tsx +1 -1
  34. package/src/components/Box/Box.fragment.tsx +1 -1
  35. package/src/components/Breadcrumbs/Breadcrumbs.fragment.tsx +1 -1
  36. package/src/components/Button/Button.fragment.tsx +1 -1
  37. package/src/components/ButtonGroup/ButtonGroup.fragment.tsx +1 -1
  38. package/src/components/Card/Card.fragment.tsx +1 -1
  39. package/src/components/Chart/Chart.fragment.tsx +1 -1
  40. package/src/components/Checkbox/Checkbox.fragment.tsx +1 -1
  41. package/src/components/Chip/Chip.fragment.tsx +1 -1
  42. package/src/components/CodeBlock/CodeBlock.fragment.tsx +1 -1
  43. package/src/components/Collapsible/Collapsible.fragment.tsx +1 -1
  44. package/src/components/ColorPicker/ColorPicker.fragment.tsx +1 -1
  45. package/src/components/Combobox/Combobox.fragment.tsx +1 -1
  46. package/src/components/Command/Command.fragment.tsx +237 -0
  47. package/src/components/Command/Command.module.scss +153 -0
  48. package/src/components/Command/Command.test.tsx +363 -0
  49. package/src/components/Command/index.tsx +502 -0
  50. package/src/components/ConversationList/ConversationList.fragment.tsx +1 -1
  51. package/src/components/DatePicker/DatePicker.fragment.tsx +10 -9
  52. package/src/components/Dialog/Dialog.fragment.tsx +1 -1
  53. package/src/components/Drawer/Drawer.fragment.tsx +206 -0
  54. package/src/components/Drawer/Drawer.module.scss +215 -0
  55. package/src/components/Drawer/Drawer.test.tsx +227 -0
  56. package/src/components/Drawer/index.tsx +239 -0
  57. package/src/components/EmptyState/EmptyState.fragment.tsx +1 -1
  58. package/src/components/Field/Field.fragment.tsx +1 -1
  59. package/src/components/Fieldset/Fieldset.fragment.tsx +1 -1
  60. package/src/components/Form/Form.fragment.tsx +1 -1
  61. package/src/components/Grid/Grid.fragment.tsx +1 -1
  62. package/src/components/Header/Header.fragment.tsx +1 -1
  63. package/src/components/Icon/Icon.fragment.tsx +1 -1
  64. package/src/components/Image/Image.fragment.tsx +1 -1
  65. package/src/components/Input/Input.fragment.tsx +1 -1
  66. package/src/components/Link/Link.fragment.tsx +1 -1
  67. package/src/components/List/List.fragment.tsx +1 -1
  68. package/src/components/Listbox/Listbox.fragment.tsx +1 -1
  69. package/src/components/Loading/Loading.fragment.tsx +1 -1
  70. package/src/components/Markdown/Markdown.fragment.tsx +1 -1
  71. package/src/components/Menu/Menu.fragment.tsx +55 -5
  72. package/src/components/Menu/Menu.module.scss +21 -10
  73. package/src/components/Menu/Menu.test.tsx +126 -3
  74. package/src/components/Menu/index.tsx +85 -11
  75. package/src/components/Message/Message.fragment.tsx +1 -1
  76. package/src/components/Message/Message.module.scss +2 -1
  77. package/src/components/NavigationMenu/NavigationMenu.fragment.tsx +1 -1
  78. package/src/components/Pagination/Pagination.fragment.tsx +152 -0
  79. package/src/components/Pagination/Pagination.module.scss +109 -0
  80. package/src/components/Pagination/Pagination.test.tsx +171 -0
  81. package/src/components/Pagination/index.tsx +360 -0
  82. package/src/components/Popover/Popover.fragment.tsx +1 -1
  83. package/src/components/Progress/Progress.fragment.tsx +1 -1
  84. package/src/components/Prompt/Prompt.fragment.tsx +1 -1
  85. package/src/components/RadioGroup/RadioGroup.fragment.tsx +1 -1
  86. package/src/components/ScrollArea/ScrollArea.fragment.tsx +1 -1
  87. package/src/components/Select/Select.fragment.tsx +1 -1
  88. package/src/components/Separator/Separator.fragment.tsx +1 -1
  89. package/src/components/Sidebar/Sidebar.fragment.tsx +2 -2
  90. package/src/components/Skeleton/Skeleton.fragment.tsx +1 -1
  91. package/src/components/Slider/Slider.fragment.tsx +1 -1
  92. package/src/components/Stack/Stack.fragment.tsx +1 -1
  93. package/src/components/Table/Table.fragment.tsx +1 -1
  94. package/src/components/TableOfContents/TableOfContents.fragment.tsx +1 -1
  95. package/src/components/Tabs/Tabs.fragment.tsx +1 -1
  96. package/src/components/Text/Text.fragment.tsx +1 -1
  97. package/src/components/Textarea/Textarea.fragment.tsx +1 -1
  98. package/src/components/Theme/Theme.fragment.tsx +1 -1
  99. package/src/components/ThinkingIndicator/ThinkingIndicator.fragment.tsx +1 -1
  100. package/src/components/Toast/Toast.fragment.tsx +1 -1
  101. package/src/components/Toggle/Toggle.fragment.tsx +1 -1
  102. package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +1 -1
  103. package/src/components/Tooltip/Tooltip.fragment.tsx +1 -1
  104. package/src/components/Tooltip/index.tsx +25 -1
  105. package/src/components/VisuallyHidden/VisuallyHidden.fragment.tsx +1 -1
  106. package/src/index.ts +34 -1
  107. package/src/styles/globals.scss +65 -7
  108. package/src/tokens/_computed.scss +1 -1
  109. package/src/tokens/_density.scss +1 -1
  110. package/src/tokens/_derive.scss +1 -1
  111. package/src/tokens/_index.scss +1 -1
  112. package/src/tokens/_mixins.scss +1 -1
  113. package/src/tokens/_palettes.scss +1 -1
  114. package/src/tokens/_radius.scss +1 -1
  115. package/src/tokens/_seeds.scss +1 -1
  116. 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 CheckIcon() {
99
+ function CheckmarkIcon() {
84
100
  return (
85
101
  <svg
86
102
  xmlns="http://www.w3.org/2000/svg"
87
- width="12"
88
- height="12"
103
+ width="14"
104
+ height="14"
89
105
  viewBox="0 0 24 24"
90
106
  fill="none"
91
107
  stroke="currentColor"
92
- strokeWidth="3"
108
+ strokeWidth={3}
93
109
  strokeLinecap="round"
94
110
  strokeLinejoin="round"
95
111
  aria-hidden="true"
96
112
  >
97
- <polyline points="20 6 9 17 4 12" />
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={checked}
259
+ checked={checkedProp}
225
260
  defaultChecked={defaultChecked}
226
- onCheckedChange={onCheckedChange}
261
+ onCheckedChange={handleCheckedChange}
227
262
  disabled={disabled}
228
263
  className={classes}
229
264
  >
230
- <span className={styles.itemIndicator}>
231
- <CheckIcon />
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.itemIndicator}>
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
  };
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { defineFragment } from '@fragments/core';
2
+ import { defineFragment } from '@fragments-sdk/cli/core';
3
3
  import { Message } from '.';
4
4
 
5
5
  export default defineFragment({
@@ -36,7 +36,7 @@
36
36
 
37
37
  .content {
38
38
  background-color: var(--fui-color-accent, $fui-color-accent);
39
- color: var(--fui-color-danger-text, $fui-color-danger-text);
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
 
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { defineFragment } from '@fragments/core';
2
+ import { defineFragment } from '@fragments-sdk/cli/core';
3
3
  import { NavigationMenu } from '.';
4
4
 
5
5
  export default defineFragment({
@@ -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
+ });