@sovereignfs/ui 0.45.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/LegalLinks.module.css +24 -0
- package/dist/Markdown.module.css +49 -0
- package/dist/MobileAppsDrawer.module.css +81 -0
- package/dist/MobileFooter.module.css +73 -0
- package/dist/MobileHeader.module.css +63 -0
- package/dist/PageContainer.module.css +20 -0
- package/dist/SwipableMobileCarousel.module.css +55 -0
- package/dist/SwipableMobileCarouselDots.module.css +49 -0
- package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/dist/index.d.ts +654 -6
- package/dist/index.js +1856 -1133
- package/dist/tokens/primitives.css +25 -0
- package/package.json +13 -9
- package/src/components/Badge/Badge.tsx +1 -7
- 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.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/Icon/icons/index.ts +8 -0
- package/src/components/Icon/icons/menu.tsx +22 -0
- package/src/components/Icon/icons/smartphone.tsx +21 -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/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/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/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
- package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
- package/src/components/Sheet/Sheet.tsx +9 -51
- 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/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 +63 -2
- package/src/overlay-shell.ts +94 -0
- package/src/stories/DesignSystemOverview.stories.tsx +322 -1
- 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 +247 -2
- 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 +65 -0
- package/src/tokens/primitives.css +25 -0
|
@@ -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,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,24 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { useResponsiveLayout } from '../../hooks/useResponsiveLayout';
|
|
3
|
+
|
|
4
|
+
export interface ResponsiveSurfaceProps {
|
|
5
|
+
web: ReactNode;
|
|
6
|
+
mobile: ReactNode;
|
|
7
|
+
breakpointPx?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ResponsiveSurface — thin JSX convenience wrapper around
|
|
12
|
+
* `useResponsiveLayout` for the common two-tree case: render an entirely
|
|
13
|
+
* different component tree below the breakpoint, not a CSS squeeze of the
|
|
14
|
+
* same one. Renders only whichever side is active — the other tree is never
|
|
15
|
+
* mounted.
|
|
16
|
+
*
|
|
17
|
+
* This does not replace a plugin's own shell component (e.g. a
|
|
18
|
+
* `MobileAwareShell` that also threads plugin-specific props like a refresh
|
|
19
|
+
* signal) — those stay a thin per-plugin wrapper around this.
|
|
20
|
+
*/
|
|
21
|
+
export function ResponsiveSurface({ web, mobile, breakpointPx }: ResponsiveSurfaceProps) {
|
|
22
|
+
const { value } = useResponsiveLayout({ web, mobile, breakpointPx });
|
|
23
|
+
return <>{value}</>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
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 { ResponsiveSurface } from '../ResponsiveSurface';
|
|
5
|
+
|
|
6
|
+
let changeHandler: ((e: MediaQueryListEvent) => void) | null = null;
|
|
7
|
+
let matches = false;
|
|
8
|
+
|
|
9
|
+
function installMatchMedia() {
|
|
10
|
+
changeHandler = null;
|
|
11
|
+
vi.stubGlobal(
|
|
12
|
+
'matchMedia',
|
|
13
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
14
|
+
matches,
|
|
15
|
+
media: query,
|
|
16
|
+
addEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => {
|
|
17
|
+
changeHandler = cb;
|
|
18
|
+
},
|
|
19
|
+
removeEventListener: vi.fn(),
|
|
20
|
+
addListener: vi.fn(),
|
|
21
|
+
removeListener: vi.fn(),
|
|
22
|
+
dispatchEvent: vi.fn(),
|
|
23
|
+
})),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('ResponsiveSurface', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
matches = false;
|
|
30
|
+
installMatchMedia();
|
|
31
|
+
});
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.unstubAllGlobals();
|
|
34
|
+
cleanup();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders the web tree by default and never mounts the mobile tree', () => {
|
|
38
|
+
render(
|
|
39
|
+
<ResponsiveSurface
|
|
40
|
+
web={<div data-testid="web">web</div>}
|
|
41
|
+
mobile={<div data-testid="mobile">mobile</div>}
|
|
42
|
+
/>,
|
|
43
|
+
);
|
|
44
|
+
expect(screen.getByTestId('web')).toBeDefined();
|
|
45
|
+
expect(screen.queryByTestId('mobile')).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('renders only the mobile tree below the breakpoint', () => {
|
|
49
|
+
matches = true;
|
|
50
|
+
installMatchMedia();
|
|
51
|
+
render(
|
|
52
|
+
<ResponsiveSurface
|
|
53
|
+
web={<div data-testid="web">web</div>}
|
|
54
|
+
mobile={<div data-testid="mobile">mobile</div>}
|
|
55
|
+
/>,
|
|
56
|
+
);
|
|
57
|
+
expect(screen.getByTestId('mobile')).toBeDefined();
|
|
58
|
+
expect(screen.queryByTestId('web')).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('swaps trees live when the viewport crosses the breakpoint', () => {
|
|
62
|
+
render(
|
|
63
|
+
<ResponsiveSurface
|
|
64
|
+
web={<div data-testid="web">web</div>}
|
|
65
|
+
mobile={<div data-testid="mobile">mobile</div>}
|
|
66
|
+
/>,
|
|
67
|
+
);
|
|
68
|
+
expect(screen.getByTestId('web')).toBeDefined();
|
|
69
|
+
act(() => changeHandler?.({ matches: true } as MediaQueryListEvent));
|
|
70
|
+
expect(screen.getByTestId('mobile')).toBeDefined();
|
|
71
|
+
expect(screen.queryByTestId('web')).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { type ReactNode,
|
|
4
|
-
import { lockBodyScroll, unlockBodyScroll } from '../../scroll-lock';
|
|
3
|
+
import { type ReactNode, useRef } from 'react';
|
|
5
4
|
import { useMountTransition, usePrefersReducedMotion } from '../../motion';
|
|
5
|
+
import {
|
|
6
|
+
useOverlayFocusCapture,
|
|
7
|
+
useOverlayKeyboardTrap,
|
|
8
|
+
useOverlayScrollLock,
|
|
9
|
+
} from '../../overlay-shell';
|
|
6
10
|
import { OverlayHeader } from '../OverlayHeader/OverlayHeader';
|
|
7
11
|
import styles from './Sheet.module.css';
|
|
8
12
|
|
|
@@ -36,9 +40,6 @@ export interface SheetProps {
|
|
|
36
40
|
children: ReactNode;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
const FOCUSABLE =
|
|
40
|
-
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
41
|
-
|
|
42
43
|
// Matches --sv-motion-duration-base (Sheet.module.css) — see Dialog.tsx's
|
|
43
44
|
// identical constant for why this stays a plain JS number.
|
|
44
45
|
const MOTION_DURATION_MS = 250;
|
|
@@ -71,55 +72,12 @@ export function Sheet({
|
|
|
71
72
|
children,
|
|
72
73
|
}: SheetProps) {
|
|
73
74
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
74
|
-
const previouslyFocused = useRef<HTMLElement | null>(null);
|
|
75
75
|
const reducedMotion = usePrefersReducedMotion();
|
|
76
76
|
const { mounted, phase } = useMountTransition(open, reducedMotion ? 0 : MOTION_DURATION_MS);
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return unlockBodyScroll;
|
|
82
|
-
}, [mounted]);
|
|
83
|
-
|
|
84
|
-
useEffect(() => {
|
|
85
|
-
if (!mounted) return;
|
|
86
|
-
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
|
87
|
-
const panel = panelRef.current;
|
|
88
|
-
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
|
|
89
|
-
(first ?? panel)?.focus();
|
|
90
|
-
return () => previouslyFocused.current?.focus();
|
|
91
|
-
}, [mounted]);
|
|
92
|
-
|
|
93
|
-
useEffect(() => {
|
|
94
|
-
if (!open) return;
|
|
95
|
-
function handleKeyDown(e: KeyboardEvent) {
|
|
96
|
-
if (e.key === 'Escape') {
|
|
97
|
-
e.stopPropagation();
|
|
98
|
-
onClose();
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
if (e.key !== 'Tab') return;
|
|
102
|
-
const panel = panelRef.current;
|
|
103
|
-
if (!panel) return;
|
|
104
|
-
const focusable = [...panel.querySelectorAll<HTMLElement>(FOCUSABLE)];
|
|
105
|
-
const first = focusable[0];
|
|
106
|
-
const last = focusable.at(-1);
|
|
107
|
-
if (!first || !last) {
|
|
108
|
-
e.preventDefault();
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
const active = document.activeElement;
|
|
112
|
-
if (e.shiftKey && active === first) {
|
|
113
|
-
e.preventDefault();
|
|
114
|
-
last.focus();
|
|
115
|
-
} else if (!e.shiftKey && active === last) {
|
|
116
|
-
e.preventDefault();
|
|
117
|
-
first.focus();
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
document.addEventListener('keydown', handleKeyDown);
|
|
121
|
-
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
122
|
-
}, [open, onClose]);
|
|
78
|
+
useOverlayScrollLock(mounted);
|
|
79
|
+
useOverlayFocusCapture(panelRef, mounted);
|
|
80
|
+
useOverlayKeyboardTrap(panelRef, open, onClose);
|
|
123
81
|
|
|
124
82
|
if (!mounted) return null;
|
|
125
83
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
.wrap {
|
|
2
|
+
position: relative;
|
|
3
|
+
height: 100%;
|
|
4
|
+
width: 100%;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.scroller {
|
|
8
|
+
height: 100%;
|
|
9
|
+
width: 100%;
|
|
10
|
+
display: flex;
|
|
11
|
+
overflow-x: auto;
|
|
12
|
+
overflow-y: hidden;
|
|
13
|
+
scroll-snap-type: x mandatory;
|
|
14
|
+
/* Momentum + rubber-banding come from the browser's native scroll physics —
|
|
15
|
+
no hand-rolled pointer/touch dragging. Deliberately NOT
|
|
16
|
+
-webkit-overflow-scrolling: touch: that legacy iOS <13 hint promotes its
|
|
17
|
+
element to its own compositing layer for GPU-accelerated momentum
|
|
18
|
+
scroll, and that layer's paint order relative to siblings/fixed
|
|
19
|
+
descendants elsewhere in the tree isn't always reliable on real iOS
|
|
20
|
+
Safari (confirmed via sovereign-tasks' and sovereign-shopper's own
|
|
21
|
+
carousels, which hit and fixed this exact bug — a position:fixed
|
|
22
|
+
overlay opened above the scroller painted BEHIND it instead of above).
|
|
23
|
+
Removing it doesn't disable momentum scrolling on any iOS version this
|
|
24
|
+
platform realistically targets. */
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.slide {
|
|
28
|
+
flex: 0 0 100%;
|
|
29
|
+
width: 100%;
|
|
30
|
+
height: 100%;
|
|
31
|
+
min-width: 0;
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
scroll-snap-align: start;
|
|
34
|
+
scroll-snap-stop: always;
|
|
35
|
+
/* Slide content is a flex column so Header/Footer stay their natural size
|
|
36
|
+
and Body (flex: 1 1 auto, min-height: 0) fills and scrolls the rest —
|
|
37
|
+
see SwipableMobileCarouselSlideParts.module.css. */
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-direction: column;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.dots {
|
|
43
|
+
/* Overlays the bottom of .wrap rather than taking its own layout row —
|
|
44
|
+
matches both sovereign-tasks' and sovereign-shopper's existing dots
|
|
45
|
+
placement. */
|
|
46
|
+
position: absolute;
|
|
47
|
+
left: 0;
|
|
48
|
+
right: 0;
|
|
49
|
+
bottom: var(--sv-space-3);
|
|
50
|
+
pointer-events: none;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.dots > * {
|
|
54
|
+
pointer-events: auto;
|
|
55
|
+
}
|