@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.
- package/dist/Accordion.module.css +3 -0
- package/dist/Alert.module.css +62 -0
- package/dist/AspectRatio.module.css +15 -0
- package/dist/Badge.module.css +75 -9
- package/dist/BalanceChip.module.css +32 -0
- package/dist/Breadcrumb.module.css +49 -0
- package/dist/Button.module.css +12 -3
- package/dist/ButtonGroup.module.css +29 -0
- package/dist/Card.module.css +6 -0
- package/dist/Checkbox.module.css +3 -1
- package/dist/Collapsible.module.css +70 -0
- package/dist/Combobox.module.css +122 -0
- package/dist/Command.module.css +80 -0
- package/dist/ConfirmDialog.module.css +5 -1
- package/dist/DataTable.module.css +28 -0
- package/dist/DatePicker.module.css +6 -2
- package/dist/Dialog.module.css +3 -0
- package/dist/HoverCard.module.css +3 -0
- package/dist/Input.module.css +6 -1
- package/dist/Item.module.css +70 -0
- package/dist/Kbd.module.css +14 -0
- package/dist/Label.module.css +11 -0
- package/dist/Marker.module.css +33 -0
- package/dist/MemberMultiSelect.module.css +27 -0
- package/dist/Menu.module.css +8 -1
- package/dist/Message.module.css +99 -0
- package/dist/MessageScroller.module.css +54 -0
- package/dist/NavigationMenu.module.css +53 -0
- package/dist/Pagination.module.css +98 -0
- package/dist/Progress.module.css +20 -0
- package/dist/RadioGroup.module.css +88 -0
- package/dist/Resizable.module.css +70 -0
- package/dist/ScrollArea.module.css +20 -0
- package/dist/Select.module.css +7 -3
- package/dist/Slider.module.css +95 -0
- package/dist/Table.module.css +45 -0
- package/dist/Tabs.module.css +6 -1
- package/dist/Toggle.module.css +6 -1
- package/dist/Typography.module.css +47 -0
- package/dist/index.d.ts +698 -5
- package/dist/index.js +1938 -482
- package/dist/tokens/primitives.css +22 -8
- package/package.json +1 -1
- package/src/components/Accordion/Accordion.module.css +3 -0
- package/src/components/Accordion/Accordion.stories.tsx +110 -0
- package/src/components/Accordion/Accordion.tsx +53 -0
- package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
- package/src/components/Alert/Alert.module.css +62 -0
- package/src/components/Alert/Alert.stories.tsx +96 -0
- package/src/components/Alert/Alert.tsx +58 -0
- package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
- package/src/components/AspectRatio/AspectRatio.module.css +15 -0
- package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
- package/src/components/AspectRatio/AspectRatio.tsx +21 -0
- package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
- package/src/components/Badge/Badge.module.css +75 -9
- package/src/components/Badge/Badge.stories.tsx +109 -1
- package/src/components/Badge/Badge.tsx +30 -2
- package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
- package/src/components/BalanceChip/BalanceChip.module.css +32 -0
- package/src/components/BalanceChip/BalanceChip.stories.tsx +45 -0
- package/src/components/BalanceChip/BalanceChip.tsx +33 -0
- package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
- package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
- package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
- package/src/components/Button/Button.module.css +12 -3
- package/src/components/Button/Button.stories.tsx +3 -2
- package/src/components/Button/Button.tsx +1 -1
- package/src/components/Button/__tests__/Button.test.tsx +10 -0
- package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
- package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
- package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
- package/src/components/Card/Card.module.css +6 -0
- package/src/components/Checkbox/Checkbox.module.css +3 -1
- package/src/components/Collapsible/Collapsible.module.css +70 -0
- package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
- package/src/components/Collapsible/Collapsible.tsx +66 -0
- package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
- package/src/components/Combobox/Combobox.module.css +122 -0
- package/src/components/Combobox/Combobox.stories.tsx +79 -0
- package/src/components/Combobox/Combobox.tsx +186 -0
- package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
- package/src/components/Command/Command.module.css +80 -0
- package/src/components/Command/Command.stories.tsx +104 -0
- package/src/components/Command/Command.tsx +168 -0
- package/src/components/Command/__tests__/Command.test.tsx +162 -0
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
- package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
- package/src/components/ContextMenu/ContextMenu.tsx +100 -0
- package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
- package/src/components/CurrencyInput/CurrencyInput.stories.tsx +50 -0
- package/src/components/CurrencyInput/CurrencyInput.tsx +60 -0
- package/src/components/DataTable/DataTable.module.css +28 -0
- package/src/components/DataTable/DataTable.stories.tsx +74 -0
- package/src/components/DataTable/DataTable.tsx +154 -0
- package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
- package/src/components/DatePicker/DatePicker.module.css +6 -2
- package/src/components/Dialog/Dialog.module.css +3 -0
- package/src/components/HoverCard/HoverCard.module.css +3 -0
- package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
- package/src/components/HoverCard/HoverCard.tsx +111 -0
- package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
- package/src/components/Icon/icons/circle-check.tsx +21 -0
- package/src/components/Icon/icons/circle-x.tsx +22 -0
- package/src/components/Icon/icons/index.ts +8 -0
- package/src/components/Input/Input.module.css +6 -1
- package/src/components/Item/Item.module.css +70 -0
- package/src/components/Item/Item.stories.tsx +62 -0
- package/src/components/Item/Item.tsx +54 -0
- package/src/components/Item/__tests__/Item.test.tsx +39 -0
- package/src/components/Kbd/Kbd.module.css +14 -0
- package/src/components/Kbd/Kbd.stories.tsx +32 -0
- package/src/components/Kbd/Kbd.tsx +12 -0
- package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
- package/src/components/Label/Label.module.css +11 -0
- package/src/components/Label/Label.stories.tsx +40 -0
- package/src/components/Label/Label.tsx +24 -0
- package/src/components/Label/__tests__/Label.test.tsx +32 -0
- package/src/components/Marker/Marker.module.css +33 -0
- package/src/components/Marker/Marker.stories.tsx +39 -0
- package/src/components/Marker/Marker.tsx +34 -0
- package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
- package/src/components/MemberMultiSelect/MemberMultiSelect.module.css +27 -0
- package/src/components/MemberMultiSelect/MemberMultiSelect.stories.tsx +74 -0
- package/src/components/MemberMultiSelect/MemberMultiSelect.tsx +67 -0
- package/src/components/Menu/Menu.module.css +8 -1
- package/src/components/Menu/Menu.tsx +30 -8
- package/src/components/Menubar/Menubar.stories.tsx +52 -0
- package/src/components/Menubar/Menubar.tsx +42 -0
- package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
- package/src/components/Message/Message.module.css +99 -0
- package/src/components/Message/Message.stories.tsx +79 -0
- package/src/components/Message/Message.tsx +41 -0
- package/src/components/Message/__tests__/Message.test.tsx +46 -0
- package/src/components/MessageScroller/MessageScroller.module.css +54 -0
- package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
- package/src/components/MessageScroller/MessageScroller.tsx +72 -0
- package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
- package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
- package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
- package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
- package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
- package/src/components/Pagination/Pagination.module.css +98 -0
- package/src/components/Pagination/Pagination.stories.tsx +49 -0
- package/src/components/Pagination/Pagination.tsx +87 -0
- package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
- package/src/components/Progress/Progress.module.css +20 -0
- package/src/components/Progress/Progress.stories.tsx +40 -0
- package/src/components/Progress/Progress.tsx +35 -0
- package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
- package/src/components/RadioGroup/RadioGroup.module.css +88 -0
- package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
- package/src/components/RadioGroup/RadioGroup.tsx +99 -0
- package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
- package/src/components/Resizable/Resizable.module.css +70 -0
- package/src/components/Resizable/Resizable.stories.tsx +82 -0
- package/src/components/Resizable/Resizable.tsx +313 -0
- package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
- package/src/components/ScrollArea/ScrollArea.module.css +20 -0
- package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
- package/src/components/ScrollArea/ScrollArea.tsx +27 -0
- package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
- package/src/components/Select/Select.module.css +7 -3
- package/src/components/Slider/Slider.module.css +95 -0
- package/src/components/Slider/Slider.stories.tsx +64 -0
- package/src/components/Slider/Slider.tsx +77 -0
- package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
- package/src/components/SplitMethodSelector/SplitMethodSelector.stories.tsx +33 -0
- package/src/components/SplitMethodSelector/SplitMethodSelector.tsx +43 -0
- package/src/components/Table/Table.module.css +45 -0
- package/src/components/Table/Table.stories.tsx +125 -0
- package/src/components/Table/Table.tsx +51 -0
- package/src/components/Table/__tests__/Table.test.tsx +50 -0
- package/src/components/Tabs/Tabs.module.css +6 -1
- package/src/components/Toggle/Toggle.module.css +6 -1
- package/src/components/Typography/Typography.module.css +47 -0
- package/src/components/Typography/Typography.stories.tsx +62 -0
- package/src/components/Typography/Typography.tsx +30 -0
- package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
- package/src/index.ts +92 -1
- package/src/stories/DesignSystemOverview.stories.tsx +706 -2
- package/src/stories/MobilePatterns.stories.tsx +8 -2
- package/src/stories/TokenGallery.stories.tsx +112 -16
- 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
|
+
});
|