@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
@@ -2,5 +2,23 @@ export { useLongPress } from './useLongPress';
2
2
  export type { UseLongPressOptions, LongPressHandlers } from './useLongPress';
3
3
  export { useDoubleTapHandler, useSingleOrDoubleTap } from './useDoubleTap';
4
4
  export { useIsMobile, MOBILE_BREAKPOINT_PX } from './useIsMobile';
5
+ export { useIsOffline } from './useIsOffline';
6
+ export { useOfflineTileState } from './useOfflineTileState';
7
+ export type { OfflineTileState } from './useOfflineTileState';
5
8
  export { useCommitOnEnterOrBlur } from './useCommitOnEnterOrBlur';
6
9
  export type { CommitOnEnterOrBlurHandlers } from './useCommitOnEnterOrBlur';
10
+ export { useSwipeReveal } from './useSwipeReveal';
11
+ export type {
12
+ UseSwipeRevealOptions,
13
+ UseSwipeRevealHandlers,
14
+ UseSwipeRevealResult,
15
+ } from './useSwipeReveal';
16
+ export { useSnapCarousel } from './useSnapCarousel';
17
+ export type { UseSnapCarouselOptions, UseSnapCarouselResult } from './useSnapCarousel';
18
+ export { useResponsiveLayout } from './useResponsiveLayout';
19
+ export type { UseResponsiveLayoutOptions, UseResponsiveLayoutResult } from './useResponsiveLayout';
20
+ export { useCarouselRouteSync } from './useCarouselRouteSync';
21
+ export type {
22
+ UseCarouselRouteSyncOptions,
23
+ UseCarouselRouteSyncResult,
24
+ } from './useCarouselRouteSync';
@@ -0,0 +1,92 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ export interface UseCarouselRouteSyncOptions {
6
+ /** Maps the current pathname to a slide index — the caller's own routing
7
+ * logic (e.g. special-casing a virtual "starred" slide), unchanged from
8
+ * whatever a plugin already has today. This hook has no idea what a
9
+ * plugin's routes look like, by design — it never imports a router. */
10
+ indexForPathname: (pathname: string) => number;
11
+ /** Inverse mapping — given a settled slide index, the path to navigate to. */
12
+ pathForIndex: (index: number) => string;
13
+ /** The current pathname. The caller reads this via their own router (e.g.
14
+ * `usePathname()` from `next/navigation`) and passes it in every render —
15
+ * this package stays framework-generic and has zero runtime dependencies. */
16
+ pathname: string;
17
+ /** Called when this hook wants to navigate — typically
18
+ * `(path) => router.replace(path, { scroll: false })`. */
19
+ onNavigate: (path: string) => void;
20
+ }
21
+
22
+ export interface UseCarouselRouteSyncResult {
23
+ /** Feed directly into SwipableMobileCarousel's `activeIndex` prop. */
24
+ activeIndex: number;
25
+ /** Feed directly into SwipableMobileCarousel's `onSettle` prop. */
26
+ onSettle: (index: number) => void;
27
+ }
28
+
29
+ /**
30
+ * useCarouselRouteSync — centralizes the pathname↔slide-index mapping and the
31
+ * "was this pathname change our own settle, or an external navigation"
32
+ * distinction that carousel-driving plugins otherwise hand-roll per plugin
33
+ * (a tapped `<Link>`, browser back/forward, vs. this hook's own
34
+ * `router.replace` after a swipe settles). Router-agnostic on purpose: the
35
+ * caller owns the actual `usePathname()`/`router.replace()` calls and only
36
+ * hands this hook plain values/callbacks.
37
+ *
38
+ * Deliberately does not own neighbor-prefetch (SwipableMobileCarousel's
39
+ * `prefetchDistance`/mount-window covers that) or scroll mechanics (the
40
+ * carousel component reacts to `activeIndex` changes itself) — this hook is
41
+ * pathname↔index mapping only, keeping routing glue decoupled from
42
+ * data-fetching and DOM scrolling.
43
+ */
44
+ export function useCarouselRouteSync({
45
+ indexForPathname,
46
+ pathForIndex,
47
+ pathname,
48
+ onNavigate,
49
+ }: UseCarouselRouteSyncOptions): UseCarouselRouteSyncResult {
50
+ // Kept in refs and refreshed every render (not effect deps) so an inline
51
+ // arrow function passed by the caller each render doesn't retrigger the
52
+ // pathname-sync effect below — only an actual pathname change should.
53
+ const indexForPathnameRef = useRef(indexForPathname);
54
+ indexForPathnameRef.current = indexForPathname;
55
+ const pathForIndexRef = useRef(pathForIndex);
56
+ pathForIndexRef.current = pathForIndex;
57
+ const onNavigateRef = useRef(onNavigate);
58
+ onNavigateRef.current = onNavigate;
59
+
60
+ const [activeIndex, setActiveIndex] = useState(() => indexForPathname(pathname));
61
+ // Set synchronously inside onSettle, right before this hook's own
62
+ // navigation call — lets the pathname-sync effect below tell "this
63
+ // pathname change is our own settle, already reflected in activeIndex"
64
+ // apart from a genuinely external navigation.
65
+ const isInternalNavRef = useRef(false);
66
+ const didMountRef = useRef(false);
67
+
68
+ useEffect(() => {
69
+ if (!didMountRef.current) {
70
+ // The very first render's activeIndex already reflects pathname via
71
+ // the lazy useState initializer above — nothing to resync on mount.
72
+ didMountRef.current = true;
73
+ return;
74
+ }
75
+ if (isInternalNavRef.current) {
76
+ isInternalNavRef.current = false;
77
+ return;
78
+ }
79
+ const newIndex = indexForPathnameRef.current(pathname);
80
+ setActiveIndex((current) => (newIndex === current ? current : newIndex));
81
+ // Only re-run when pathname itself changes — indexForPathname is read
82
+ // through a ref precisely so its identity doesn't matter here.
83
+ }, [pathname]);
84
+
85
+ function onSettle(index: number) {
86
+ isInternalNavRef.current = true;
87
+ setActiveIndex(index);
88
+ onNavigateRef.current(pathForIndexRef.current(index));
89
+ }
90
+
91
+ return { activeIndex, onSettle };
92
+ }
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ /**
6
+ * useIsOffline — whether the browser currently has no network connection.
7
+ *
8
+ * SSR-safe: always initialises to `false` (online) so the server-rendered HTML
9
+ * matches the first client render, then reads the real `navigator.onLine`
10
+ * value in `useEffect`. This picks up the case where the page loaded already
11
+ * offline (e.g. served from the service-worker cache) at the cost of an
12
+ * imperceptible one-frame delay before the offline state appears — the same
13
+ * trade-off `OfflineBanner` makes, which this hook was extracted from so every
14
+ * consumer shares one connectivity source instead of re-deriving it slightly
15
+ * differently (research 0012, epic task 2.32).
16
+ *
17
+ * Reflects only `navigator.onLine` — true whenever the OS reports a network
18
+ * interface is up, even without real upstream connectivity (e.g. Wi-Fi with no
19
+ * internet). That is a known limitation of the browser API itself, not of this
20
+ * hook; there is currently no better cross-browser signal available.
21
+ */
22
+ export function useIsOffline(): boolean {
23
+ const [isOffline, setIsOffline] = useState(false);
24
+
25
+ useEffect(() => {
26
+ setIsOffline(!navigator.onLine);
27
+
28
+ const handleOffline = () => setIsOffline(true);
29
+ const handleOnline = () => setIsOffline(false);
30
+
31
+ window.addEventListener('offline', handleOffline);
32
+ window.addEventListener('online', handleOnline);
33
+
34
+ return () => {
35
+ window.removeEventListener('offline', handleOffline);
36
+ window.removeEventListener('online', handleOnline);
37
+ };
38
+ }, []);
39
+
40
+ return isOffline;
41
+ }
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+
3
+ import { useIsOffline } from './useIsOffline';
4
+
5
+ /**
6
+ * The two reasons an app tile can be non-interactive, kept structurally
7
+ * distinct because they have different causes and must never read as the same
8
+ * thing to a user (research 0012, epic task 2.33):
9
+ *
10
+ * - `'connectivity-dimmed'` — a no-offline-tier app, dimmed only while the
11
+ * device is **actually offline right now**. Reactive and temporary; clears
12
+ * the instant connectivity returns.
13
+ * - `'capability-restricted'` — a `device-only` app on a surface without the
14
+ * durable, encrypted, device-auth-gated store it needs. Static and
15
+ * unrelated to connectivity — it stays restricted even when fully online,
16
+ * and must never say "offline" to a user who plainly isn't.
17
+ */
18
+ export type OfflineTileState = 'connectivity-dimmed' | 'capability-restricted' | null;
19
+
20
+ /**
21
+ * Which of the two states, if any, applies to an app tile given its declared
22
+ * offline tier.
23
+ *
24
+ * `deviceOnlyAvailable` is the caller's own
25
+ * `isDeviceOnlyTierAvailable()` result (from
26
+ * `@sovereignfs/sdk/device-client`) — deliberately taken as a plain boolean
27
+ * parameter rather than computed inside this hook, so `@sovereignfs/ui` never
28
+ * depends on `@sovereignfs/sdk` (this package must stay usable standalone,
29
+ * outside the plugin runtime — RFC 0073).
30
+ *
31
+ * An `offline-first` app is never restricted by either state: that tier's
32
+ * whole point is working with no network, so it stays fully interactive both
33
+ * online and offline. A `device-only` app that *is* available behaves the
34
+ * same way — the restriction is about availability, not connectivity.
35
+ */
36
+ export function useOfflineTileState(
37
+ offline: 'offline-first' | 'device-only' | undefined,
38
+ deviceOnlyAvailable: boolean,
39
+ ): OfflineTileState {
40
+ const isOffline = useIsOffline();
41
+
42
+ if (offline === 'device-only' && !deviceOnlyAvailable) return 'capability-restricted';
43
+ if (offline === undefined && isOffline) return 'connectivity-dimmed';
44
+ return null;
45
+ }
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+
3
+ import { useIsMobile } from './useIsMobile';
4
+
5
+ export interface UseResponsiveLayoutOptions<TWeb, TMobile> {
6
+ web: TWeb;
7
+ mobile: TMobile;
8
+ breakpointPx?: number;
9
+ }
10
+
11
+ export interface UseResponsiveLayoutResult<TWeb, TMobile> {
12
+ isMobile: boolean;
13
+ value: TWeb | TMobile;
14
+ }
15
+
16
+ /**
17
+ * useResponsiveLayout — picks between a `web` and `mobile` value for the
18
+ * current viewport, built on `useIsMobile` (same breakpoint/SSR-safe-default
19
+ * behavior, not reimplemented here). Generic over the value type so it works
20
+ * for JSX (the common case — forking an entire component tree, not just a
21
+ * class name) or any other per-breakpoint value a plugin wants to pick
22
+ * without repeating the isMobile-ternary boilerplate every call site.
23
+ *
24
+ * This formalizes the `if (isMobile) return <mobile/>; return <web/>;` fork
25
+ * plugins have hand-rolled per-plugin (e.g. a `MobileAwareShell` component
26
+ * that swaps an entire routed page tree for a client-only mobile one) —
27
+ * see `ResponsiveSurface` for the equivalent as a plain JSX component.
28
+ */
29
+ export function useResponsiveLayout<TWeb, TMobile>({
30
+ web,
31
+ mobile,
32
+ breakpointPx,
33
+ }: UseResponsiveLayoutOptions<TWeb, TMobile>): UseResponsiveLayoutResult<TWeb, TMobile> {
34
+ const isMobile = useIsMobile(breakpointPx);
35
+ return { isMobile, value: isMobile ? mobile : web };
36
+ }
@@ -0,0 +1,79 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import type { RefObject } from 'react';
5
+
6
+ export interface UseSnapCarouselOptions {
7
+ /** Total number of slides — settle detection clamps to this range. */
8
+ itemCount: number;
9
+ /** Called when the user's scroll gesture settles on a different slide
10
+ * index than the last one reported. */
11
+ onSettle?: (index: number) => void;
12
+ /** Debounce delay (ms) after the last `scroll` event before checking where
13
+ * the container settled. Default 120 — matches the value this hook was
14
+ * extracted from. Debounced rather than keyed to the `scrollend` event,
15
+ * which pre-17.4 iOS Safari/WKWebView doesn't support. */
16
+ debounceMs?: number;
17
+ }
18
+
19
+ export interface UseSnapCarouselResult {
20
+ /** Attach to the `scroll-snap-type: x` scroll container. */
21
+ scrollRef: RefObject<HTMLDivElement | null>;
22
+ /** Imperatively scrolls the container to a slide index. */
23
+ scrollToIndex: (index: number, behavior?: ScrollBehavior) => void;
24
+ }
25
+
26
+ /**
27
+ * useSnapCarousel — debounced "settled slide" detection over a native
28
+ * `scroll-snap-type: x` container, extracted from `sovereign-tasks`'s
29
+ * `MobileTasksCarousel` and independently reimplemented in
30
+ * `sovereign-shopper`'s `MobileShopperCarousel` (RFC 0079, epic task 9.20).
31
+ * Gives swipe-between-slides physics for free via native scroll-snap, with
32
+ * this hook only handling "which slide did we land on."
33
+ */
34
+ export function useSnapCarousel({
35
+ itemCount,
36
+ onSettle,
37
+ debounceMs = 120,
38
+ }: UseSnapCarouselOptions): UseSnapCarouselResult {
39
+ const scrollRef = useRef<HTMLDivElement>(null);
40
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
41
+ const lastIndexRef = useRef<number | null>(null);
42
+ // Kept in a ref so the scroll listener always calls the latest callback
43
+ // without needing to be torn down and re-attached on every render.
44
+ const onSettleRef = useRef(onSettle);
45
+ onSettleRef.current = onSettle;
46
+
47
+ useEffect(() => {
48
+ const el = scrollRef.current;
49
+ if (!el) return;
50
+ function handleScroll() {
51
+ if (timerRef.current) clearTimeout(timerRef.current);
52
+ timerRef.current = setTimeout(() => {
53
+ const current = scrollRef.current;
54
+ if (!current) return;
55
+ const width = current.clientWidth;
56
+ if (!width) return;
57
+ const rawIndex = Math.round(current.scrollLeft / width);
58
+ const newIndex = Math.max(0, Math.min(itemCount - 1, rawIndex));
59
+ if (newIndex === lastIndexRef.current) return;
60
+ lastIndexRef.current = newIndex;
61
+ onSettleRef.current?.(newIndex);
62
+ }, debounceMs);
63
+ }
64
+ el.addEventListener('scroll', handleScroll, { passive: true });
65
+ return () => {
66
+ el.removeEventListener('scroll', handleScroll);
67
+ if (timerRef.current) clearTimeout(timerRef.current);
68
+ };
69
+ }, [itemCount, debounceMs]);
70
+
71
+ function scrollToIndex(index: number, behavior: ScrollBehavior = 'smooth') {
72
+ const el = scrollRef.current;
73
+ if (!el) return;
74
+ lastIndexRef.current = index;
75
+ el.scrollTo({ left: index * el.clientWidth, behavior });
76
+ }
77
+
78
+ return { scrollRef, scrollToIndex };
79
+ }
@@ -0,0 +1,107 @@
1
+ 'use client';
2
+
3
+ import { useRef } from 'react';
4
+ import type { PointerEvent as ReactPointerEvent, RefObject } from 'react';
5
+
6
+ export interface UseSwipeRevealOptions {
7
+ /** Max reveal distance in px — the row translates by `-revealWidth` when open. */
8
+ revealWidth: number;
9
+ /** Whether the reveal is currently open. Controlled by the caller so a
10
+ * list of rows can coordinate "only one open at a time" — this hook has
11
+ * no open state of its own. */
12
+ open: boolean;
13
+ /** Called when a drag releases past the halfway point — the caller should
14
+ * set `open` to true. */
15
+ onOpen: () => void;
16
+ /** Called when a drag releases before the halfway point — the caller
17
+ * should set `open` to false. */
18
+ onClose: () => void;
19
+ /** Skip entirely (hooks can't be called conditionally). */
20
+ disabled?: boolean;
21
+ }
22
+
23
+ export interface UseSwipeRevealHandlers {
24
+ onPointerDown: (e: ReactPointerEvent) => void;
25
+ onPointerMove: (e: ReactPointerEvent) => void;
26
+ onPointerUp: (e: ReactPointerEvent) => void;
27
+ onPointerCancel: (e: ReactPointerEvent) => void;
28
+ }
29
+
30
+ export interface UseSwipeRevealResult {
31
+ /** Attach to the swipeable row element — the hook writes a live transform
32
+ * to it directly during the drag (not via React state) so the row tracks
33
+ * the finger at 60fps with no re-render per pointermove. */
34
+ rowRef: RefObject<HTMLDivElement | null>;
35
+ /** Spread onto the row's pointer event handlers. */
36
+ handlers: UseSwipeRevealHandlers;
37
+ }
38
+
39
+ /**
40
+ * useSwipeReveal — horizontal swipe-to-reveal (e.g. Done/Delete actions
41
+ * behind a list row), extracted from `sovereign-tasks`'s `TaskItem`/
42
+ * `ListSidebar`, which independently hand-rolled the identical gesture twice
43
+ * with only `revealWidth` differing (RFC 0079, epic task 9.20).
44
+ *
45
+ * Axis-locks on the first 8px of movement (so a vertical scroll or the
46
+ * carousel's own horizontal swipe-between-slides gesture isn't hijacked by a
47
+ * diagonal drag) and only resolves to open/closed on release, past the
48
+ * halfway point of `revealWidth`.
49
+ */
50
+ export function useSwipeReveal({
51
+ revealWidth,
52
+ open,
53
+ onOpen,
54
+ onClose,
55
+ disabled = false,
56
+ }: UseSwipeRevealOptions): UseSwipeRevealResult {
57
+ const rowRef = useRef<HTMLDivElement>(null);
58
+ const dragState = useRef<{
59
+ startX: number;
60
+ startY: number;
61
+ locked: 'horizontal' | 'vertical' | null;
62
+ } | null>(null);
63
+
64
+ function onPointerDown(e: ReactPointerEvent) {
65
+ if (disabled) return;
66
+ e.currentTarget.setPointerCapture(e.pointerId);
67
+ dragState.current = { startX: e.clientX, startY: e.clientY, locked: null };
68
+ }
69
+
70
+ function onPointerMove(e: ReactPointerEvent) {
71
+ const state = dragState.current;
72
+ if (!state) return;
73
+ const dx = e.clientX - state.startX;
74
+ const dy = e.clientY - state.startY;
75
+ if (!state.locked) {
76
+ if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
77
+ state.locked = Math.abs(dx) > Math.abs(dy) ? 'horizontal' : 'vertical';
78
+ }
79
+ if (state.locked !== 'horizontal') return;
80
+ e.preventDefault();
81
+ const base = open ? -revealWidth : 0;
82
+ const next = Math.min(0, Math.max(-revealWidth, base + dx));
83
+ if (rowRef.current) rowRef.current.style.transform = `translateX(${next}px)`;
84
+ }
85
+
86
+ function releaseDrag(e: ReactPointerEvent) {
87
+ const state = dragState.current;
88
+ dragState.current = null;
89
+ if (!state || state.locked !== 'horizontal') return;
90
+ const dx = e.clientX - state.startX;
91
+ const base = open ? -revealWidth : 0;
92
+ const finalX = Math.min(0, Math.max(-revealWidth, base + dx));
93
+ if (rowRef.current) rowRef.current.style.transform = '';
94
+ if (finalX < -revealWidth / 2) onOpen();
95
+ else onClose();
96
+ }
97
+
98
+ return {
99
+ rowRef,
100
+ handlers: {
101
+ onPointerDown,
102
+ onPointerMove,
103
+ onPointerUp: releaseDrag,
104
+ onPointerCancel: releaseDrag,
105
+ },
106
+ };
107
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,7 @@
1
+ export { LegalLinks } from './components/LegalLinks/LegalLinks';
2
+ export type { LegalLinksProps } from './components/LegalLinks/LegalLinks';
3
+ export { Markdown } from './components/Markdown/Markdown';
4
+ export type { MarkdownProps } from './components/Markdown/Markdown';
1
5
  export { Badge } from './components/Badge/Badge';
2
6
  export type { BadgeProps, BadgeVariant, BadgeStatus } from './components/Badge/Badge';
3
7
  export { Icon } from './components/Icon/Icon';
@@ -57,14 +61,34 @@ export { FormField } from './components/FormField/FormField';
57
61
  export type { FormFieldProps, FormFieldRenderProps } from './components/FormField/FormField';
58
62
  export { PageHeader } from './components/PageHeader/PageHeader';
59
63
  export type { PageHeaderProps } from './components/PageHeader/PageHeader';
64
+ export { PageContainer } from './components/PageContainer/PageContainer';
65
+ export type { PageContainerProps } from './components/PageContainer/PageContainer';
66
+ export { MobileHeader } from './components/MobileHeader/MobileHeader';
67
+ export type { MobileHeaderProps } from './components/MobileHeader/MobileHeader';
68
+ export { MobileFooter } from './components/MobileFooter/MobileFooter';
69
+ export type { MobileFooterProps, FooterIcon } from './components/MobileFooter/MobileFooter';
70
+ export { MobileAppsDrawer } from './components/MobileAppsDrawer/MobileAppsDrawer';
71
+ export type {
72
+ MobileAppsDrawerProps,
73
+ MobileAppsDrawerItem,
74
+ } from './components/MobileAppsDrawer/MobileAppsDrawer';
60
75
  export { EmptyState } from './components/EmptyState/EmptyState';
61
76
  export type { EmptyStateProps } from './components/EmptyState/EmptyState';
77
+ export { OfflineGate } from './components/OfflineGate/OfflineGate';
78
+ export type { OfflineGateProps } from './components/OfflineGate/OfflineGate';
79
+ export { DeviceOnlyGate } from './components/DeviceOnlyGate/DeviceOnlyGate';
80
+ export type { DeviceOnlyGateProps } from './components/DeviceOnlyGate/DeviceOnlyGate';
81
+ export { DeviceStorageKeyGate } from './components/DeviceStorageKeyGate/DeviceStorageKeyGate';
82
+ export type {
83
+ DeviceStorageKeyGateProps,
84
+ DeviceStorageKeyGateStatus,
85
+ } from './components/DeviceStorageKeyGate/DeviceStorageKeyGate';
62
86
  export { Spinner } from './components/Spinner/Spinner';
63
87
  export type { SpinnerProps } from './components/Spinner/Spinner';
64
88
  export { Avatar } from './components/Avatar/Avatar';
65
89
  export type { AvatarProps } from './components/Avatar/Avatar';
66
90
  export { NavTabs } from './components/NavTabs/NavTabs';
67
- export type { NavTabsProps, NavTabItem } from './components/NavTabs/NavTabs';
91
+ export type { NavTabsProps, NavTabItem, NavTabLinkProps } from './components/NavTabs/NavTabs';
68
92
  export { Tooltip } from './components/Tooltip/Tooltip';
69
93
  export type { TooltipProps } from './components/Tooltip/Tooltip';
70
94
  export { Checkbox } from './components/Checkbox/Checkbox';
@@ -184,7 +208,44 @@ export {
184
208
  useDoubleTapHandler,
185
209
  useSingleOrDoubleTap,
186
210
  useIsMobile,
211
+ useIsOffline,
212
+ useOfflineTileState,
187
213
  useCommitOnEnterOrBlur,
214
+ useSwipeReveal,
215
+ useSnapCarousel,
216
+ useResponsiveLayout,
217
+ useCarouselRouteSync,
188
218
  } from './hooks';
219
+ export type { OfflineTileState } from './hooks';
189
220
  export { MOBILE_BREAKPOINT_PX } from './hooks';
190
- export type { UseLongPressOptions, LongPressHandlers, CommitOnEnterOrBlurHandlers } from './hooks';
221
+ export type {
222
+ UseLongPressOptions,
223
+ LongPressHandlers,
224
+ CommitOnEnterOrBlurHandlers,
225
+ UseSwipeRevealOptions,
226
+ UseSwipeRevealHandlers,
227
+ UseSwipeRevealResult,
228
+ UseSnapCarouselOptions,
229
+ UseSnapCarouselResult,
230
+ UseResponsiveLayoutOptions,
231
+ UseResponsiveLayoutResult,
232
+ UseCarouselRouteSyncOptions,
233
+ UseCarouselRouteSyncResult,
234
+ } from './hooks';
235
+ export { ResponsiveSurface } from './components/ResponsiveSurface/ResponsiveSurface';
236
+ export type { ResponsiveSurfaceProps } from './components/ResponsiveSurface/ResponsiveSurface';
237
+ export { SwipableMobileCarouselDots } from './components/SwipableMobileCarouselDots/SwipableMobileCarouselDots';
238
+ export type { SwipableMobileCarouselDotsProps } from './components/SwipableMobileCarouselDots/SwipableMobileCarouselDots';
239
+ export { SwipableMobileCarousel } from './components/SwipableMobileCarousel/SwipableMobileCarousel';
240
+ export type {
241
+ SwipableMobileCarouselProps,
242
+ SwipableMobileCarouselIndicatorProps,
243
+ } from './components/SwipableMobileCarousel/SwipableMobileCarousel';
244
+ export { SwipableMobileCarouselSlide } from './components/SwipableMobileCarousel/SwipableMobileCarouselSlide';
245
+ export type { SwipableMobileCarouselSlideProps } from './components/SwipableMobileCarousel/SwipableMobileCarouselSlide';
246
+ export { SwipableMobileCarouselSlideHeader } from './components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader';
247
+ export type { SwipableMobileCarouselSlideHeaderProps } from './components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader';
248
+ export { SwipableMobileCarouselSlideBody } from './components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody';
249
+ export type { SwipableMobileCarouselSlideBodyProps } from './components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody';
250
+ export { SwipableMobileCarouselSlideFooter } from './components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter';
251
+ export type { SwipableMobileCarouselSlideFooterProps } from './components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter';
@@ -0,0 +1,94 @@
1
+ 'use client';
2
+
3
+ import { type RefObject, useEffect, useRef } from 'react';
4
+ import { lockBodyScroll, unlockBodyScroll } from './scroll-lock';
5
+
6
+ // Internal to the design system — not exported from `index.ts`. Shared by
7
+ // Dialog, Drawer, and Sheet, which independently implemented identical
8
+ // scrim/focus-trap/Escape/scroll-lock logic before this consolidation
9
+ // (RFC 0079, epic task 9.19). ConfirmDialog is deliberately not migrated
10
+ // here — it stays on the native `<dialog>` element, which already provides
11
+ // equivalent focus-trap/backdrop semantics more reliably for its use case.
12
+
13
+ export const OVERLAY_FOCUSABLE_SELECTOR =
14
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
15
+
16
+ /**
17
+ * Locks document scroll for the whole mounted lifetime of an overlay
18
+ * (including its exit animation), not just while `open` — so the background
19
+ * can't scroll while the panel is still visibly sliding/fading away.
20
+ * Ref-counted via `scroll-lock.ts` so nested overlays (e.g. a confirmation
21
+ * dialog inside an overlay-shell plugin) don't release the lock while a
22
+ * sibling is still open.
23
+ */
24
+ export function useOverlayScrollLock(mounted: boolean): void {
25
+ useEffect(() => {
26
+ if (!mounted) return;
27
+ lockBodyScroll();
28
+ return unlockBodyScroll;
29
+ }, [mounted]);
30
+ }
31
+
32
+ /**
33
+ * Captures the element focused before the overlay opened, moves focus into
34
+ * the panel when `active` becomes true, and restores the original focus on
35
+ * cleanup. Callers pass whichever prop should trigger the capture — `open`
36
+ * for Dialog/Drawer, `mounted` for Sheet (which restores focus on unmount
37
+ * rather than on the raw `open` transition).
38
+ */
39
+ export function useOverlayFocusCapture(
40
+ panelRef: RefObject<HTMLElement | null>,
41
+ active: boolean,
42
+ ): void {
43
+ const previouslyFocused = useRef<HTMLElement | null>(null);
44
+ useEffect(() => {
45
+ if (!active) return;
46
+ previouslyFocused.current = document.activeElement as HTMLElement | null;
47
+ const panel = panelRef.current;
48
+ const first = panel?.querySelector<HTMLElement>(OVERLAY_FOCUSABLE_SELECTOR);
49
+ (first ?? panel)?.focus();
50
+ return () => previouslyFocused.current?.focus();
51
+ }, [active, panelRef]);
52
+ }
53
+
54
+ /**
55
+ * Escape-to-close plus a Tab focus trap cycling within the panel. Attached
56
+ * at `document` level so no keyboard listener is needed on the overlay's own
57
+ * element (which would trigger `jsx-a11y/no-noninteractive-element-interactions`).
58
+ */
59
+ export function useOverlayKeyboardTrap(
60
+ panelRef: RefObject<HTMLElement | null>,
61
+ open: boolean,
62
+ onClose: () => void,
63
+ ): void {
64
+ useEffect(() => {
65
+ if (!open) return;
66
+ function handleKeyDown(e: KeyboardEvent) {
67
+ if (e.key === 'Escape') {
68
+ e.stopPropagation();
69
+ onClose();
70
+ return;
71
+ }
72
+ if (e.key !== 'Tab') return;
73
+ const panel = panelRef.current;
74
+ if (!panel) return;
75
+ const focusable = [...panel.querySelectorAll<HTMLElement>(OVERLAY_FOCUSABLE_SELECTOR)];
76
+ const first = focusable[0];
77
+ const last = focusable.at(-1);
78
+ if (!first || !last) {
79
+ e.preventDefault();
80
+ return;
81
+ }
82
+ const active = document.activeElement;
83
+ if (e.shiftKey && active === first) {
84
+ e.preventDefault();
85
+ last.focus();
86
+ } else if (!e.shiftKey && active === last) {
87
+ e.preventDefault();
88
+ first.focus();
89
+ }
90
+ }
91
+ document.addEventListener('keydown', handleKeyDown);
92
+ return () => document.removeEventListener('keydown', handleKeyDown);
93
+ }, [open, onClose, panelRef]);
94
+ }