@sovereignfs/ui 0.40.0 → 0.45.0

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 (186) hide show
  1. package/dist/Accordion.module.css +3 -0
  2. package/dist/Alert.module.css +62 -0
  3. package/dist/AspectRatio.module.css +15 -0
  4. package/dist/Badge.module.css +75 -9
  5. package/dist/BalanceChip.module.css +32 -0
  6. package/dist/Breadcrumb.module.css +49 -0
  7. package/dist/Button.module.css +12 -3
  8. package/dist/ButtonGroup.module.css +29 -0
  9. package/dist/Card.module.css +6 -0
  10. package/dist/Checkbox.module.css +3 -1
  11. package/dist/Collapsible.module.css +70 -0
  12. package/dist/Combobox.module.css +122 -0
  13. package/dist/Command.module.css +80 -0
  14. package/dist/ConfirmDialog.module.css +5 -1
  15. package/dist/DataTable.module.css +28 -0
  16. package/dist/DatePicker.module.css +6 -2
  17. package/dist/Dialog.module.css +3 -0
  18. package/dist/HoverCard.module.css +3 -0
  19. package/dist/Input.module.css +6 -1
  20. package/dist/Item.module.css +70 -0
  21. package/dist/Kbd.module.css +14 -0
  22. package/dist/Label.module.css +11 -0
  23. package/dist/Marker.module.css +33 -0
  24. package/dist/MemberMultiSelect.module.css +27 -0
  25. package/dist/Menu.module.css +8 -1
  26. package/dist/Message.module.css +99 -0
  27. package/dist/MessageScroller.module.css +54 -0
  28. package/dist/NavigationMenu.module.css +53 -0
  29. package/dist/Pagination.module.css +98 -0
  30. package/dist/Progress.module.css +20 -0
  31. package/dist/RadioGroup.module.css +88 -0
  32. package/dist/Resizable.module.css +70 -0
  33. package/dist/ScrollArea.module.css +20 -0
  34. package/dist/Select.module.css +7 -3
  35. package/dist/Slider.module.css +95 -0
  36. package/dist/Table.module.css +45 -0
  37. package/dist/Tabs.module.css +6 -1
  38. package/dist/Toggle.module.css +6 -1
  39. package/dist/Typography.module.css +47 -0
  40. package/dist/index.d.ts +698 -5
  41. package/dist/index.js +1938 -482
  42. package/dist/tokens/primitives.css +22 -8
  43. package/package.json +1 -1
  44. package/src/components/Accordion/Accordion.module.css +3 -0
  45. package/src/components/Accordion/Accordion.stories.tsx +110 -0
  46. package/src/components/Accordion/Accordion.tsx +53 -0
  47. package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
  48. package/src/components/Alert/Alert.module.css +62 -0
  49. package/src/components/Alert/Alert.stories.tsx +96 -0
  50. package/src/components/Alert/Alert.tsx +58 -0
  51. package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
  52. package/src/components/AspectRatio/AspectRatio.module.css +15 -0
  53. package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
  54. package/src/components/AspectRatio/AspectRatio.tsx +21 -0
  55. package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
  56. package/src/components/Badge/Badge.module.css +75 -9
  57. package/src/components/Badge/Badge.stories.tsx +109 -1
  58. package/src/components/Badge/Badge.tsx +30 -2
  59. package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
  60. package/src/components/BalanceChip/BalanceChip.module.css +32 -0
  61. package/src/components/BalanceChip/BalanceChip.stories.tsx +45 -0
  62. package/src/components/BalanceChip/BalanceChip.tsx +33 -0
  63. package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
  64. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
  65. package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
  66. package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
  67. package/src/components/Button/Button.module.css +12 -3
  68. package/src/components/Button/Button.stories.tsx +3 -2
  69. package/src/components/Button/Button.tsx +1 -1
  70. package/src/components/Button/__tests__/Button.test.tsx +10 -0
  71. package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
  72. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
  73. package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
  74. package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
  75. package/src/components/Card/Card.module.css +6 -0
  76. package/src/components/Checkbox/Checkbox.module.css +3 -1
  77. package/src/components/Collapsible/Collapsible.module.css +70 -0
  78. package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
  79. package/src/components/Collapsible/Collapsible.tsx +66 -0
  80. package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
  81. package/src/components/Combobox/Combobox.module.css +122 -0
  82. package/src/components/Combobox/Combobox.stories.tsx +79 -0
  83. package/src/components/Combobox/Combobox.tsx +186 -0
  84. package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
  85. package/src/components/Command/Command.module.css +80 -0
  86. package/src/components/Command/Command.stories.tsx +104 -0
  87. package/src/components/Command/Command.tsx +168 -0
  88. package/src/components/Command/__tests__/Command.test.tsx +162 -0
  89. package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
  90. package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
  91. package/src/components/ContextMenu/ContextMenu.tsx +100 -0
  92. package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
  93. package/src/components/CurrencyInput/CurrencyInput.stories.tsx +50 -0
  94. package/src/components/CurrencyInput/CurrencyInput.tsx +60 -0
  95. package/src/components/DataTable/DataTable.module.css +28 -0
  96. package/src/components/DataTable/DataTable.stories.tsx +74 -0
  97. package/src/components/DataTable/DataTable.tsx +154 -0
  98. package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
  99. package/src/components/DatePicker/DatePicker.module.css +6 -2
  100. package/src/components/Dialog/Dialog.module.css +3 -0
  101. package/src/components/HoverCard/HoverCard.module.css +3 -0
  102. package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
  103. package/src/components/HoverCard/HoverCard.tsx +111 -0
  104. package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
  105. package/src/components/Icon/icons/circle-check.tsx +21 -0
  106. package/src/components/Icon/icons/circle-x.tsx +22 -0
  107. package/src/components/Icon/icons/index.ts +8 -0
  108. package/src/components/Input/Input.module.css +6 -1
  109. package/src/components/Item/Item.module.css +70 -0
  110. package/src/components/Item/Item.stories.tsx +62 -0
  111. package/src/components/Item/Item.tsx +54 -0
  112. package/src/components/Item/__tests__/Item.test.tsx +39 -0
  113. package/src/components/Kbd/Kbd.module.css +14 -0
  114. package/src/components/Kbd/Kbd.stories.tsx +32 -0
  115. package/src/components/Kbd/Kbd.tsx +12 -0
  116. package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
  117. package/src/components/Label/Label.module.css +11 -0
  118. package/src/components/Label/Label.stories.tsx +40 -0
  119. package/src/components/Label/Label.tsx +24 -0
  120. package/src/components/Label/__tests__/Label.test.tsx +32 -0
  121. package/src/components/Marker/Marker.module.css +33 -0
  122. package/src/components/Marker/Marker.stories.tsx +39 -0
  123. package/src/components/Marker/Marker.tsx +34 -0
  124. package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
  125. package/src/components/MemberMultiSelect/MemberMultiSelect.module.css +27 -0
  126. package/src/components/MemberMultiSelect/MemberMultiSelect.stories.tsx +74 -0
  127. package/src/components/MemberMultiSelect/MemberMultiSelect.tsx +67 -0
  128. package/src/components/Menu/Menu.module.css +8 -1
  129. package/src/components/Menu/Menu.tsx +30 -8
  130. package/src/components/Menubar/Menubar.stories.tsx +52 -0
  131. package/src/components/Menubar/Menubar.tsx +42 -0
  132. package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
  133. package/src/components/Message/Message.module.css +99 -0
  134. package/src/components/Message/Message.stories.tsx +79 -0
  135. package/src/components/Message/Message.tsx +41 -0
  136. package/src/components/Message/__tests__/Message.test.tsx +46 -0
  137. package/src/components/MessageScroller/MessageScroller.module.css +54 -0
  138. package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
  139. package/src/components/MessageScroller/MessageScroller.tsx +72 -0
  140. package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
  141. package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
  142. package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
  143. package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
  144. package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
  145. package/src/components/Pagination/Pagination.module.css +98 -0
  146. package/src/components/Pagination/Pagination.stories.tsx +49 -0
  147. package/src/components/Pagination/Pagination.tsx +87 -0
  148. package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
  149. package/src/components/Progress/Progress.module.css +20 -0
  150. package/src/components/Progress/Progress.stories.tsx +40 -0
  151. package/src/components/Progress/Progress.tsx +35 -0
  152. package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
  153. package/src/components/RadioGroup/RadioGroup.module.css +88 -0
  154. package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
  155. package/src/components/RadioGroup/RadioGroup.tsx +99 -0
  156. package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
  157. package/src/components/Resizable/Resizable.module.css +70 -0
  158. package/src/components/Resizable/Resizable.stories.tsx +82 -0
  159. package/src/components/Resizable/Resizable.tsx +313 -0
  160. package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
  161. package/src/components/ScrollArea/ScrollArea.module.css +20 -0
  162. package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
  163. package/src/components/ScrollArea/ScrollArea.tsx +27 -0
  164. package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
  165. package/src/components/Select/Select.module.css +7 -3
  166. package/src/components/Slider/Slider.module.css +95 -0
  167. package/src/components/Slider/Slider.stories.tsx +64 -0
  168. package/src/components/Slider/Slider.tsx +77 -0
  169. package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
  170. package/src/components/SplitMethodSelector/SplitMethodSelector.stories.tsx +33 -0
  171. package/src/components/SplitMethodSelector/SplitMethodSelector.tsx +43 -0
  172. package/src/components/Table/Table.module.css +45 -0
  173. package/src/components/Table/Table.stories.tsx +125 -0
  174. package/src/components/Table/Table.tsx +51 -0
  175. package/src/components/Table/__tests__/Table.test.tsx +50 -0
  176. package/src/components/Tabs/Tabs.module.css +6 -1
  177. package/src/components/Toggle/Toggle.module.css +6 -1
  178. package/src/components/Typography/Typography.module.css +47 -0
  179. package/src/components/Typography/Typography.stories.tsx +62 -0
  180. package/src/components/Typography/Typography.tsx +30 -0
  181. package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
  182. package/src/index.ts +92 -1
  183. package/src/stories/DesignSystemOverview.stories.tsx +706 -2
  184. package/src/stories/MobilePatterns.stories.tsx +8 -2
  185. package/src/stories/TokenGallery.stories.tsx +112 -16
  186. package/src/tokens/primitives.css +22 -8
