@sovereignfs/ui 0.41.0 → 0.54.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/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/LegalLinks.module.css +24 -0
- package/dist/Markdown.module.css +49 -0
- package/dist/Marker.module.css +33 -0
- package/dist/Menu.module.css +8 -1
- package/dist/Message.module.css +99 -0
- package/dist/MessageScroller.module.css +54 -0
- package/dist/MobileAppsDrawer.module.css +81 -0
- package/dist/MobileFooter.module.css +73 -0
- package/dist/MobileHeader.module.css +63 -0
- package/dist/NavigationMenu.module.css +53 -0
- package/dist/PageContainer.module.css +20 -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/SwipableMobileCarousel.module.css +55 -0
- package/dist/SwipableMobileCarouselDots.module.css +49 -0
- package/dist/SwipableMobileCarouselSlideParts.module.css +29 -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 +1268 -8
- package/dist/index.js +3046 -975
- package/dist/tokens/primitives.css +47 -8
- package/package.json +13 -9
- 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 +31 -9
- package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
- 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/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/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
- package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
- package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
- package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
- package/src/components/Dialog/Dialog.module.css +3 -0
- package/src/components/Dialog/Dialog.tsx +8 -59
- package/src/components/Drawer/Drawer.stories.tsx +44 -0
- package/src/components/Drawer/Drawer.tsx +18 -56
- package/src/components/Drawer/__tests__/Drawer.test.tsx +22 -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 +16 -0
- package/src/components/Icon/icons/menu.tsx +22 -0
- package/src/components/Icon/icons/smartphone.tsx +21 -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/LegalLinks/LegalLinks.module.css +24 -0
- package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
- package/src/components/LegalLinks/LegalLinks.tsx +40 -0
- package/src/components/Markdown/Markdown.module.css +49 -0
- package/src/components/Markdown/Markdown.stories.tsx +30 -0
- package/src/components/Markdown/Markdown.tsx +168 -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/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/MobileAppsDrawer/MobileAppsDrawer.module.css +81 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
- package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
- package/src/components/MobileFooter/MobileFooter.module.css +73 -0
- package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
- package/src/components/MobileFooter/MobileFooter.tsx +109 -0
- package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +71 -0
- package/src/components/MobileHeader/MobileHeader.module.css +63 -0
- package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
- package/src/components/MobileHeader/MobileHeader.tsx +77 -0
- package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +58 -0
- package/src/components/NavTabs/NavTabs.tsx +39 -11
- 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/OfflineGate/OfflineGate.tsx +44 -0
- package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
- package/src/components/PageContainer/PageContainer.module.css +20 -0
- package/src/components/PageContainer/PageContainer.tsx +31 -0
- package/src/components/PageContainer/__tests__/PageContainer.test.tsx +37 -0
- package/src/components/PageHeader/PageHeader.tsx +16 -2
- 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/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
- package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -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/Sheet/Sheet.tsx +9 -51
- 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/SwipableMobileCarousel/SwipableMobileCarousel.module.css +55 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +227 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +133 -0
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
- package/src/components/SwipableMobileCarousel/context.ts +13 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +49 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +62 -0
- package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -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/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
- package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
- package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
- package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
- package/src/hooks/__tests__/useSnapCarousel.test.tsx +93 -0
- package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
- package/src/hooks/index.ts +18 -0
- package/src/hooks/useCarouselRouteSync.ts +92 -0
- package/src/hooks/useIsOffline.ts +41 -0
- package/src/hooks/useOfflineTileState.ts +45 -0
- package/src/hooks/useResponsiveLayout.ts +36 -0
- package/src/hooks/useSnapCarousel.ts +79 -0
- package/src/hooks/useSwipeReveal.ts +107 -0
- package/src/index.ts +141 -3
- package/src/overlay-shell.ts +94 -0
- package/src/stories/DesignSystemOverview.stories.tsx +936 -3
- package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
- package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
- package/src/stories/InteractionHooks.stories.tsx +483 -0
- package/src/stories/MobilePatterns.stories.tsx +255 -4
- package/src/stories/NavTabs.stories.tsx +18 -0
- package/src/stories/OfflineGate.stories.tsx +42 -0
- package/src/stories/PageContainer.stories.tsx +37 -0
- package/src/stories/PageHeader.stories.tsx +11 -0
- package/src/stories/ResponsiveSurface.stories.tsx +47 -0
- package/src/stories/SwipableMobileCarousel.stories.tsx +304 -0
- package/src/stories/TokenGallery.stories.tsx +177 -16
- package/src/tokens/primitives.css +47 -8
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { useIsOffline } from '../../hooks/useIsOffline';
|
|
5
|
+
import { EmptyState } from '../EmptyState/EmptyState';
|
|
6
|
+
|
|
7
|
+
export interface OfflineGateProps {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
/** Defaults to a generic "this section" phrasing — pass the surface's own name. */
|
|
10
|
+
surfaceName?: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* OfflineGate — blocks administrative/settings surfaces from rendering
|
|
16
|
+
* (possibly stale) cached content while the device has no network, showing an
|
|
17
|
+
* explanatory empty state instead (research 0012, epic task 2.32).
|
|
18
|
+
*
|
|
19
|
+
* Distinct from `OfflineBanner`: the banner is an informational overlay that
|
|
20
|
+
* coexists with normal content everywhere in the shell. This is a hard block
|
|
21
|
+
* for surfaces where operating against stale data is actively wrong — e.g. a
|
|
22
|
+
* Console user list or an Account billing page reflects a point-in-time
|
|
23
|
+
* snapshot the moment it's server-rendered, and any cached copy replayed
|
|
24
|
+
* later has no way to signal that it may already be stale. `OfflineGate`
|
|
25
|
+
* doesn't try to tell staleness apart from freshness; it removes the
|
|
26
|
+
* question by not rendering the content at all while offline.
|
|
27
|
+
*
|
|
28
|
+
* `children` is rendered unconditionally while online, so this adds no cost
|
|
29
|
+
* to the common case — it only decides what to show, never how the wrapped
|
|
30
|
+
* content itself is fetched or cached.
|
|
31
|
+
*/
|
|
32
|
+
export function OfflineGate({ children, surfaceName, className }: OfflineGateProps) {
|
|
33
|
+
const isOffline = useIsOffline();
|
|
34
|
+
if (!isOffline) return <>{children}</>;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<EmptyState
|
|
38
|
+
icon="alert-triangle"
|
|
39
|
+
heading="You're offline"
|
|
40
|
+
description={`${surfaceName ?? 'This section'} needs a connection — reconnect to continue.`}
|
|
41
|
+
className={className}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { OfflineGate } from '../OfflineGate';
|
|
5
|
+
|
|
6
|
+
describe('OfflineGate', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: true });
|
|
9
|
+
});
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.unstubAllGlobals();
|
|
12
|
+
cleanup();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('renders children unchanged while online', () => {
|
|
16
|
+
render(
|
|
17
|
+
<OfflineGate>
|
|
18
|
+
<p>Live content</p>
|
|
19
|
+
</OfflineGate>,
|
|
20
|
+
);
|
|
21
|
+
expect(screen.getByText('Live content')).toBeDefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('replaces children with an explanatory empty state while offline', () => {
|
|
25
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
|
|
26
|
+
render(
|
|
27
|
+
<OfflineGate>
|
|
28
|
+
<p>Live content</p>
|
|
29
|
+
</OfflineGate>,
|
|
30
|
+
);
|
|
31
|
+
expect(screen.queryByText('Live content')).toBeNull();
|
|
32
|
+
expect(screen.getByText("You're offline")).toBeDefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('names the surface in the offline message when provided', () => {
|
|
36
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
|
|
37
|
+
render(
|
|
38
|
+
<OfflineGate surfaceName="Console">
|
|
39
|
+
<p>Live content</p>
|
|
40
|
+
</OfflineGate>,
|
|
41
|
+
);
|
|
42
|
+
expect(screen.getByText(/Console needs a connection/)).toBeDefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('falls back to generic phrasing with no surface name', () => {
|
|
46
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
|
|
47
|
+
render(
|
|
48
|
+
<OfflineGate>
|
|
49
|
+
<p>Live content</p>
|
|
50
|
+
</OfflineGate>,
|
|
51
|
+
);
|
|
52
|
+
expect(screen.getByText(/This section needs a connection/)).toBeDefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('swaps back to children on reconnect', () => {
|
|
56
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
|
|
57
|
+
render(
|
|
58
|
+
<OfflineGate>
|
|
59
|
+
<p>Live content</p>
|
|
60
|
+
</OfflineGate>,
|
|
61
|
+
);
|
|
62
|
+
expect(screen.getByText("You're offline")).toBeDefined();
|
|
63
|
+
act(() => window.dispatchEvent(new Event('online')));
|
|
64
|
+
expect(screen.getByText('Live content')).toBeDefined();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
import styles from './PageContainer.module.css';
|
|
3
|
+
|
|
4
|
+
export interface PageContainerProps extends HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
/** Constrains and centers content width. Adds no padding of its own — the
|
|
6
|
+
* runtime shell already pads plugin content. Defaults to 'md'. */
|
|
7
|
+
maxWidth?: 'sm' | 'md' | 'lg' | 'full';
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const maxWidthClass: Record<NonNullable<PageContainerProps['maxWidth']>, string> = {
|
|
12
|
+
sm: styles.sm as string,
|
|
13
|
+
md: styles.md as string,
|
|
14
|
+
lg: styles.lg as string,
|
|
15
|
+
full: styles.full as string,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function PageContainer({
|
|
19
|
+
maxWidth = 'md',
|
|
20
|
+
className,
|
|
21
|
+
children,
|
|
22
|
+
...rest
|
|
23
|
+
}: PageContainerProps) {
|
|
24
|
+
const cls = [styles.container, maxWidthClass[maxWidth], className].filter(Boolean).join(' ');
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className={cls} {...rest}>
|
|
28
|
+
{children}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { PageContainer } from '../PageContainer';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('PageContainer', () => {
|
|
9
|
+
it('renders its children', () => {
|
|
10
|
+
render(<PageContainer>content</PageContainer>);
|
|
11
|
+
expect(screen.getByText('content')).toBeDefined();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('defaults to md max-width', () => {
|
|
15
|
+
render(<PageContainer>content</PageContainer>);
|
|
16
|
+
expect(screen.getByText('content').className).toContain('md');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('applies sm, lg, and full max-width classes', () => {
|
|
20
|
+
const { rerender } = render(<PageContainer maxWidth="sm">content</PageContainer>);
|
|
21
|
+
expect(screen.getByText('content').className).toContain('sm');
|
|
22
|
+
rerender(<PageContainer maxWidth="lg">content</PageContainer>);
|
|
23
|
+
expect(screen.getByText('content').className).toContain('lg');
|
|
24
|
+
rerender(<PageContainer maxWidth="full">content</PageContainer>);
|
|
25
|
+
expect(screen.getByText('content').className).toContain('full');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('forwards className and other div props', () => {
|
|
29
|
+
render(
|
|
30
|
+
<PageContainer className="custom" data-testid="container">
|
|
31
|
+
content
|
|
32
|
+
</PageContainer>,
|
|
33
|
+
);
|
|
34
|
+
const el = screen.getByTestId('container');
|
|
35
|
+
expect(el.className).toContain('custom');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -6,13 +6,27 @@ export interface PageHeaderProps {
|
|
|
6
6
|
description?: string;
|
|
7
7
|
action?: ReactNode;
|
|
8
8
|
className?: string;
|
|
9
|
+
/** Heading level for the title. Defaults to `1` for standalone use; pass
|
|
10
|
+
* `2` or `3` when the page already sits under a shell/plugin `<h1>`, to
|
|
11
|
+
* avoid a duplicate top-level heading. Visual style is unchanged at every
|
|
12
|
+
* level — only the rendered tag changes. */
|
|
13
|
+
headingLevel?: 1 | 2 | 3;
|
|
9
14
|
}
|
|
10
15
|
|
|
11
|
-
|
|
16
|
+
const HEADING_TAG = { 1: 'h1', 2: 'h2', 3: 'h3' } as const;
|
|
17
|
+
|
|
18
|
+
export function PageHeader({
|
|
19
|
+
title,
|
|
20
|
+
description,
|
|
21
|
+
action,
|
|
22
|
+
className,
|
|
23
|
+
headingLevel = 1,
|
|
24
|
+
}: PageHeaderProps) {
|
|
25
|
+
const Heading = HEADING_TAG[headingLevel];
|
|
12
26
|
return (
|
|
13
27
|
<header className={[styles.header, className].filter(Boolean).join(' ')}>
|
|
14
28
|
<div className={styles.text}>
|
|
15
|
-
<
|
|
29
|
+
<Heading className={styles.title}>{title}</Heading>
|
|
16
30
|
{description && <p className={styles.description}>{description}</p>}
|
|
17
31
|
</div>
|
|
18
32
|
{action && <div className={styles.action}>{action}</div>}
|
|
@@ -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
|
+
});
|