@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,63 @@
|
|
|
1
|
+
.header {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
justify-content: space-between;
|
|
5
|
+
gap: var(--sv-space-3);
|
|
6
|
+
width: 100%;
|
|
7
|
+
/* Mobile-only component — never render wider than the platform's own
|
|
8
|
+
mobile breakpoint, even if a consumer accidentally mounts it in a wider
|
|
9
|
+
container (e.g. an un-gated preview). At real mobile widths this is a
|
|
10
|
+
no-op since the viewport itself is already narrower. */
|
|
11
|
+
max-width: 768px;
|
|
12
|
+
margin-inline: auto;
|
|
13
|
+
min-height: var(--sv-shell-header-height, 60px);
|
|
14
|
+
padding: var(--sv-space-3) var(--sv-space-4);
|
|
15
|
+
padding-top: max(var(--sv-space-3), env(safe-area-inset-top));
|
|
16
|
+
padding-right: max(var(--sv-space-4), env(safe-area-inset-right));
|
|
17
|
+
padding-left: max(var(--sv-space-4), env(safe-area-inset-left));
|
|
18
|
+
background-color: var(--sv-color-surface);
|
|
19
|
+
border-bottom: 1px solid var(--sv-color-border);
|
|
20
|
+
position: sticky;
|
|
21
|
+
top: 0;
|
|
22
|
+
z-index: 101;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.brandGroup {
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
gap: var(--sv-space-3);
|
|
29
|
+
min-width: 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Matches the runtime shell's own .mobileBrandIcon (shell.module.css) — the
|
|
33
|
+
default logo shown when the consumer doesn't supply one. */
|
|
34
|
+
.defaultLogo {
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
justify-content: center;
|
|
38
|
+
flex-shrink: 0;
|
|
39
|
+
width: 36px;
|
|
40
|
+
height: 36px;
|
|
41
|
+
border-radius: var(--sv-radius-md);
|
|
42
|
+
background-color: var(--sv-color-accent);
|
|
43
|
+
color: var(--sv-color-text-on-accent);
|
|
44
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
45
|
+
overflow: hidden;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.title {
|
|
49
|
+
font-size: var(--sv-font-size-md);
|
|
50
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
51
|
+
color: var(--sv-color-text-primary);
|
|
52
|
+
overflow: hidden;
|
|
53
|
+
text-overflow: ellipsis;
|
|
54
|
+
white-space: nowrap;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.right {
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
gap: var(--sv-space-2);
|
|
61
|
+
margin-left: auto;
|
|
62
|
+
flex-shrink: 0;
|
|
63
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { MobileHeader } from './MobileHeader';
|
|
3
|
+
import { Avatar } from '../Avatar/Avatar';
|
|
4
|
+
import { Icon } from '../Icon/Icon';
|
|
5
|
+
|
|
6
|
+
function Logo() {
|
|
7
|
+
return (
|
|
8
|
+
<span
|
|
9
|
+
aria-label="Home"
|
|
10
|
+
style={{
|
|
11
|
+
display: 'flex',
|
|
12
|
+
alignItems: 'center',
|
|
13
|
+
justifyContent: 'center',
|
|
14
|
+
width: 28,
|
|
15
|
+
height: 28,
|
|
16
|
+
borderRadius: 'var(--sv-radius-sm)',
|
|
17
|
+
background: 'var(--sv-color-accent)',
|
|
18
|
+
color: 'var(--sv-color-text-on-accent)',
|
|
19
|
+
fontWeight: 700,
|
|
20
|
+
fontSize: 13,
|
|
21
|
+
flexShrink: 0,
|
|
22
|
+
}}
|
|
23
|
+
>
|
|
24
|
+
S
|
|
25
|
+
</span>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const meta = {
|
|
30
|
+
title: 'Components/MobileHeader',
|
|
31
|
+
component: MobileHeader,
|
|
32
|
+
parameters: {
|
|
33
|
+
layout: 'padded',
|
|
34
|
+
docs: {
|
|
35
|
+
description: {
|
|
36
|
+
component:
|
|
37
|
+
'Mobile shell header content row (RFC 0088). Logo, bell, and avatarMenu are always rendered by the consumer — the optional title is the only overridable part. Presentational only, no data fetching. See Overview/Mobile Patterns for the full immutable/overridable boundary.',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
args: {
|
|
42
|
+
logo: <Logo />,
|
|
43
|
+
bell: <Icon name="bell" size="md" aria-label="Notifications" />,
|
|
44
|
+
avatarMenu: <Avatar name="Jamie Doe" size="sm" />,
|
|
45
|
+
},
|
|
46
|
+
} satisfies Meta<typeof MobileHeader>;
|
|
47
|
+
|
|
48
|
+
export default meta;
|
|
49
|
+
type Story = StoryObj<typeof meta>;
|
|
50
|
+
|
|
51
|
+
export const Default: Story = {};
|
|
52
|
+
|
|
53
|
+
export const WithTitle: Story = {
|
|
54
|
+
args: {
|
|
55
|
+
title: 'Tasks',
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
import styles from './MobileHeader.module.css';
|
|
3
|
+
|
|
4
|
+
export interface MobileHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
/** The brand/logo element — already a complete, clickable link home (e.g. an
|
|
6
|
+
* instance logo wrapped in the consumer's own router `Link`). Always
|
|
7
|
+
* rendered. `MobileHeader` stays router-agnostic, so this is provided
|
|
8
|
+
* fully built rather than as a bare icon + href. Optional — omit it to get
|
|
9
|
+
* a default "S" badge (matching the runtime shell's own fallback brand
|
|
10
|
+
* icon when no instance logo is configured), sized and styled to the same
|
|
11
|
+
* 36px/radius-md convention a custom logo is expected to follow. */
|
|
12
|
+
logo?: ReactNode;
|
|
13
|
+
/** Optional contextual title rendered beside the logo (e.g. the active
|
|
14
|
+
* plugin's name). Absent by default — this is the one overridable part of
|
|
15
|
+
* the header. See RFC 0088. */
|
|
16
|
+
title?: string;
|
|
17
|
+
/** Notification bell — always rendered; not overridable.
|
|
18
|
+
* TODO: this is just an icon slot today (the consumer wires up its own
|
|
19
|
+
* click handler/content) — it doesn't yet accommodate a full notification
|
|
20
|
+
* feature (unread badge, dropdown panel, etc. — see the runtime shell's
|
|
21
|
+
* own NotificationBell). Deferred; revisit once a plugin actually needs
|
|
22
|
+
* more than a bare bell icon here. */
|
|
23
|
+
bell: ReactNode;
|
|
24
|
+
/** Avatar / account menu trigger — always rendered; not overridable. */
|
|
25
|
+
avatarMenu: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Default fallback logo — matches the runtime shell's own .mobileBrandIcon
|
|
29
|
+
* exactly (36px, radius-md, accent background) for the "no instance logo
|
|
30
|
+
* configured" case, just with a fixed "S" instead of the instance name's
|
|
31
|
+
* first letter (this component can't know the instance name — no data
|
|
32
|
+
* fetching, see the component's own doc comment). */
|
|
33
|
+
function DefaultLogo() {
|
|
34
|
+
return (
|
|
35
|
+
<span className={styles.defaultLogo} aria-hidden="true">
|
|
36
|
+
S
|
|
37
|
+
</span>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The mobile header bar: brand/logo (always shown), an optional contextual
|
|
43
|
+
* title, and a fixed bell + avatar-menu cluster. Owns its own chrome —
|
|
44
|
+
* background, border-bottom, sticky positioning, safe-area-aware padding,
|
|
45
|
+
* and a 768px max-width (so it never stretches full-bleed if mounted outside
|
|
46
|
+
* an actual mobile viewport) — matching the runtime shell's own mobile
|
|
47
|
+
* header exactly, since this component is meant to replace that hand-rolled
|
|
48
|
+
* markup. Presentational only — no data fetching; the consumer supplies
|
|
49
|
+
* `bell`/`avatarMenu` (and, optionally, `logo`) as already-wired components
|
|
50
|
+
* (their own sizing — 36px logo/avatar, 44px bell tap target — is the
|
|
51
|
+
* consumer's responsibility, matching the reference implementation in the
|
|
52
|
+
* runtime shell). See RFC 0088 for the immutable/overridable boundary this
|
|
53
|
+
* enforces.
|
|
54
|
+
*/
|
|
55
|
+
export function MobileHeader({
|
|
56
|
+
logo,
|
|
57
|
+
title,
|
|
58
|
+
bell,
|
|
59
|
+
avatarMenu,
|
|
60
|
+
className,
|
|
61
|
+
...rest
|
|
62
|
+
}: MobileHeaderProps) {
|
|
63
|
+
const cls = [styles.header, className].filter(Boolean).join(' ');
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className={cls} {...rest}>
|
|
67
|
+
<div className={styles.brandGroup}>
|
|
68
|
+
{logo ?? <DefaultLogo />}
|
|
69
|
+
{title && <span className={styles.title}>{title}</span>}
|
|
70
|
+
</div>
|
|
71
|
+
<div className={styles.right}>
|
|
72
|
+
{bell}
|
|
73
|
+
{avatarMenu}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { MobileHeader } from '../MobileHeader';
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
cleanup();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('MobileHeader', () => {
|
|
11
|
+
it('always renders the logo, bell, and avatar menu slots', () => {
|
|
12
|
+
render(
|
|
13
|
+
<MobileHeader
|
|
14
|
+
logo={<a href="/">logo</a>}
|
|
15
|
+
bell={<span data-testid="bell">bell</span>}
|
|
16
|
+
avatarMenu={<span data-testid="avatar">avatar</span>}
|
|
17
|
+
/>,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
expect(screen.getByText('logo')).toBeDefined();
|
|
21
|
+
expect(screen.getByTestId('bell')).toBeDefined();
|
|
22
|
+
expect(screen.getByTestId('avatar')).toBeDefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('renders no title by default', () => {
|
|
26
|
+
render(<MobileHeader logo={<a href="/">logo</a>} bell={<span />} avatarMenu={<span />} />);
|
|
27
|
+
|
|
28
|
+
expect(screen.queryByText(/tasks/i)).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('renders the optional title when provided', () => {
|
|
32
|
+
render(
|
|
33
|
+
<MobileHeader
|
|
34
|
+
logo={<a href="/">logo</a>}
|
|
35
|
+
title="Tasks"
|
|
36
|
+
bell={<span />}
|
|
37
|
+
avatarMenu={<span />}
|
|
38
|
+
/>,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(screen.getByText('Tasks')).toBeDefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('renders a default "S" badge when logo is omitted', () => {
|
|
45
|
+
render(<MobileHeader bell={<span />} avatarMenu={<span />} />);
|
|
46
|
+
|
|
47
|
+
expect(screen.getByText('S')).toBeDefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('renders the supplied logo instead of the default badge when provided', () => {
|
|
51
|
+
render(
|
|
52
|
+
<MobileHeader logo={<a href="/">custom logo</a>} bell={<span />} avatarMenu={<span />} />,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(screen.getByText('custom logo')).toBeDefined();
|
|
56
|
+
expect(screen.queryByText('S')).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Fragment, type ReactNode } from 'react';
|
|
1
2
|
import styles from './NavTabs.module.css';
|
|
2
3
|
|
|
3
4
|
export interface NavTabItem {
|
|
@@ -6,28 +7,55 @@ export interface NavTabItem {
|
|
|
6
7
|
active?: boolean;
|
|
7
8
|
}
|
|
8
9
|
|
|
10
|
+
/** Props to spread onto a consumer-supplied link element — keeps NavTabs' own
|
|
11
|
+
* class names and a11y attributes in sync with whatever the consumer renders. */
|
|
12
|
+
export interface NavTabLinkProps {
|
|
13
|
+
href: string;
|
|
14
|
+
className: string;
|
|
15
|
+
'aria-current': 'page' | undefined;
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
export interface NavTabsProps {
|
|
10
20
|
items: NavTabItem[];
|
|
11
21
|
className?: string;
|
|
12
22
|
'aria-label'?: string;
|
|
23
|
+
/** Render a tab as something other than a plain `<a href>` — e.g. Next's
|
|
24
|
+
* `<Link replace>` inside an overlay-shell plugin, where a full page
|
|
25
|
+
* navigation would break the dialog's history contract. Receives the item
|
|
26
|
+
* plus the class name/aria attributes NavTabs would otherwise apply. */
|
|
27
|
+
renderLink?: (item: NavTabItem, linkProps: NavTabLinkProps) => ReactNode;
|
|
13
28
|
}
|
|
14
29
|
|
|
15
|
-
export function NavTabs({ items, className, 'aria-label': ariaLabel }: NavTabsProps) {
|
|
30
|
+
export function NavTabs({ items, className, 'aria-label': ariaLabel, renderLink }: NavTabsProps) {
|
|
16
31
|
return (
|
|
17
32
|
<nav
|
|
18
33
|
className={[styles.nav, className].filter(Boolean).join(' ')}
|
|
19
34
|
aria-label={ariaLabel ?? 'Page navigation'}
|
|
20
35
|
>
|
|
21
|
-
{items.map((item) =>
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
{items.map((item) => {
|
|
37
|
+
const linkProps: NavTabLinkProps = {
|
|
38
|
+
href: item.href,
|
|
39
|
+
className: [styles.link, item.active && styles.active].filter(Boolean).join(' '),
|
|
40
|
+
'aria-current': item.active ? 'page' : undefined,
|
|
41
|
+
children: item.label,
|
|
42
|
+
};
|
|
43
|
+
return (
|
|
44
|
+
<Fragment key={item.href}>
|
|
45
|
+
{renderLink ? (
|
|
46
|
+
renderLink(item, linkProps)
|
|
47
|
+
) : (
|
|
48
|
+
<a
|
|
49
|
+
href={linkProps.href}
|
|
50
|
+
className={linkProps.className}
|
|
51
|
+
aria-current={linkProps['aria-current']}
|
|
52
|
+
>
|
|
53
|
+
{linkProps.children}
|
|
54
|
+
</a>
|
|
55
|
+
)}
|
|
56
|
+
</Fragment>
|
|
57
|
+
);
|
|
58
|
+
})}
|
|
31
59
|
</nav>
|
|
32
60
|
);
|
|
33
61
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
.nav {
|
|
2
|
+
min-width: 0;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.list {
|
|
6
|
+
display: flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
gap: var(--sv-space-1);
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
list-style: none;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.link,
|
|
15
|
+
.trigger {
|
|
16
|
+
display: inline-flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
height: 36px;
|
|
19
|
+
padding: 0 var(--sv-space-3);
|
|
20
|
+
border: none;
|
|
21
|
+
border-radius: var(--sv-radius-md);
|
|
22
|
+
background: transparent;
|
|
23
|
+
color: var(--sv-color-text-primary);
|
|
24
|
+
font-family: var(--sv-font-family);
|
|
25
|
+
font-size: var(--sv-font-size-sm);
|
|
26
|
+
font-weight: var(--sv-font-weight-medium);
|
|
27
|
+
text-decoration: none;
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@media (hover: hover) {
|
|
32
|
+
.link:hover,
|
|
33
|
+
.trigger:hover {
|
|
34
|
+
background: var(--sv-color-surface-sunken);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.link:focus-visible,
|
|
39
|
+
.trigger:focus-visible {
|
|
40
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
41
|
+
outline-offset: 2px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.triggerOpen {
|
|
45
|
+
background: var(--sv-color-surface-sunken);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@media (pointer: coarse) {
|
|
49
|
+
.link,
|
|
50
|
+
.trigger {
|
|
51
|
+
min-height: var(--sv-touch-target-min, 44px);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { NavigationMenu } from './NavigationMenu';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/NavigationMenu',
|
|
6
|
+
component: NavigationMenu,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: 'padded',
|
|
9
|
+
docs: {
|
|
10
|
+
description: {
|
|
11
|
+
component:
|
|
12
|
+
'Top-level nav bar where some items open a flyout panel. Desktop-oriented by design — a hover-triggered flyout bar has no mobile equivalent. Once one flyout is open, hovering a sibling switches directly to it; ArrowLeft/ArrowRight move focus between triggers.',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
args: { 'aria-label': 'Main' },
|
|
17
|
+
} satisfies Meta<typeof NavigationMenu>;
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const FlyoutContent = ({ links }: { links: string[] }) => (
|
|
25
|
+
<div style={{ padding: 'var(--sv-space-3)', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
26
|
+
{links.map((link) => (
|
|
27
|
+
<a
|
|
28
|
+
key={link}
|
|
29
|
+
href={`/${link.toLowerCase().replace(/\s+/g, '-')}`}
|
|
30
|
+
style={{
|
|
31
|
+
fontSize: 14,
|
|
32
|
+
color: 'var(--sv-color-text-primary)',
|
|
33
|
+
textDecoration: 'none',
|
|
34
|
+
padding: 'var(--sv-space-1) var(--sv-space-2)',
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
{link}
|
|
38
|
+
</a>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
export const Default: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
items: [
|
|
46
|
+
{ label: 'Home', href: '/home' },
|
|
47
|
+
{
|
|
48
|
+
label: 'Products',
|
|
49
|
+
content: <FlyoutContent links={['Tasks', 'Ledger', 'Shopper', 'Wallet']} />,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
label: 'Company',
|
|
53
|
+
content: <FlyoutContent links={['About', 'Blog', 'Careers']} />,
|
|
54
|
+
},
|
|
55
|
+
{ label: 'Docs', href: '/docs' },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -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
|
+
});
|