@@ -0,0 +1,125 @@
1
+ 'use client';
2
+
3
+ import { useRef, useState } from 'react';
4
+ import type { ReactNode, KeyboardEvent as ReactKeyboardEvent } from 'react';
5
+ import { Popover } from '../Popover/Popover';
6
+ import styles from './NavigationMenu.module.css';
7
+
8
+ export interface NavigationMenuItem {
9
+ label: string;
10
+ /** A plain link with no flyout — mutually exclusive with `content`. */
11
+ href?: string;
12
+ /** Flyout panel content shown when this item is triggered. Pass a
13
+ * function instead of a plain node when the content needs to close its
14
+ * own flyout after an action (e.g. `Menubar`'s menu items) — it receives
15
+ * a `close` callback. */
16
+ content?: ReactNode | ((close: () => void) => ReactNode);
17
+ }
18
+
19
+ export interface NavigationMenuProps {
20
+ items: NavigationMenuItem[];
21
+ /** Renders a plain-link item's anchor. Defaults to `<a href>`, which
22
+ * causes a full page reload — pass Next's `<Link>` (or equivalent) to
23
+ * keep navigation client-side. */
24
+ renderLink?: (href: string, children: ReactNode) => ReactNode;
25
+ 'aria-label'?: string;
26
+ }
27
+
28
+ /**
29
+ * NavigationMenu — top-level nav bar where some items open a flyout panel.
30
+ *
31
+ * Desktop-oriented by design — a hover-triggered flyout bar has no mobile
32
+ * equivalent (Research 0004); it renders as-is on touch rather than
33
+ * attempting a redesign no consumer has asked for yet.
34
+ *
35
+ * Each flyout is its own `Popover` instance rather than one shared
36
+ * positioned panel, reusing its collision detection per item. Once one
37
+ * flyout is open, moving the pointer to a sibling trigger switches directly
38
+ * to that one — standard menu-bar behavior — and ArrowLeft/ArrowRight move
39
+ * focus between top-level triggers (a roving-tabindex bar, per the
40
+ * WAI-ARIA menu pattern), Escape closes.
41
+ */
42
+ export function NavigationMenu({
43
+ items,
44
+ renderLink,
45
+ 'aria-label': ariaLabel,
46
+ }: NavigationMenuProps) {
47
+ const [openIndex, setOpenIndex] = useState<number | null>(null);
48
+ const triggerRefs = useRef<(HTMLButtonElement | null)[]>([]);
49
+
50
+ function handleKeyDown(e: ReactKeyboardEvent, index: number) {
51
+ if (e.key === 'Escape') {
52
+ setOpenIndex(null);
53
+ triggerRefs.current[index]?.focus();
54
+ return;
55
+ }
56
+ if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
57
+ e.preventDefault();
58
+ const direction = e.key === 'ArrowRight' ? 1 : -1;
59
+ const nextIndex = (index + direction + items.length) % items.length;
60
+ triggerRefs.current[nextIndex]?.focus();
61
+ }
62
+ }
63
+
64
+ return (
65
+ <nav aria-label={ariaLabel} className={styles.nav}>
66
+ <ul className={styles.list}>
67
+ {items.map((item, index) => {
68
+ if (!item.content) {
69
+ const linkChildren = item.label;
70
+ return (
71
+ <li key={item.label}>
72
+ {item.href && renderLink ? (
73
+ renderLink(item.href, linkChildren)
74
+ ) : (
75
+ <a href={item.href} className={styles.link}>
76
+ {linkChildren}
77
+ </a>
78
+ )}
79
+ </li>
80
+ );
81
+ }
82
+
83
+ const isOpen = openIndex === index;
84
+ return (
85
+ <li key={item.label}>
86
+ <Popover
87
+ aria-label={item.label}
88
+ open={isOpen}
89
+ onClose={() => setOpenIndex((current) => (current === index ? null : current))}
90
+ trigger={
91
+ <button
92
+ ref={(el) => {
93
+ triggerRefs.current[index] = el;
94
+ }}
95
+ type="button"
96
+ className={[styles.trigger, isOpen ? styles.triggerOpen : '']
97
+ .filter(Boolean)
98
+ .join(' ')}
99
+ aria-expanded={isOpen}
100
+ onClick={() => setOpenIndex((current) => (current === index ? null : index))}
101
+ onMouseEnter={() => {
102
+ // Once a sibling is already open, hovering another
103
+ // trigger switches directly to it — standard menu-bar
104
+ // behavior. Otherwise hovering alone does not open
105
+ // one; that stays an explicit click, since a nav bar
106
+ // (unlike HoverCard) isn't a "preview" surface.
107
+ setOpenIndex((current) => (current !== null ? index : current));
108
+ }}
109
+ onKeyDown={(e) => handleKeyDown(e, index)}
110
+ >
111
+ {item.label}
112
+ </button>
113
+ }
114
+ >
115
+ {typeof item.content === 'function'
116
+ ? item.content(() => setOpenIndex(null))
117
+ : item.content}
118
+ </Popover>
119
+ </li>
120
+ );
121
+ })}
122
+ </ul>
123
+ </nav>
124
+ );
125
+ }
@@ -0,0 +1,93 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen, fireEvent } from '@testing-library/react';
4
+ import { NavigationMenu } from '../NavigationMenu';
5
+
6
+ afterEach(cleanup);
7
+
8
+ const items = [
9
+ { label: 'Home', href: '/home' },
10
+ { label: 'Products', content: <div>Products flyout</div> },
11
+ { label: 'Company', content: <div>Company flyout</div> },
12
+ ];
13
+
14
+ describe('NavigationMenu', () => {
15
+ it('renders a plain link for an item with no content', () => {
16
+ render(<NavigationMenu items={items} aria-label="Main" />);
17
+ const link = screen.getByRole('link', { name: 'Home' });
18
+ expect(link.getAttribute('href')).toBe('/home');
19
+ });
20
+
21
+ it('renders a toggle trigger for an item with content, collapsed by default', () => {
22
+ render(<NavigationMenu items={items} aria-label="Main" />);
23
+ expect(screen.getByRole('button', { name: 'Products' }).getAttribute('aria-expanded')).toBe(
24
+ 'false',
25
+ );
26
+ });
27
+
28
+ it('opens a flyout on trigger click', () => {
29
+ render(<NavigationMenu items={items} aria-label="Main" />);
30
+ fireEvent.click(screen.getByRole('button', { name: 'Products' }));
31
+ expect(screen.getByText('Products flyout')).toBeDefined();
32
+ });
33
+
34
+ it('closes on a second click of the same trigger', () => {
35
+ render(<NavigationMenu items={items} aria-label="Main" />);
36
+ const trigger = screen.getByRole('button', { name: 'Products' });
37
+ fireEvent.click(trigger);
38
+ fireEvent.click(trigger);
39
+ expect(screen.queryByText('Products flyout')).toBeNull();
40
+ });
41
+
42
+ it('switches directly to a sibling on hover once one flyout is already open', () => {
43
+ render(<NavigationMenu items={items} aria-label="Main" />);
44
+ fireEvent.click(screen.getByRole('button', { name: 'Products' }));
45
+ expect(screen.getByText('Products flyout')).toBeDefined();
46
+
47
+ fireEvent.mouseEnter(screen.getByRole('button', { name: 'Company' }));
48
+ expect(screen.getByText('Company flyout')).toBeDefined();
49
+ expect(screen.queryByText('Products flyout')).toBeNull();
50
+ });
51
+
52
+ it('does not open a flyout on hover alone when nothing else is open', () => {
53
+ render(<NavigationMenu items={items} aria-label="Main" />);
54
+ fireEvent.mouseEnter(screen.getByRole('button', { name: 'Products' }));
55
+ expect(screen.queryByText('Products flyout')).toBeNull();
56
+ });
57
+
58
+ it('ArrowRight/ArrowLeft move focus between top-level triggers', () => {
59
+ render(<NavigationMenu items={items} aria-label="Main" />);
60
+ const products = screen.getByRole('button', { name: 'Products' });
61
+ const company = screen.getByRole('button', { name: 'Company' });
62
+ products.focus();
63
+ fireEvent.keyDown(products, { key: 'ArrowRight' });
64
+ expect(document.activeElement).toBe(company);
65
+ fireEvent.keyDown(company, { key: 'ArrowLeft' });
66
+ expect(document.activeElement).toBe(products);
67
+ });
68
+
69
+ it('Escape closes the open flyout and returns focus to its trigger', () => {
70
+ render(<NavigationMenu items={items} aria-label="Main" />);
71
+ const trigger = screen.getByRole('button', { name: 'Products' });
72
+ fireEvent.click(trigger);
73
+ fireEvent.keyDown(trigger, { key: 'Escape' });
74
+ expect(screen.queryByText('Products flyout')).toBeNull();
75
+ expect(document.activeElement).toBe(trigger);
76
+ });
77
+
78
+ it('uses a caller-supplied renderLink for plain links instead of a bare <a>', () => {
79
+ render(
80
+ <NavigationMenu
81
+ items={items}
82
+ aria-label="Main"
83
+ renderLink={(href, children) => (
84
+ <button type="button" data-href={href}>
85
+ {children}
86
+ </button>
87
+ )}
88
+ />,
89
+ );
90
+ expect(screen.queryByRole('link', { name: 'Home' })).toBeNull();
91
+ expect(screen.getByRole('button', { name: 'Home' })).toHaveProperty('dataset.href', '/home');
92
+ });
93
+ });
@@ -0,0 +1,98 @@
1
+ .nav {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: var(--sv-space-1);
5
+ }
6
+
7
+ .list {
8
+ display: flex;
9
+ align-items: center;
10
+ gap: var(--sv-space-1);
11
+ margin: 0;
12
+ padding: 0;
13
+ list-style: none;
14
+ }
15
+
16
+ /* Icon-only nav buttons — same footprint as Dialog's close button. */
17
+ .navButton {
18
+ display: inline-flex;
19
+ align-items: center;
20
+ justify-content: center;
21
+ width: var(--sv-space-8);
22
+ height: var(--sv-space-8);
23
+ border: none;
24
+ border-radius: var(--sv-radius-md);
25
+ background: transparent;
26
+ color: var(--sv-color-text-primary);
27
+ cursor: pointer;
28
+ }
29
+
30
+ .navButton:disabled {
31
+ opacity: 0.5;
32
+ cursor: not-allowed;
33
+ }
34
+
35
+ @media (hover: hover) {
36
+ .navButton:not(:disabled):hover {
37
+ background: var(--sv-color-surface-sunken);
38
+ }
39
+ }
40
+
41
+ .navButton:focus-visible {
42
+ outline: 2px solid var(--sv-color-focus-ring);
43
+ outline-offset: 2px;
44
+ }
45
+
46
+ @media (pointer: coarse) {
47
+ .navButton {
48
+ width: var(--sv-touch-target-min, 44px);
49
+ height: var(--sv-touch-target-min, 44px);
50
+ }
51
+ }
52
+
53
+ .pageButton {
54
+ display: inline-flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+ min-width: var(--sv-space-8);
58
+ height: var(--sv-space-8);
59
+ padding: 0 var(--sv-space-2);
60
+ border: none;
61
+ border-radius: var(--sv-radius-md);
62
+ background: transparent;
63
+ color: var(--sv-color-text-primary);
64
+ font-size: var(--sv-font-size-sm);
65
+ cursor: pointer;
66
+ }
67
+
68
+ @media (hover: hover) {
69
+ .pageButton:not(.active):hover {
70
+ background: var(--sv-color-surface-sunken);
71
+ }
72
+ }
73
+
74
+ .pageButton:focus-visible {
75
+ outline: 2px solid var(--sv-color-focus-ring);
76
+ outline-offset: 2px;
77
+ }
78
+
79
+ .active {
80
+ background: var(--sv-color-accent);
81
+ color: var(--sv-color-text-on-accent);
82
+ }
83
+
84
+ @media (pointer: coarse) {
85
+ .pageButton {
86
+ min-width: var(--sv-touch-target-min, 44px);
87
+ height: var(--sv-touch-target-min, 44px);
88
+ }
89
+ }
90
+
91
+ .ellipsis {
92
+ display: inline-flex;
93
+ align-items: center;
94
+ justify-content: center;
95
+ min-width: var(--sv-space-8);
96
+ height: var(--sv-space-8);
97
+ color: var(--sv-color-text-muted);
98
+ }
@@ -0,0 +1,49 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { Pagination } from './Pagination';
4
+
5
+ const meta = {
6
+ title: 'Components/Pagination',
7
+ component: Pagination,
8
+ parameters: {
9
+ layout: 'padded',
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'Page-number / prev-next control. Shows first, last, and pages near the current one, with an ellipsis for gaps once the page count grows.',
14
+ },
15
+ },
16
+ },
17
+ args: {
18
+ page: 1,
19
+ totalPages: 5,
20
+ onChange: () => {},
21
+ },
22
+ } satisfies Meta<typeof Pagination>;
23
+
24
+ export default meta;
25
+ type Story = StoryObj<typeof meta>;
26
+
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export const FewPages: Story = {
30
+ render: (args) => {
31
+ const [page, setPage] = useState(args.page);
32
+ return <Pagination {...args} page={page} totalPages={5} onChange={setPage} />;
33
+ },
34
+ };
35
+
36
+ export const ManyPagesWithEllipsis: Story = {
37
+ render: (_args) => {
38
+ const [page, setPage] = useState(8);
39
+ return <Pagination page={page} totalPages={40} onChange={setPage} />;
40
+ },
41
+ };
42
+
43
+ export const FirstPage: Story = {
44
+ args: { page: 1, totalPages: 10 },
45
+ };
46
+
47
+ export const LastPage: Story = {
48
+ args: { page: 10, totalPages: 10 },
49
+ };
@@ -0,0 +1,87 @@
1
+ import { Icon } from '../Icon/Icon';
2
+ import styles from './Pagination.module.css';
3
+
4
+ export interface PaginationProps {
5
+ /** 1-indexed current page. */
6
+ page: number;
7
+ totalPages: number;
8
+ onChange: (page: number) => void;
9
+ className?: string;
10
+ 'aria-label'?: string;
11
+ }
12
+
13
+ type PageEntry = number | 'ellipsis';
14
+
15
+ /** First page, last page, current page ± 1 sibling, ellipsis for gaps. */
16
+ function getPageList(page: number, totalPages: number): PageEntry[] {
17
+ if (totalPages <= 1) return [1];
18
+
19
+ const siblingCount = 1;
20
+ const startPage = Math.max(2, page - siblingCount);
21
+ const endPage = Math.min(totalPages - 1, page + siblingCount);
22
+
23
+ const pages: PageEntry[] = [1];
24
+ if (startPage > 2) pages.push('ellipsis');
25
+ for (let p = startPage; p <= endPage; p++) {
26
+ pages.push(p);
27
+ }
28
+ if (endPage < totalPages - 1) pages.push('ellipsis');
29
+ pages.push(totalPages);
30
+ return pages;
31
+ }
32
+
33
+ export function Pagination({
34
+ page,
35
+ totalPages,
36
+ onChange,
37
+ className,
38
+ 'aria-label': ariaLabel = 'Pagination',
39
+ }: PaginationProps) {
40
+ const pages = getPageList(page, totalPages);
41
+
42
+ return (
43
+ <nav aria-label={ariaLabel} className={[styles.nav, className].filter(Boolean).join(' ')}>
44
+ <button
45
+ type="button"
46
+ className={styles.navButton}
47
+ onClick={() => onChange(page - 1)}
48
+ disabled={page <= 1}
49
+ aria-label="Previous page"
50
+ >
51
+ <Icon name="chevron-left" size="sm" aria-hidden />
52
+ </button>
53
+ <ul className={styles.list}>
54
+ {pages.map((p, index) =>
55
+ p === 'ellipsis' ? (
56
+ <li key={`ellipsis-${index}`} className={styles.ellipsis} aria-hidden="true">
57
+ …
58
+ </li>
59
+ ) : (
60
+ <li key={p}>
61
+ <button
62
+ type="button"
63
+ className={[styles.pageButton, p === page ? styles.active : '']
64
+ .filter(Boolean)
65
+ .join(' ')}
66
+ onClick={() => onChange(p)}
67
+ aria-current={p === page ? 'page' : undefined}
68
+ aria-label={`Page ${p}`}
69
+ >
70
+ {p}
71
+ </button>
72
+ </li>
73
+ ),
74
+ )}
75
+ </ul>
76
+ <button
77
+ type="button"
78
+ className={styles.navButton}
79
+ onClick={() => onChange(page + 1)}
80
+ disabled={page >= totalPages}
81
+ aria-label="Next page"
82
+ >
83
+ <Icon name="chevron-right" size="sm" aria-hidden />
84
+ </button>
85
+ </nav>
86
+ );
87
+ }
@@ -0,0 +1,56 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { Pagination } from '../Pagination';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('Pagination', () => {
9
+ it('marks the current page with aria-current="page"', () => {
10
+ render(<Pagination page={3} totalPages={5} onChange={() => {}} />);
11
+ expect(screen.getByRole('button', { name: 'Page 3' }).getAttribute('aria-current')).toBe(
12
+ 'page',
13
+ );
14
+ expect(screen.getByRole('button', { name: 'Page 2' }).getAttribute('aria-current')).toBeNull();
15
+ });
16
+
17
+ it('calls onChange with the clicked page number', () => {
18
+ const onChange = vi.fn();
19
+ render(<Pagination page={3} totalPages={5} onChange={onChange} />);
20
+ screen.getByRole('button', { name: 'Page 4' }).click();
21
+ expect(onChange).toHaveBeenCalledWith(4);
22
+ });
23
+
24
+ it('disables the previous button on the first page', () => {
25
+ render(<Pagination page={1} totalPages={5} onChange={() => {}} />);
26
+ expect(screen.getByRole('button', { name: 'Previous page' })).toHaveProperty('disabled', true);
27
+ expect(screen.getByRole('button', { name: 'Next page' })).toHaveProperty('disabled', false);
28
+ });
29
+
30
+ it('disables the next button on the last page', () => {
31
+ render(<Pagination page={5} totalPages={5} onChange={() => {}} />);
32
+ expect(screen.getByRole('button', { name: 'Next page' })).toHaveProperty('disabled', true);
33
+ });
34
+
35
+ it('next/previous buttons step the page by one', () => {
36
+ const onChange = vi.fn();
37
+ render(<Pagination page={3} totalPages={5} onChange={onChange} />);
38
+ screen.getByRole('button', { name: 'Next page' }).click();
39
+ expect(onChange).toHaveBeenLastCalledWith(4);
40
+ screen.getByRole('button', { name: 'Previous page' }).click();
41
+ expect(onChange).toHaveBeenLastCalledWith(2);
42
+ });
43
+
44
+ it('shows an ellipsis and jumps to the last page for a large page count', () => {
45
+ render(<Pagination page={5} totalPages={20} onChange={() => {}} />);
46
+ expect(screen.getByRole('button', { name: 'Page 1' })).toBeDefined();
47
+ expect(screen.getByRole('button', { name: 'Page 20' })).toBeDefined();
48
+ expect(screen.queryAllByText('…').length).toBeGreaterThan(0);
49
+ });
50
+
51
+ it('renders no ellipsis for a small page count', () => {
52
+ render(<Pagination page={2} totalPages={4} onChange={() => {}} />);
53
+ expect(screen.queryAllByText('…').length).toBe(0);
54
+ expect(screen.getAllByRole('button', { name: /^Page \d$/ }).length).toBe(4);
55
+ });
56
+ });
@@ -0,0 +1,20 @@
1
+ .track {
2
+ width: 100%;
3
+ height: 8px;
4
+ border-radius: var(--sv-radius-full);
5
+ background: var(--sv-color-surface-sunken);
6
+ overflow: hidden;
7
+ }
8
+
9
+ .fill {
10
+ height: 100%;
11
+ border-radius: var(--sv-radius-full);
12
+ background: var(--sv-color-accent);
13
+ transition: width 0.2s ease;
14
+ }
15
+
16
+ @media (prefers-reduced-motion: reduce) {
17
+ .fill {
18
+ transition: none;
19
+ }
20
+ }
@@ -0,0 +1,40 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Progress } from './Progress';
3
+
4
+ const meta = {
5
+ title: 'Components/Progress',
6
+ component: Progress,
7
+ parameters: {
8
+ layout: 'padded',
9
+ docs: {
10
+ description: {
11
+ component:
12
+ 'Determinate progress bar. role="progressbar" with aria-valuenow/min/max so screen readers announce the current value. Indeterminate state is out of scope.',
13
+ },
14
+ },
15
+ },
16
+ args: {
17
+ value: 40,
18
+ label: 'Upload progress',
19
+ },
20
+ } satisfies Meta<typeof Progress>;
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof meta>;
24
+
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export const Default: Story = {};
28
+
29
+ export const Empty: Story = {
30
+ args: { value: 0 },
31
+ };
32
+
33
+ export const Complete: Story = {
34
+ args: { value: 100 },
35
+ };
36
+
37
+ export const OverMax: Story = {
38
+ name: 'Clamped (value > 100)',
39
+ args: { value: 140 },
40
+ };
@@ -0,0 +1,35 @@
1
+ import styles from './Progress.module.css';
2
+
3
+ export interface ProgressProps {
4
+ /** 0–100. Values outside that range are clamped. */
5
+ value: number;
6
+ /** Accessible name — Progress has no visible text of its own. */
7
+ label?: string;
8
+ id?: string;
9
+ className?: string;
10
+ }
11
+
12
+ /**
13
+ * Progress — determinate bar.
14
+ *
15
+ * `role="progressbar"` with `aria-valuenow`/`aria-valuemin`/`aria-valuemax`
16
+ * so screen readers announce the current value. Indeterminate state is out
17
+ * of scope until a consumer needs it.
18
+ */
19
+ export function Progress({ value, label, id, className }: ProgressProps) {
20
+ const clamped = Math.min(100, Math.max(0, value));
21
+
22
+ return (
23
+ <div
24
+ id={id}
25
+ role="progressbar"
26
+ aria-valuenow={clamped}
27
+ aria-valuemin={0}
28
+ aria-valuemax={100}
29
+ aria-label={label}
30
+ className={[styles.track, className].filter(Boolean).join(' ')}
31
+ >
32
+ <div className={styles.fill} style={{ width: `${clamped}%` }} />
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,31 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { Progress } from '../Progress';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('Progress', () => {
9
+ it('exposes progressbar ARIA attributes for the given value', () => {
10
+ render(<Progress value={40} label="Upload progress" />);
11
+ const bar = screen.getByRole('progressbar', { name: 'Upload progress' });
12
+ expect(bar.getAttribute('aria-valuenow')).toBe('40');
13
+ expect(bar.getAttribute('aria-valuemin')).toBe('0');
14
+ expect(bar.getAttribute('aria-valuemax')).toBe('100');
15
+ });
16
+
17
+ it('clamps a value above 100', () => {
18
+ render(<Progress value={140} label="Upload progress" />);
19
+ expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe('100');
20
+ });
21
+
22
+ it('clamps a negative value to 0', () => {
23
+ render(<Progress value={-10} label="Upload progress" />);
24
+ expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe('0');
25
+ });
26
+
27
+ it('renders without an accessible name when label is omitted', () => {
28
+ render(<Progress value={40} />);
29
+ expect(screen.getByRole('progressbar').getAttribute('aria-label')).toBeNull();
30
+ });
31
+ });