@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.
Files changed (93) hide show
  1. package/dist/LegalLinks.module.css +24 -0
  2. package/dist/Markdown.module.css +49 -0
  3. package/dist/MobileAppsDrawer.module.css +81 -0
  4. package/dist/MobileFooter.module.css +73 -0
  5. package/dist/MobileHeader.module.css +63 -0
  6. package/dist/PageContainer.module.css +20 -0
  7. package/dist/SwipableMobileCarousel.module.css +55 -0
  8. package/dist/SwipableMobileCarouselDots.module.css +49 -0
  9. package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
  10. package/dist/index.d.ts +654 -6
  11. package/dist/index.js +1856 -1133
  12. package/dist/tokens/primitives.css +25 -0
  13. package/package.json +13 -9
  14. package/src/components/Badge/Badge.tsx +1 -7
  15. package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
  16. package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
  17. package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
  18. package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
  19. package/src/components/Dialog/Dialog.tsx +8 -59
  20. package/src/components/Drawer/Drawer.stories.tsx +44 -0
  21. package/src/components/Drawer/Drawer.tsx +18 -56
  22. package/src/components/Drawer/__tests__/Drawer.test.tsx +22 -0
  23. package/src/components/Icon/icons/index.ts +8 -0
  24. package/src/components/Icon/icons/menu.tsx +22 -0
  25. package/src/components/Icon/icons/smartphone.tsx +21 -0
  26. package/src/components/LegalLinks/LegalLinks.module.css +24 -0
  27. package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
  28. package/src/components/LegalLinks/LegalLinks.tsx +40 -0
  29. package/src/components/Markdown/Markdown.module.css +49 -0
  30. package/src/components/Markdown/Markdown.stories.tsx +30 -0
  31. package/src/components/Markdown/Markdown.tsx +168 -0
  32. package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +81 -0
  33. package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
  34. package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
  35. package/src/components/MobileFooter/MobileFooter.module.css +73 -0
  36. package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
  37. package/src/components/MobileFooter/MobileFooter.tsx +109 -0
  38. package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +71 -0
  39. package/src/components/MobileHeader/MobileHeader.module.css +63 -0
  40. package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
  41. package/src/components/MobileHeader/MobileHeader.tsx +77 -0
  42. package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +58 -0
  43. package/src/components/NavTabs/NavTabs.tsx +39 -11
  44. package/src/components/OfflineGate/OfflineGate.tsx +44 -0
  45. package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
  46. package/src/components/PageContainer/PageContainer.module.css +20 -0
  47. package/src/components/PageContainer/PageContainer.tsx +31 -0
  48. package/src/components/PageContainer/__tests__/PageContainer.test.tsx +37 -0
  49. package/src/components/PageHeader/PageHeader.tsx +16 -2
  50. package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
  51. package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
  52. package/src/components/Sheet/Sheet.tsx +9 -51
  53. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +55 -0
  54. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +227 -0
  55. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
  56. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
  57. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
  58. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
  59. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
  60. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +133 -0
  61. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
  62. package/src/components/SwipableMobileCarousel/context.ts +13 -0
  63. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +49 -0
  64. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +62 -0
  65. package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
  66. package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
  67. package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
  68. package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
  69. package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
  70. package/src/hooks/__tests__/useSnapCarousel.test.tsx +93 -0
  71. package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
  72. package/src/hooks/index.ts +18 -0
  73. package/src/hooks/useCarouselRouteSync.ts +92 -0
  74. package/src/hooks/useIsOffline.ts +41 -0
  75. package/src/hooks/useOfflineTileState.ts +45 -0
  76. package/src/hooks/useResponsiveLayout.ts +36 -0
  77. package/src/hooks/useSnapCarousel.ts +79 -0
  78. package/src/hooks/useSwipeReveal.ts +107 -0
  79. package/src/index.ts +63 -2
  80. package/src/overlay-shell.ts +94 -0
  81. package/src/stories/DesignSystemOverview.stories.tsx +322 -1
  82. package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
  83. package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
  84. package/src/stories/InteractionHooks.stories.tsx +483 -0
  85. package/src/stories/MobilePatterns.stories.tsx +247 -2
  86. package/src/stories/NavTabs.stories.tsx +18 -0
  87. package/src/stories/OfflineGate.stories.tsx +42 -0
  88. package/src/stories/PageContainer.stories.tsx +37 -0
  89. package/src/stories/PageHeader.stories.tsx +11 -0
  90. package/src/stories/ResponsiveSurface.stories.tsx +47 -0
  91. package/src/stories/SwipableMobileCarousel.stories.tsx +304 -0
  92. package/src/stories/TokenGallery.stories.tsx +65 -0
  93. 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
- <a
23
- key={item.href}
24
- href={item.href}
25
- className={[styles.link, item.active && styles.active].filter(Boolean).join(' ')}
26
- aria-current={item.active ? 'page' : undefined}
27
- >
28
- {item.label}
29
- </a>
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,20 @@
1
+ .container {
2
+ width: 100%;
3
+ margin-inline: auto;
4
+ }
5
+
6
+ .sm {
7
+ max-width: 640px;
8
+ }
9
+
10
+ .md {
11
+ max-width: 960px;
12
+ }
13
+
14
+ .lg {
15
+ max-width: 1200px;
16
+ }
17
+
18
+ .full {
19
+ max-width: none;
20
+ }
@@ -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
- export function PageHeader({ title, description, action, className }: PageHeaderProps) {
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
- <h1 className={styles.title}>{title}</h1>
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, useEffect, useRef } from 'react';
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
- useEffect(() => {
79
- if (!mounted) return;
80
- lockBodyScroll();
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
+ }