@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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import styles from './SwipableMobileCarouselDots.module.css';
|
|
4
|
+
|
|
5
|
+
export interface SwipableMobileCarouselDotsProps {
|
|
6
|
+
count: number;
|
|
7
|
+
activeIndex: number;
|
|
8
|
+
onJump: (index: number) => void;
|
|
9
|
+
/** Per-dot accessible names, e.g. ["Lists", "Starred", "Groceries"]. Falls
|
|
10
|
+
* back to "Slide N of count" for any index without one. */
|
|
11
|
+
labels?: (string | undefined)[];
|
|
12
|
+
/** Labels the whole group — required, no generic fallback (mirrors
|
|
13
|
+
* SwipableMobileCarousel's own required aria-label). */
|
|
14
|
+
'aria-label': string;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* SwipableMobileCarouselDots — a real, tappable, labeled slide indicator.
|
|
20
|
+
* Standalone (not carousel-only): reusable anywhere a set of positions needs
|
|
21
|
+
* a dot indicator (e.g. an image gallery, an onboarding stepper), and is also
|
|
22
|
+
* SwipableMobileCarousel's default `renderIndicator`.
|
|
23
|
+
*
|
|
24
|
+
* Replaces the `aria-hidden`, non-interactive dots pattern both
|
|
25
|
+
* sovereign-tasks and sovereign-shopper currently hand-roll — every dot here
|
|
26
|
+
* is a real `role="tab"` button with its own accessible name, reachable by
|
|
27
|
+
* keyboard. No roving tabIndex/arrow-key handling: `Tabs` (this library's
|
|
28
|
+
* other `role="tablist"` component) has none either, so this stays
|
|
29
|
+
* consistent with it rather than gold-plating past its sibling.
|
|
30
|
+
*/
|
|
31
|
+
export function SwipableMobileCarouselDots({
|
|
32
|
+
count,
|
|
33
|
+
activeIndex,
|
|
34
|
+
onJump,
|
|
35
|
+
labels,
|
|
36
|
+
'aria-label': ariaLabel,
|
|
37
|
+
className,
|
|
38
|
+
}: SwipableMobileCarouselDotsProps) {
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
role="tablist"
|
|
42
|
+
aria-label={ariaLabel}
|
|
43
|
+
className={[styles.dots, className].filter(Boolean).join(' ')}
|
|
44
|
+
>
|
|
45
|
+
{Array.from({ length: count }, (_, i) => {
|
|
46
|
+
const active = i === activeIndex;
|
|
47
|
+
const label = labels?.[i] ?? `Slide ${i + 1} of ${count}`;
|
|
48
|
+
return (
|
|
49
|
+
<button
|
|
50
|
+
key={i}
|
|
51
|
+
type="button"
|
|
52
|
+
role="tab"
|
|
53
|
+
aria-selected={active}
|
|
54
|
+
aria-label={label}
|
|
55
|
+
className={[styles.dot, active ? styles.dotActive : ''].filter(Boolean).join(' ')}
|
|
56
|
+
onClick={() => onJump(i)}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
})}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { SwipableMobileCarouselDots } from '../SwipableMobileCarouselDots';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('SwipableMobileCarouselDots', () => {
|
|
9
|
+
it('renders a tablist with one tab per count, none aria-hidden', () => {
|
|
10
|
+
render(
|
|
11
|
+
<SwipableMobileCarouselDots count={3} activeIndex={0} onJump={() => {}} aria-label="Lists" />,
|
|
12
|
+
);
|
|
13
|
+
const list = screen.getByRole('tablist', { name: 'Lists' });
|
|
14
|
+
expect(list.getAttribute('aria-hidden')).toBeNull();
|
|
15
|
+
const tabs = screen.getAllByRole('tab');
|
|
16
|
+
expect(tabs).toHaveLength(3);
|
|
17
|
+
tabs.forEach((tab) => expect(tab.getAttribute('aria-hidden')).toBeNull());
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('marks only the active dot as aria-selected', () => {
|
|
21
|
+
render(
|
|
22
|
+
<SwipableMobileCarouselDots count={3} activeIndex={1} onJump={() => {}} aria-label="Lists" />,
|
|
23
|
+
);
|
|
24
|
+
const tabs = screen.getAllByRole('tab');
|
|
25
|
+
expect(tabs[0]?.getAttribute('aria-selected')).toBe('false');
|
|
26
|
+
expect(tabs[1]?.getAttribute('aria-selected')).toBe('true');
|
|
27
|
+
expect(tabs[2]?.getAttribute('aria-selected')).toBe('false');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('uses the provided per-dot labels, falling back to "Slide N of count"', () => {
|
|
31
|
+
render(
|
|
32
|
+
<SwipableMobileCarouselDots
|
|
33
|
+
count={3}
|
|
34
|
+
activeIndex={0}
|
|
35
|
+
onJump={() => {}}
|
|
36
|
+
labels={['Lists', undefined, 'Groceries']}
|
|
37
|
+
aria-label="Lists"
|
|
38
|
+
/>,
|
|
39
|
+
);
|
|
40
|
+
expect(screen.getByRole('tab', { name: 'Lists' })).toBeDefined();
|
|
41
|
+
expect(screen.getByRole('tab', { name: 'Slide 2 of 3' })).toBeDefined();
|
|
42
|
+
expect(screen.getByRole('tab', { name: 'Groceries' })).toBeDefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('clicking a dot calls onJump with its index', () => {
|
|
46
|
+
const onJump = vi.fn();
|
|
47
|
+
render(
|
|
48
|
+
<SwipableMobileCarouselDots count={3} activeIndex={0} onJump={onJump} aria-label="Lists" />,
|
|
49
|
+
);
|
|
50
|
+
const thirdTab = screen.getAllByRole('tab')[2];
|
|
51
|
+
if (!thirdTab) throw new Error('expected a 3rd tab');
|
|
52
|
+
fireEvent.click(thirdTab);
|
|
53
|
+
expect(onJump).toHaveBeenCalledWith(2);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('every dot is a real, focusable button (not a decorative element)', () => {
|
|
57
|
+
render(
|
|
58
|
+
<SwipableMobileCarouselDots count={2} activeIndex={0} onJump={() => {}} aria-label="Lists" />,
|
|
59
|
+
);
|
|
60
|
+
const tabs = screen.getAllByRole('tab');
|
|
61
|
+
tabs.forEach((tab) => expect(tab.tagName).toBe('BUTTON'));
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, render } from '@testing-library/react';
|
|
4
|
+
import { useCarouselRouteSync } from '../useCarouselRouteSync';
|
|
5
|
+
|
|
6
|
+
const SLIDES = ['/a', '/b', '/c'];
|
|
7
|
+
function indexForPathname(pathname: string) {
|
|
8
|
+
const i = SLIDES.indexOf(pathname);
|
|
9
|
+
return i === -1 ? 0 : i;
|
|
10
|
+
}
|
|
11
|
+
function pathForIndex(index: number) {
|
|
12
|
+
return SLIDES[index] ?? '/a';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function Harness({
|
|
16
|
+
pathname,
|
|
17
|
+
onNavigate,
|
|
18
|
+
onResult,
|
|
19
|
+
}: {
|
|
20
|
+
pathname: string;
|
|
21
|
+
onNavigate: (path: string) => void;
|
|
22
|
+
onResult: (result: { activeIndex: number; onSettle: (i: number) => void }) => void;
|
|
23
|
+
}) {
|
|
24
|
+
const result = useCarouselRouteSync({ indexForPathname, pathForIndex, pathname, onNavigate });
|
|
25
|
+
onResult(result);
|
|
26
|
+
return <span data-testid="active">{result.activeIndex}</span>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
afterEach(cleanup);
|
|
30
|
+
|
|
31
|
+
describe('useCarouselRouteSync', () => {
|
|
32
|
+
it('initializes activeIndex from the current pathname', () => {
|
|
33
|
+
const onNavigate = vi.fn();
|
|
34
|
+
const { getByTestId } = render(
|
|
35
|
+
<Harness pathname="/b" onNavigate={onNavigate} onResult={() => {}} />,
|
|
36
|
+
);
|
|
37
|
+
expect(getByTestId('active').textContent).toBe('1');
|
|
38
|
+
expect(onNavigate).not.toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('an external pathname change (tapped Link, browser back/forward) updates activeIndex without calling onNavigate', () => {
|
|
42
|
+
const onNavigate = vi.fn();
|
|
43
|
+
const { getByTestId, rerender } = render(
|
|
44
|
+
<Harness pathname="/a" onNavigate={onNavigate} onResult={() => {}} />,
|
|
45
|
+
);
|
|
46
|
+
expect(getByTestId('active').textContent).toBe('0');
|
|
47
|
+
|
|
48
|
+
rerender(<Harness pathname="/c" onNavigate={onNavigate} onResult={() => {}} />);
|
|
49
|
+
expect(getByTestId('active').textContent).toBe('2');
|
|
50
|
+
expect(onNavigate).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("onSettle calls onNavigate with pathForIndex's output and updates activeIndex synchronously", () => {
|
|
54
|
+
const onNavigate = vi.fn();
|
|
55
|
+
let latest: { activeIndex: number; onSettle: (i: number) => void } = {
|
|
56
|
+
activeIndex: -1,
|
|
57
|
+
onSettle: () => {},
|
|
58
|
+
};
|
|
59
|
+
const { getByTestId } = render(
|
|
60
|
+
<Harness
|
|
61
|
+
pathname="/a"
|
|
62
|
+
onNavigate={onNavigate}
|
|
63
|
+
onResult={(r) => {
|
|
64
|
+
latest = r;
|
|
65
|
+
}}
|
|
66
|
+
/>,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
act(() => void latest.onSettle(2));
|
|
70
|
+
|
|
71
|
+
expect(getByTestId('active').textContent).toBe('2');
|
|
72
|
+
expect(onNavigate).toHaveBeenCalledWith('/c');
|
|
73
|
+
expect(onNavigate).toHaveBeenCalledTimes(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('does NOT re-trigger the external-nav branch on the pathname-effect run that follows its own onSettle-driven navigation', () => {
|
|
77
|
+
// This is the isInternalNav dance: onSettle navigates via onNavigate; the
|
|
78
|
+
// caller's router then re-renders this hook with the new pathname — that
|
|
79
|
+
// pathname change must be recognized as "our own settle, already
|
|
80
|
+
// applied" and NOT reprocessed as an external nav. Both existing plugins'
|
|
81
|
+
// code comments describe a real double-flicker bug from getting this
|
|
82
|
+
// wrong, so this is the highest-value assertion in this file.
|
|
83
|
+
const onNavigate = vi.fn();
|
|
84
|
+
let latest: { activeIndex: number; onSettle: (i: number) => void } = {
|
|
85
|
+
activeIndex: -1,
|
|
86
|
+
onSettle: () => {},
|
|
87
|
+
};
|
|
88
|
+
const { getByTestId, rerender } = render(
|
|
89
|
+
<Harness
|
|
90
|
+
pathname="/a"
|
|
91
|
+
onNavigate={onNavigate}
|
|
92
|
+
onResult={(r) => {
|
|
93
|
+
latest = r;
|
|
94
|
+
}}
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Settle on /b (index 1) — this is the carousel's own gesture-driven navigation.
|
|
99
|
+
act(() => void latest.onSettle(1));
|
|
100
|
+
expect(getByTestId('active').textContent).toBe('1');
|
|
101
|
+
expect(onNavigate).toHaveBeenCalledWith('/b');
|
|
102
|
+
|
|
103
|
+
// The caller's router "commits" the navigation by re-rendering with the
|
|
104
|
+
// new pathname (exactly what onNavigate's real router.replace would
|
|
105
|
+
// eventually cause). If this were misidentified as an external nav, the
|
|
106
|
+
// effect would recompute indexForPathname('/b') anyway — same value, so
|
|
107
|
+
// a bug here wouldn't show up as a *wrong* index, only as an extra,
|
|
108
|
+
// redundant setState (the actual symptom in the original bug was a
|
|
109
|
+
// visible re-snap/flicker, not a wrong end state) — so this test also
|
|
110
|
+
// asserts onNavigate is never called a second time as a side effect of
|
|
111
|
+
// that re-render, which would happen if the effect treated it as new
|
|
112
|
+
// settle-worthy input rather than a no-op resync.
|
|
113
|
+
rerender(<Harness pathname="/b" onNavigate={onNavigate} onResult={(r) => (latest = r)} />);
|
|
114
|
+
|
|
115
|
+
expect(getByTestId('active').textContent).toBe('1');
|
|
116
|
+
expect(onNavigate).toHaveBeenCalledTimes(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('an unrecognized pathname falls back to index 0', () => {
|
|
120
|
+
const onNavigate = vi.fn();
|
|
121
|
+
const { getByTestId } = render(
|
|
122
|
+
<Harness pathname="/unknown" onNavigate={onNavigate} onResult={() => {}} />,
|
|
123
|
+
);
|
|
124
|
+
expect(getByTestId('active').textContent).toBe('0');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, render } from '@testing-library/react';
|
|
4
|
+
import { useIsOffline } from '../useIsOffline';
|
|
5
|
+
|
|
6
|
+
function Harness() {
|
|
7
|
+
const isOffline = useIsOffline();
|
|
8
|
+
return <span data-testid="out">{String(isOffline)}</span>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('useIsOffline', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: true });
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.unstubAllGlobals();
|
|
17
|
+
cleanup();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('reflects navigator.onLine after mount', () => {
|
|
21
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
|
|
22
|
+
const { getByTestId } = render(<Harness />);
|
|
23
|
+
expect(getByTestId('out').textContent).toBe('true');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('does not report offline when navigator.onLine is true', () => {
|
|
27
|
+
const { getByTestId } = render(<Harness />);
|
|
28
|
+
expect(getByTestId('out').textContent).toBe('false');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('flips on the offline event', () => {
|
|
32
|
+
const { getByTestId } = render(<Harness />);
|
|
33
|
+
expect(getByTestId('out').textContent).toBe('false');
|
|
34
|
+
act(() => window.dispatchEvent(new Event('offline')));
|
|
35
|
+
expect(getByTestId('out').textContent).toBe('true');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('flips back on the online event', () => {
|
|
39
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
|
|
40
|
+
const { getByTestId } = render(<Harness />);
|
|
41
|
+
expect(getByTestId('out').textContent).toBe('true');
|
|
42
|
+
act(() => window.dispatchEvent(new Event('online')));
|
|
43
|
+
expect(getByTestId('out').textContent).toBe('false');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('removes its listeners on unmount', () => {
|
|
47
|
+
const removeSpy = vi.spyOn(window, 'removeEventListener');
|
|
48
|
+
const { unmount } = render(<Harness />);
|
|
49
|
+
unmount();
|
|
50
|
+
expect(removeSpy).toHaveBeenCalledWith('offline', expect.any(Function));
|
|
51
|
+
expect(removeSpy).toHaveBeenCalledWith('online', expect.any(Function));
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, render } from '@testing-library/react';
|
|
4
|
+
import { useOfflineTileState } from '../useOfflineTileState';
|
|
5
|
+
|
|
6
|
+
function Harness({
|
|
7
|
+
offline,
|
|
8
|
+
deviceOnlyAvailable,
|
|
9
|
+
}: {
|
|
10
|
+
offline: 'offline-first' | 'device-only' | undefined;
|
|
11
|
+
deviceOnlyAvailable: boolean;
|
|
12
|
+
}) {
|
|
13
|
+
const state = useOfflineTileState(offline, deviceOnlyAvailable);
|
|
14
|
+
return <span data-testid="out">{String(state)}</span>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('useOfflineTileState', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: true });
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.unstubAllGlobals();
|
|
23
|
+
cleanup();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('is null for a no-tier app while online', () => {
|
|
27
|
+
const { getByTestId } = render(<Harness offline={undefined} deviceOnlyAvailable={false} />);
|
|
28
|
+
expect(getByTestId('out').textContent).toBe('null');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('connectivity-dims a no-tier app while offline', () => {
|
|
32
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
|
|
33
|
+
const { getByTestId } = render(<Harness offline={undefined} deviceOnlyAvailable={false} />);
|
|
34
|
+
expect(getByTestId('out').textContent).toBe('connectivity-dimmed');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('never dims an offline-first app, online or offline', () => {
|
|
38
|
+
const online = render(<Harness offline="offline-first" deviceOnlyAvailable={false} />);
|
|
39
|
+
expect(online.getByTestId('out').textContent).toBe('null');
|
|
40
|
+
cleanup();
|
|
41
|
+
|
|
42
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
|
|
43
|
+
const offline = render(<Harness offline="offline-first" deviceOnlyAvailable={false} />);
|
|
44
|
+
expect(offline.getByTestId('out').textContent).toBe('null');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('capability-restricts a device-only app when unavailable, regardless of connectivity', () => {
|
|
48
|
+
const online = render(<Harness offline="device-only" deviceOnlyAvailable={false} />);
|
|
49
|
+
expect(online.getByTestId('out').textContent).toBe('capability-restricted');
|
|
50
|
+
cleanup();
|
|
51
|
+
|
|
52
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
|
|
53
|
+
const offline = render(<Harness offline="device-only" deviceOnlyAvailable={false} />);
|
|
54
|
+
expect(offline.getByTestId('out').textContent).toBe('capability-restricted');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('is not restricted for a device-only app when available, and not dimmed offline either', () => {
|
|
58
|
+
const online = render(<Harness offline="device-only" deviceOnlyAvailable={true} />);
|
|
59
|
+
expect(online.getByTestId('out').textContent).toBe('null');
|
|
60
|
+
cleanup();
|
|
61
|
+
|
|
62
|
+
// Availability is a capability signal, not a connectivity one — a
|
|
63
|
+
// device-only app that IS available behaves like offline-first once the
|
|
64
|
+
// capability question is settled.
|
|
65
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
|
|
66
|
+
const offline = render(<Harness offline="device-only" deviceOnlyAvailable={true} />);
|
|
67
|
+
expect(offline.getByTestId('out').textContent).toBe('null');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, render } from '@testing-library/react';
|
|
4
|
+
import { useResponsiveLayout } from '../useResponsiveLayout';
|
|
5
|
+
|
|
6
|
+
// Same matchMedia mocking approach as useIsMobile.test.tsx, since this hook
|
|
7
|
+
// is a thin wrapper over it and must not diverge in SSR/breakpoint behavior.
|
|
8
|
+
let changeHandler: ((e: MediaQueryListEvent) => void) | null = null;
|
|
9
|
+
let matches = false;
|
|
10
|
+
let lastQuery = '';
|
|
11
|
+
|
|
12
|
+
function installMatchMedia() {
|
|
13
|
+
changeHandler = null;
|
|
14
|
+
vi.stubGlobal(
|
|
15
|
+
'matchMedia',
|
|
16
|
+
vi.fn().mockImplementation((query: string) => {
|
|
17
|
+
lastQuery = query;
|
|
18
|
+
return {
|
|
19
|
+
matches,
|
|
20
|
+
media: query,
|
|
21
|
+
addEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => {
|
|
22
|
+
changeHandler = cb;
|
|
23
|
+
},
|
|
24
|
+
removeEventListener: vi.fn(),
|
|
25
|
+
addListener: vi.fn(),
|
|
26
|
+
removeListener: vi.fn(),
|
|
27
|
+
dispatchEvent: vi.fn(),
|
|
28
|
+
};
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function Harness({ breakpointPx }: { breakpointPx?: number }) {
|
|
34
|
+
const { isMobile, value } = useResponsiveLayout({
|
|
35
|
+
web: 'web-tree',
|
|
36
|
+
mobile: 'mobile-tree',
|
|
37
|
+
breakpointPx,
|
|
38
|
+
});
|
|
39
|
+
return (
|
|
40
|
+
<span data-testid="out" data-is-mobile={String(isMobile)}>
|
|
41
|
+
{value}
|
|
42
|
+
</span>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('useResponsiveLayout', () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
matches = false;
|
|
49
|
+
installMatchMedia();
|
|
50
|
+
});
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
vi.unstubAllGlobals();
|
|
53
|
+
cleanup();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('defaults to the web value before/without a mobile match (SSR-safe default)', () => {
|
|
57
|
+
matches = false;
|
|
58
|
+
installMatchMedia();
|
|
59
|
+
const { getByTestId } = render(<Harness />);
|
|
60
|
+
expect(getByTestId('out').textContent).toBe('web-tree');
|
|
61
|
+
expect(getByTestId('out').dataset.isMobile).toBe('false');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('picks the mobile value once matchMedia reports a match', () => {
|
|
65
|
+
matches = true;
|
|
66
|
+
installMatchMedia();
|
|
67
|
+
const { getByTestId } = render(<Harness />);
|
|
68
|
+
expect(getByTestId('out').textContent).toBe('mobile-tree');
|
|
69
|
+
expect(getByTestId('out').dataset.isMobile).toBe('true');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('swaps live when the viewport crosses the breakpoint', () => {
|
|
73
|
+
matches = false;
|
|
74
|
+
installMatchMedia();
|
|
75
|
+
const { getByTestId } = render(<Harness />);
|
|
76
|
+
expect(getByTestId('out').textContent).toBe('web-tree');
|
|
77
|
+
act(() => changeHandler?.({ matches: true } as MediaQueryListEvent));
|
|
78
|
+
expect(getByTestId('out').textContent).toBe('mobile-tree');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('passes a custom breakpointPx straight through to useIsMobile', () => {
|
|
82
|
+
matches = false;
|
|
83
|
+
installMatchMedia();
|
|
84
|
+
render(<Harness breakpointPx={640} />);
|
|
85
|
+
expect(lastQuery).toBe('(max-width: 640px)');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, fireEvent, render } from '@testing-library/react';
|
|
4
|
+
import { useSnapCarousel, type UseSnapCarouselOptions } from '../useSnapCarousel';
|
|
5
|
+
|
|
6
|
+
// jsdom doesn't implement scrollTo on elements.
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.useFakeTimers();
|
|
9
|
+
if (!Element.prototype.scrollTo) {
|
|
10
|
+
Element.prototype.scrollTo = vi.fn();
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.runOnlyPendingTimers();
|
|
16
|
+
vi.useRealTimers();
|
|
17
|
+
cleanup();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function setLayout(el: HTMLElement, clientWidth: number, scrollLeft: number) {
|
|
21
|
+
Object.defineProperty(el, 'clientWidth', { value: clientWidth, configurable: true });
|
|
22
|
+
Object.defineProperty(el, 'scrollLeft', { value: scrollLeft, configurable: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function Harness(
|
|
26
|
+
props: UseSnapCarouselOptions & { onScrollTo?: (fn: (i: number) => void) => void },
|
|
27
|
+
) {
|
|
28
|
+
const { onScrollTo, ...options } = props;
|
|
29
|
+
const { scrollRef, scrollToIndex } = useSnapCarousel(options);
|
|
30
|
+
onScrollTo?.(scrollToIndex);
|
|
31
|
+
return <div data-testid="scroller" ref={scrollRef} />;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('useSnapCarousel', () => {
|
|
35
|
+
it('calls onSettle with the new index after the debounce window', () => {
|
|
36
|
+
const onSettle = vi.fn();
|
|
37
|
+
const { getByTestId } = render(<Harness itemCount={3} onSettle={onSettle} debounceMs={120} />);
|
|
38
|
+
const el = getByTestId('scroller');
|
|
39
|
+
setLayout(el, 300, 300); // scrollLeft 300 / width 300 -> index 1
|
|
40
|
+
fireEvent.scroll(el);
|
|
41
|
+
expect(onSettle).not.toHaveBeenCalled();
|
|
42
|
+
act(() => void vi.advanceTimersByTime(120));
|
|
43
|
+
expect(onSettle).toHaveBeenCalledWith(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('debounces repeated scroll events into a single settle call', () => {
|
|
47
|
+
const onSettle = vi.fn();
|
|
48
|
+
const { getByTestId } = render(<Harness itemCount={3} onSettle={onSettle} debounceMs={120} />);
|
|
49
|
+
const el = getByTestId('scroller');
|
|
50
|
+
setLayout(el, 300, 0);
|
|
51
|
+
fireEvent.scroll(el);
|
|
52
|
+
act(() => void vi.advanceTimersByTime(60));
|
|
53
|
+
setLayout(el, 300, 300);
|
|
54
|
+
fireEvent.scroll(el);
|
|
55
|
+
act(() => void vi.advanceTimersByTime(120));
|
|
56
|
+
expect(onSettle).toHaveBeenCalledOnce();
|
|
57
|
+
expect(onSettle).toHaveBeenCalledWith(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('does not call onSettle again for the same index', () => {
|
|
61
|
+
const onSettle = vi.fn();
|
|
62
|
+
const { getByTestId } = render(<Harness itemCount={3} onSettle={onSettle} debounceMs={120} />);
|
|
63
|
+
const el = getByTestId('scroller');
|
|
64
|
+
setLayout(el, 300, 300);
|
|
65
|
+
fireEvent.scroll(el);
|
|
66
|
+
act(() => void vi.advanceTimersByTime(120));
|
|
67
|
+
fireEvent.scroll(el);
|
|
68
|
+
act(() => void vi.advanceTimersByTime(120));
|
|
69
|
+
expect(onSettle).toHaveBeenCalledOnce();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('clamps the settled index within [0, itemCount - 1]', () => {
|
|
73
|
+
const onSettle = vi.fn();
|
|
74
|
+
const { getByTestId } = render(<Harness itemCount={2} onSettle={onSettle} debounceMs={120} />);
|
|
75
|
+
const el = getByTestId('scroller');
|
|
76
|
+
setLayout(el, 300, 900); // rounds to index 3, clamps to itemCount - 1 = 1
|
|
77
|
+
fireEvent.scroll(el);
|
|
78
|
+
act(() => void vi.advanceTimersByTime(120));
|
|
79
|
+
expect(onSettle).toHaveBeenCalledWith(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('scrollToIndex scrolls the container to the target slide', () => {
|
|
83
|
+
let scrollToIndex: ((index: number) => void) | undefined;
|
|
84
|
+
const { getByTestId } = render(
|
|
85
|
+
<Harness itemCount={3} onScrollTo={(fn) => (scrollToIndex = fn)} />,
|
|
86
|
+
);
|
|
87
|
+
const el = getByTestId('scroller');
|
|
88
|
+
setLayout(el, 400, 0);
|
|
89
|
+
const spy = vi.spyOn(el, 'scrollTo');
|
|
90
|
+
scrollToIndex?.(2);
|
|
91
|
+
expect(spy).toHaveBeenCalledWith({ left: 800, behavior: 'smooth' });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render } from '@testing-library/react';
|
|
4
|
+
import { useSwipeReveal, type UseSwipeRevealOptions } from '../useSwipeReveal';
|
|
5
|
+
|
|
6
|
+
// jsdom doesn't implement pointer capture — stub it as a no-op so
|
|
7
|
+
// `e.currentTarget.setPointerCapture(...)` doesn't throw.
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
if (!HTMLElement.prototype.setPointerCapture) {
|
|
10
|
+
HTMLElement.prototype.setPointerCapture = vi.fn();
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(cleanup);
|
|
15
|
+
|
|
16
|
+
function Harness(props: UseSwipeRevealOptions) {
|
|
17
|
+
const { rowRef, handlers } = useSwipeReveal(props);
|
|
18
|
+
return (
|
|
19
|
+
<div data-testid="row" ref={rowRef} {...handlers}>
|
|
20
|
+
row
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pointer(x: number, y: number) {
|
|
26
|
+
return { clientX: x, clientY: y, pointerId: 1 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('useSwipeReveal', () => {
|
|
30
|
+
it('calls onOpen when a horizontal drag crosses the halfway point', () => {
|
|
31
|
+
const onOpen = vi.fn();
|
|
32
|
+
const onClose = vi.fn();
|
|
33
|
+
const { getByTestId } = render(
|
|
34
|
+
<Harness revealWidth={100} open={false} onOpen={onOpen} onClose={onClose} />,
|
|
35
|
+
);
|
|
36
|
+
const el = getByTestId('row');
|
|
37
|
+
fireEvent.pointerDown(el, pointer(0, 0));
|
|
38
|
+
fireEvent.pointerMove(el, pointer(-60, 0));
|
|
39
|
+
fireEvent.pointerUp(el, pointer(-60, 0));
|
|
40
|
+
expect(onOpen).toHaveBeenCalledOnce();
|
|
41
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('calls onClose when the drag does not cross the halfway point', () => {
|
|
45
|
+
const onOpen = vi.fn();
|
|
46
|
+
const onClose = vi.fn();
|
|
47
|
+
const { getByTestId } = render(
|
|
48
|
+
<Harness revealWidth={100} open={false} onOpen={onOpen} onClose={onClose} />,
|
|
49
|
+
);
|
|
50
|
+
const el = getByTestId('row');
|
|
51
|
+
fireEvent.pointerDown(el, pointer(0, 0));
|
|
52
|
+
fireEvent.pointerMove(el, pointer(-20, 0));
|
|
53
|
+
fireEvent.pointerUp(el, pointer(-20, 0));
|
|
54
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
55
|
+
expect(onOpen).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('locks to vertical and ignores a mostly-vertical drag', () => {
|
|
59
|
+
const onOpen = vi.fn();
|
|
60
|
+
const onClose = vi.fn();
|
|
61
|
+
const { getByTestId } = render(
|
|
62
|
+
<Harness revealWidth={100} open={false} onOpen={onOpen} onClose={onClose} />,
|
|
63
|
+
);
|
|
64
|
+
const el = getByTestId('row');
|
|
65
|
+
fireEvent.pointerDown(el, pointer(0, 0));
|
|
66
|
+
fireEvent.pointerMove(el, pointer(2, 60));
|
|
67
|
+
fireEvent.pointerUp(el, pointer(2, 60));
|
|
68
|
+
expect(onOpen).not.toHaveBeenCalled();
|
|
69
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('does nothing when disabled', () => {
|
|
73
|
+
const onOpen = vi.fn();
|
|
74
|
+
const onClose = vi.fn();
|
|
75
|
+
const { getByTestId } = render(
|
|
76
|
+
<Harness revealWidth={100} open={false} onOpen={onOpen} onClose={onClose} disabled />,
|
|
77
|
+
);
|
|
78
|
+
const el = getByTestId('row');
|
|
79
|
+
fireEvent.pointerDown(el, pointer(0, 0));
|
|
80
|
+
fireEvent.pointerMove(el, pointer(-60, 0));
|
|
81
|
+
fireEvent.pointerUp(el, pointer(-60, 0));
|
|
82
|
+
expect(onOpen).not.toHaveBeenCalled();
|
|
83
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('closes from an already-open state when dragged back past halfway', () => {
|
|
87
|
+
const onOpen = vi.fn();
|
|
88
|
+
const onClose = vi.fn();
|
|
89
|
+
const { getByTestId } = render(
|
|
90
|
+
<Harness revealWidth={100} open onOpen={onOpen} onClose={onClose} />,
|
|
91
|
+
);
|
|
92
|
+
const el = getByTestId('row');
|
|
93
|
+
fireEvent.pointerDown(el, pointer(0, 0));
|
|
94
|
+
fireEvent.pointerMove(el, pointer(60, 0));
|
|
95
|
+
fireEvent.pointerUp(el, pointer(60, 0));
|
|
96
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
97
|
+
expect(onOpen).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('ignores pointerup without a preceding pointerdown', () => {
|
|
101
|
+
const onOpen = vi.fn();
|
|
102
|
+
const onClose = vi.fn();
|
|
103
|
+
const { getByTestId } = render(
|
|
104
|
+
<Harness revealWidth={100} open={false} onOpen={onOpen} onClose={onClose} />,
|
|
105
|
+
);
|
|
106
|
+
fireEvent.pointerUp(getByTestId('row'), pointer(-60, 0));
|
|
107
|
+
expect(onOpen).not.toHaveBeenCalled();
|
|
108
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
});
|