@mrmeg/expo-ui 0.27.0 → 0.28.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 (145) hide show
  1. package/CHANGELOG.md +712 -0
  2. package/LLM_USAGE.md +260 -32
  3. package/README.md +558 -101
  4. package/dist/components/Accordion.js +12 -6
  5. package/dist/components/AnimatedView.d.ts +1 -1
  6. package/dist/components/AnimatedView.js +5 -2
  7. package/dist/components/Avatar.d.ts +1 -1
  8. package/dist/components/Avatar.js +2 -2
  9. package/dist/components/Badge.js +5 -1
  10. package/dist/components/BottomSheet.d.ts +66 -9
  11. package/dist/components/BottomSheet.js +143 -39
  12. package/dist/components/Button.d.ts +20 -4
  13. package/dist/components/Button.js +64 -43
  14. package/dist/components/Card.d.ts +1 -1
  15. package/dist/components/Card.js +24 -4
  16. package/dist/components/Carousel.js +2 -2
  17. package/dist/components/Checkbox.js +51 -44
  18. package/dist/components/Collapsible.js +7 -3
  19. package/dist/components/Dialog.js +178 -29
  20. package/dist/components/Drawer.d.ts +1 -1
  21. package/dist/components/Drawer.js +65 -55
  22. package/dist/components/DropdownMenu.js +11 -7
  23. package/dist/components/EmptyState.d.ts +2 -2
  24. package/dist/components/EmptyState.js +5 -2
  25. package/dist/components/Hydrated.d.ts +31 -0
  26. package/dist/components/Hydrated.js +30 -0
  27. package/dist/components/Icon.d.ts +17 -5
  28. package/dist/components/Icon.js +31 -10
  29. package/dist/components/InputOTP.js +7 -3
  30. package/dist/components/Item.d.ts +103 -4
  31. package/dist/components/Item.js +168 -15
  32. package/dist/components/KeyboardAvoidingView.d.ts +1 -1
  33. package/dist/components/KeyboardAvoidingView.js +4 -1
  34. package/dist/components/Label.js +3 -2
  35. package/dist/components/MaxWidthContainer.js +1 -1
  36. package/dist/components/Notification.js +45 -27
  37. package/dist/components/Popover.d.ts +18 -5
  38. package/dist/components/Popover.js +72 -8
  39. package/dist/components/Progress.js +8 -6
  40. package/dist/components/RadioGroup.js +52 -49
  41. package/dist/components/Screen.d.ts +61 -0
  42. package/dist/components/Screen.js +62 -0
  43. package/dist/components/SectionHeader.d.ts +1 -1
  44. package/dist/components/SegmentedControl.d.ts +12 -4
  45. package/dist/components/SegmentedControl.js +94 -4
  46. package/dist/components/Select.js +21 -26
  47. package/dist/components/Skeleton.js +24 -10
  48. package/dist/components/Slider.d.ts +11 -3
  49. package/dist/components/Slider.js +74 -6
  50. package/dist/components/StatCard.d.ts +4 -2
  51. package/dist/components/StatCard.js +3 -1
  52. package/dist/components/StyledText.d.ts +7 -1
  53. package/dist/components/StyledText.js +5 -3
  54. package/dist/components/Switch.d.ts +1 -1
  55. package/dist/components/Switch.js +23 -32
  56. package/dist/components/Tabs.d.ts +2 -2
  57. package/dist/components/Tabs.js +13 -24
  58. package/dist/components/TextInput.d.ts +1 -1
  59. package/dist/components/TextInput.js +73 -27
  60. package/dist/components/Toggle.d.ts +2 -42
  61. package/dist/components/Toggle.js +38 -27
  62. package/dist/components/ToggleGroup.d.ts +1 -1
  63. package/dist/components/ToggleGroup.js +37 -32
  64. package/dist/components/Tooltip.d.ts +1 -1
  65. package/dist/components/Tooltip.js +9 -3
  66. package/dist/components/UIProvider.d.ts +22 -2
  67. package/dist/components/UIProvider.js +15 -3
  68. package/dist/components/bottomSheetDismiss.d.ts +19 -0
  69. package/dist/components/bottomSheetDismiss.js +44 -0
  70. package/dist/components/bottomSheetHostSupport.d.ts +46 -0
  71. package/dist/components/bottomSheetHostSupport.js +67 -0
  72. package/dist/components/index.d.ts +46 -44
  73. package/dist/components/index.js +2 -0
  74. package/dist/components/keyboardDismiss.d.ts +25 -0
  75. package/dist/components/keyboardDismiss.js +60 -3
  76. package/dist/components/nativeTextField.android.js +13 -9
  77. package/dist/constants/colors.d.ts +40 -5
  78. package/dist/constants/colors.js +26 -8
  79. package/dist/constants/fonts.d.ts +41 -2
  80. package/dist/constants/fonts.js +46 -7
  81. package/dist/constants/index.d.ts +5 -4
  82. package/dist/constants/index.js +1 -0
  83. package/dist/constants/interaction.d.ts +18 -0
  84. package/dist/constants/interaction.js +18 -0
  85. package/dist/constants/spacing.d.ts +2 -1
  86. package/dist/constants/spacing.js +2 -1
  87. package/dist/design-system.json +154 -2
  88. package/dist/hooks/index.d.ts +12 -7
  89. package/dist/hooks/index.js +5 -0
  90. package/dist/hooks/useDimensions.d.ts +13 -8
  91. package/dist/hooks/useDimensions.js +123 -48
  92. package/dist/hooks/useFocusVisible.d.ts +30 -0
  93. package/dist/hooks/useFocusVisible.js +45 -0
  94. package/dist/hooks/useFontStyle.d.ts +2 -2
  95. package/dist/hooks/useFontStyle.js +3 -2
  96. package/dist/hooks/useHydrated.d.ts +13 -0
  97. package/dist/hooks/useHydrated.js +19 -0
  98. package/dist/hooks/useNotificationOffset.d.ts +20 -0
  99. package/dist/hooks/useNotificationOffset.js +34 -0
  100. package/dist/hooks/useResources.d.ts +21 -20
  101. package/dist/hooks/useResources.js +72 -68
  102. package/dist/hooks/useScalePress.d.ts +8 -5
  103. package/dist/hooks/useScalePress.js +16 -9
  104. package/dist/hooks/useShape.d.ts +24 -0
  105. package/dist/hooks/useShape.js +24 -0
  106. package/dist/hooks/useStaggeredEntrance.js +9 -7
  107. package/dist/hooks/useTheme.d.ts +3 -3
  108. package/dist/hooks/useTheme.js +177 -66
  109. package/dist/hooks/useWindowInsets.d.ts +13 -0
  110. package/dist/hooks/useWindowInsets.js +23 -0
  111. package/dist/index.d.ts +5 -5
  112. package/dist/lib/composeRefs.d.ts +8 -0
  113. package/dist/lib/composeRefs.js +20 -0
  114. package/dist/lib/haptics.d.ts +12 -0
  115. package/dist/lib/haptics.js +21 -0
  116. package/dist/lib/index.d.ts +6 -4
  117. package/dist/lib/index.js +2 -0
  118. package/dist/lib/interFonts.d.ts +15 -0
  119. package/dist/lib/interFonts.js +1 -0
  120. package/dist/lib/interFonts.native.d.ts +3 -0
  121. package/dist/lib/interFonts.native.js +20 -0
  122. package/dist/lib/popoverPlacement.d.ts +36 -0
  123. package/dist/lib/popoverPlacement.js +29 -0
  124. package/dist/lib/stateSurface.d.ts +28 -0
  125. package/dist/lib/stateSurface.js +30 -0
  126. package/dist/lib/themedStyles.d.ts +1 -1
  127. package/dist/lib/useAnimatedValue.d.ts +15 -0
  128. package/dist/lib/useAnimatedValue.js +19 -0
  129. package/dist/lib/webHitTarget.d.ts +40 -0
  130. package/dist/lib/webHitTarget.js +37 -0
  131. package/dist/state/feedbackStore.d.ts +24 -0
  132. package/dist/state/feedbackStore.js +16 -0
  133. package/dist/state/globalUIStore.d.ts +27 -0
  134. package/dist/state/globalUIStore.js +23 -0
  135. package/dist/state/index.d.ts +7 -5
  136. package/dist/state/index.js +2 -0
  137. package/dist/state/initialScheme.d.ts +27 -0
  138. package/dist/state/initialScheme.js +29 -0
  139. package/dist/state/notify.d.ts +1 -1
  140. package/dist/state/themeColorScope.d.ts +1 -1
  141. package/dist/state/themeStore.d.ts +69 -16
  142. package/dist/state/themeStore.js +65 -38
  143. package/llms-full.md +56 -8
  144. package/llms.txt +10 -1
  145. package/package.json +69 -2
@@ -1,7 +1,7 @@
1
1
  {
2
- "schemaVersion": 1,
2
+ "schemaVersion": 2,
3
3
  "package": "@mrmeg/expo-ui",
4
- "version": "0.27.0",
4
+ "version": "0.28.0",
5
5
  "tokens": {
6
6
  "spacing": {
7
7
  "entries": [
@@ -97,6 +97,10 @@
97
97
  "name": "touchTarget",
98
98
  "value": 44
99
99
  },
100
+ {
101
+ "name": "minTarget",
102
+ "value": 24
103
+ },
100
104
  {
101
105
  "name": "buttonPadding",
102
106
  "value": 10
@@ -170,6 +174,50 @@
170
174
  "value": 48
171
175
  }
172
176
  ]
177
+ },
178
+ "typography": {
179
+ "entries": [
180
+ {
181
+ "name": "xs",
182
+ "value": 11,
183
+ "lineHeight": 16.5
184
+ },
185
+ {
186
+ "name": "sm",
187
+ "value": 12,
188
+ "lineHeight": 18
189
+ },
190
+ {
191
+ "name": "base",
192
+ "value": 14,
193
+ "lineHeight": 21
194
+ },
195
+ {
196
+ "name": "body",
197
+ "value": 15,
198
+ "lineHeight": 24.75
199
+ },
200
+ {
201
+ "name": "lg",
202
+ "value": 18,
203
+ "lineHeight": 27
204
+ },
205
+ {
206
+ "name": "xl",
207
+ "value": 22,
208
+ "lineHeight": 26.4
209
+ },
210
+ {
211
+ "name": "xxl",
212
+ "value": 28,
213
+ "lineHeight": 33.6
214
+ },
215
+ {
216
+ "name": "display",
217
+ "value": 34,
218
+ "lineHeight": 40.8
219
+ }
220
+ ]
173
221
  }
174
222
  },
175
223
  "palette": {
@@ -450,6 +498,86 @@
450
498
  "serif",
451
499
  "mono"
452
500
  ],
501
+ "fonts": {
502
+ "families": {
503
+ "serif": {
504
+ "light": [
505
+ "Georgia, 'Times New Roman', serif",
506
+ "Georgia"
507
+ ],
508
+ "regular": [
509
+ "Georgia, 'Times New Roman', serif",
510
+ "Georgia"
511
+ ],
512
+ "medium": [
513
+ "Georgia, 'Times New Roman', serif",
514
+ "Georgia"
515
+ ],
516
+ "semibold": [
517
+ "Georgia, 'Times New Roman', serif",
518
+ "Georgia"
519
+ ],
520
+ "bold": [
521
+ "Georgia, 'Times New Roman', serif",
522
+ "Georgia"
523
+ ]
524
+ },
525
+ "sansSerif": {
526
+ "light": [
527
+ "\"Inter\", system-ui, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\"",
528
+ "Inter_400Regular",
529
+ "sans-serif"
530
+ ],
531
+ "regular": [
532
+ "\"Inter\", system-ui, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\"",
533
+ "Inter_400Regular",
534
+ "sans-serif"
535
+ ],
536
+ "medium": [
537
+ "\"Inter\", system-ui, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\"",
538
+ "Inter_500Medium",
539
+ "sans-serif"
540
+ ],
541
+ "semibold": [
542
+ "\"Inter\", system-ui, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\"",
543
+ "Inter_600SemiBold",
544
+ "sans-serif"
545
+ ],
546
+ "bold": [
547
+ "\"Inter\", system-ui, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\"",
548
+ "Inter_700Bold",
549
+ "sans-serif"
550
+ ]
551
+ },
552
+ "mono": {
553
+ "light": [
554
+ "ui-monospace, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace",
555
+ "Menlo",
556
+ "monospace"
557
+ ],
558
+ "regular": [
559
+ "ui-monospace, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace",
560
+ "Menlo",
561
+ "monospace"
562
+ ],
563
+ "medium": [
564
+ "ui-monospace, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace",
565
+ "Menlo",
566
+ "monospace"
567
+ ],
568
+ "semibold": [
569
+ "ui-monospace, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace",
570
+ "Menlo",
571
+ "monospace"
572
+ ],
573
+ "bold": [
574
+ "ui-monospace, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace",
575
+ "Menlo",
576
+ "monospace"
577
+ ]
578
+ }
579
+ }
580
+ },
453
581
  "components": [
454
582
  {
455
583
  "name": "Accordion",
@@ -1297,6 +1425,14 @@
1297
1425
  "variantValues": null,
1298
1426
  "sizeValues": null
1299
1427
  },
1428
+ {
1429
+ "name": "Hydrated",
1430
+ "file": "Hydrated.tsx",
1431
+ "hasSize": false,
1432
+ "variantProp": null,
1433
+ "variantValues": null,
1434
+ "sizeValues": null
1435
+ },
1300
1436
  {
1301
1437
  "name": "Icon",
1302
1438
  "file": "Icon.tsx",
@@ -1321,6 +1457,14 @@
1321
1457
  "variantValues": null,
1322
1458
  "sizeValues": null
1323
1459
  },
1460
+ {
1461
+ "name": "ItemGroup",
1462
+ "file": "Item.tsx",
1463
+ "hasSize": false,
1464
+ "variantProp": null,
1465
+ "variantValues": null,
1466
+ "sizeValues": null
1467
+ },
1324
1468
  {
1325
1469
  "name": "ItemMedia",
1326
1470
  "file": "Item.tsx",
@@ -1404,6 +1548,14 @@
1404
1548
  "variantValues": null,
1405
1549
  "sizeValues": null
1406
1550
  },
1551
+ {
1552
+ "name": "Screen",
1553
+ "file": "Screen.tsx",
1554
+ "hasSize": false,
1555
+ "variantProp": null,
1556
+ "variantValues": null,
1557
+ "sizeValues": null
1558
+ },
1407
1559
  {
1408
1560
  "name": "Popover",
1409
1561
  "file": "Popover.tsx",
@@ -1,7 +1,12 @@
1
- export * from "./useDimensions";
2
- export * from "./useFontStyle";
3
- export * from "./useReduceMotion";
4
- export * from "./useResources";
5
- export * from "./useScalePress";
6
- export * from "./useStaggeredEntrance";
7
- export * from "./useTheme";
1
+ export * from "./useDimensions.js";
2
+ export * from "./useFocusVisible.js";
3
+ export * from "./useFontStyle.js";
4
+ export * from "./useReduceMotion.js";
5
+ export * from "./useResources.js";
6
+ export * from "./useScalePress.js";
7
+ export * from "./useShape.js";
8
+ export * from "./useStaggeredEntrance.js";
9
+ export * from "./useTheme.js";
10
+ export { useHydrated } from "./useHydrated.js";
11
+ export { useNotificationOffset } from "./useNotificationOffset.js";
12
+ export { useWindowInsets } from "./useWindowInsets.js";
@@ -1,7 +1,12 @@
1
1
  export * from "./useDimensions.js";
2
+ export * from "./useFocusVisible.js";
2
3
  export * from "./useFontStyle.js";
3
4
  export * from "./useReduceMotion.js";
4
5
  export * from "./useResources.js";
5
6
  export * from "./useScalePress.js";
7
+ export * from "./useShape.js";
6
8
  export * from "./useStaggeredEntrance.js";
7
9
  export * from "./useTheme.js";
10
+ export { useHydrated } from "./useHydrated.js";
11
+ export { useNotificationOffset } from "./useNotificationOffset.js";
12
+ export { useWindowInsets } from "./useWindowInsets.js";
@@ -4,7 +4,7 @@
4
4
  * 1. A desktop visitor sees no reflow — their real viewport lands in the
5
5
  * same breakpoint.
6
6
  * 2. A mobile visitor gets one frame of desktop-styled content before the
7
- * post-mount effect snaps to real dimensions — better than the inverse
7
+ * post-hydration update snaps to real dimensions — better than the inverse
8
8
  * (every desktop visitor seeing mobile-tiny, then snapping).
9
9
  * 3. It matches the frame the app hands `SafeAreaProvider`, and it is the
10
10
  * value the HTML shell `expo export` renders in Node is built from, so
@@ -29,13 +29,18 @@ type WindowDimensions = {
29
29
  * Provides a consistent way to access window dimensions and screen size
30
30
  * information across mobile and web.
31
31
  *
32
- * On web the first render can't read the window: server-side there is no DOM,
33
- * and reading it during the browser's first render would disagree with the
34
- * markup it hydrates. The width comes from `SsrViewportContext` when the host
35
- * provides a per-request value (server render + first client render must
36
- * provide the same one), falling back to `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT`.
37
- * A post-mount effect swaps in the real viewport and follows resize from
38
- * there.
32
+ * On web the server render and the browser's hydration pass can't read the
33
+ * window: server-side there is no DOM, and reading it while hydrating would
34
+ * disagree with the markup. Both use the width from `SsrViewportContext` when
35
+ * the host provides a per-request value (server render + first client render
36
+ * must provide the same one), falling back to `DEFAULT_VIEWPORT_WIDTH` /
37
+ * `_HEIGHT`. Right after hydration the real viewport takes over, and resize
38
+ * is followed from there. Every consumer shares one window listener and one
39
+ * snapshot, re-renders only when the width or height changes, and a component
40
+ * that mounts after hydration reads the current viewport straight away.
41
+ *
42
+ * The platform branch is chosen once, at module load (`Platform.OS` never
43
+ * changes at runtime), so each platform calls exactly one set of hooks.
39
44
  */
40
45
  export declare const useDimensions: () => WindowDimensions;
41
46
  export {};
@@ -1,4 +1,4 @@
1
- import { use, useEffect, useState } from "react";
1
+ import { use, useMemo, useSyncExternalStore } from "react";
2
2
  import { Platform, useWindowDimensions } from "react-native";
3
3
  import { SsrViewportContext } from "../state/SsrViewportContext.js";
4
4
  /**
@@ -7,7 +7,7 @@ import { SsrViewportContext } from "../state/SsrViewportContext.js";
7
7
  * 1. A desktop visitor sees no reflow — their real viewport lands in the
8
8
  * same breakpoint.
9
9
  * 2. A mobile visitor gets one frame of desktop-styled content before the
10
- * post-mount effect snaps to real dimensions — better than the inverse
10
+ * post-hydration update snaps to real dimensions — better than the inverse
11
11
  * (every desktop visitor seeing mobile-tiny, then snapping).
12
12
  * 3. It matches the frame the app hands `SafeAreaProvider`, and it is the
13
13
  * value the HTML shell `expo export` renders in Node is built from, so
@@ -34,61 +34,136 @@ const calculateDimensionFlags = (width, height) => {
34
34
  isLargeScreen: width > SCREEN_SIZES.MEDIUM,
35
35
  };
36
36
  };
37
- // Persist the real viewport width as a cookie on first mount so subsequent
38
- // SSR requests can render at the user's actual layout (no reflow on repeat
39
- // visits). The server reads this cookie via resolveSsrViewportWidth in
40
- // server/lib/ssrViewport.ts.
37
+ // Persist the real viewport width as a cookie so subsequent SSR requests can
38
+ // render at the user's actual layout (no reflow on repeat visits). The server
39
+ // reads this cookie via resolveSsrViewportWidth in server/lib/ssrViewport.ts.
41
40
  const SSR_VIEWPORT_COOKIE = "mrmeg-vw";
42
41
  const SSR_VIEWPORT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
42
+ // Resize fires continuously while a window is dragged; the cookie only has to
43
+ // hold the width the drag settled on.
44
+ const SSR_VIEWPORT_COOKIE_DEBOUNCE_MS = 250;
45
+ // Round to nearest 10 so resize-driven writes don't bust HTTP caching on
46
+ // every pixel of horizontal movement.
47
+ const roundViewportWidth = (width) => Math.round(width / 10) * 10;
48
+ // ---------------------------------------------------------------------------
49
+ // Web: one window store shared by every useDimensions() consumer (same shape
50
+ // as useReduceMotion's). A single `resize` listener, attached while at least
51
+ // one consumer is mounted, updates one snapshot and notifies consumers only
52
+ // when the width or height actually changed.
53
+ // ---------------------------------------------------------------------------
54
+ let windowSnapshot = null;
55
+ const windowListeners = new Set();
56
+ let writtenCookieWidth = null;
57
+ let pendingCookieWidth = null;
58
+ let cookieTimer = null;
43
59
  function writeViewportCookie(width) {
44
60
  if (typeof document === "undefined")
45
61
  return;
46
- // Round to nearest 10 so resize-driven writes don't bust HTTP caching on
47
- // every pixel of horizontal movement.
48
- const rounded = Math.round(width / 10) * 10;
62
+ const rounded = roundViewportWidth(width);
63
+ writtenCookieWidth = rounded;
49
64
  document.cookie = `${SSR_VIEWPORT_COOKIE}=${rounded}; path=/; max-age=${SSR_VIEWPORT_COOKIE_MAX_AGE}; SameSite=Lax`;
50
65
  }
66
+ /** Reads the window, reusing the current snapshot when nothing changed. */
67
+ function readWindow() {
68
+ const width = typeof window === "undefined" ? DEFAULT_VIEWPORT_WIDTH : window.innerWidth;
69
+ const height = typeof window === "undefined" ? DEFAULT_VIEWPORT_HEIGHT : window.innerHeight;
70
+ if (!windowSnapshot || windowSnapshot.width !== width || windowSnapshot.height !== height) {
71
+ windowSnapshot = calculateDimensionFlags(width, height);
72
+ }
73
+ return windowSnapshot;
74
+ }
75
+ function flushViewportCookie() {
76
+ if (cookieTimer !== null) {
77
+ clearTimeout(cookieTimer);
78
+ cookieTimer = null;
79
+ }
80
+ if (pendingCookieWidth !== null) {
81
+ const width = pendingCookieWidth;
82
+ pendingCookieWidth = null;
83
+ writeViewportCookie(width);
84
+ }
85
+ }
86
+ function scheduleViewportCookie(width) {
87
+ if (roundViewportWidth(width) === writtenCookieWidth) {
88
+ pendingCookieWidth = null;
89
+ if (cookieTimer !== null) {
90
+ clearTimeout(cookieTimer);
91
+ cookieTimer = null;
92
+ }
93
+ return;
94
+ }
95
+ pendingCookieWidth = width;
96
+ if (cookieTimer !== null)
97
+ clearTimeout(cookieTimer);
98
+ cookieTimer = setTimeout(flushViewportCookie, SSR_VIEWPORT_COOKIE_DEBOUNCE_MS);
99
+ }
100
+ function handleWindowResize() {
101
+ const previous = windowSnapshot;
102
+ const next = readWindow();
103
+ if (next !== previous) {
104
+ for (const listener of windowListeners)
105
+ listener();
106
+ }
107
+ scheduleViewportCookie(next.width);
108
+ }
109
+ // useSyncExternalStore contract: the first consumer attaches the one window
110
+ // listener (and records the real width in the cookie, once per page view
111
+ // rather than once per consumer); the last one to unmount detaches it.
112
+ function subscribeToWindow(listener) {
113
+ windowListeners.add(listener);
114
+ if (windowListeners.size === 1 && canListenToWindow()) {
115
+ window.addEventListener("resize", handleWindowResize);
116
+ writeViewportCookie(readWindow().width);
117
+ }
118
+ return () => {
119
+ windowListeners.delete(listener);
120
+ if (windowListeners.size === 0 && canListenToWindow()) {
121
+ window.removeEventListener("resize", handleWindowResize);
122
+ flushViewportCookie();
123
+ }
124
+ };
125
+ }
126
+ function canListenToWindow() {
127
+ return typeof window !== "undefined" && typeof window.addEventListener === "function";
128
+ }
129
+ function getWindowSnapshot() {
130
+ // While subscribed, the resize listener keeps the snapshot current, so a
131
+ // render never reads (and forces layout on) the window.
132
+ return windowListeners.size > 0 && windowSnapshot ? windowSnapshot : readWindow();
133
+ }
134
+ // The server has no window, and the browser's hydration pass must render what
135
+ // the server did: `null` selects the seeded frame below for both.
136
+ function getServerWindowSnapshot() {
137
+ return null;
138
+ }
139
+ function useWebDimensions() {
140
+ const ssrWidth = use(SsrViewportContext);
141
+ const live = useSyncExternalStore(subscribeToWindow, getWindowSnapshot, getServerWindowSnapshot);
142
+ const seeded = useMemo(() => calculateDimensionFlags(ssrWidth ?? DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT), [ssrWidth]);
143
+ return live ?? seeded;
144
+ }
145
+ // Native reads come from useWindowDimensions, which subscribes to rotation /
146
+ // split-screen / resize and tears the listener down for us — no manual
147
+ // Dimensions.addEventListener to leak.
148
+ function useNativeDimensions() {
149
+ const { width, height } = useWindowDimensions();
150
+ return useMemo(() => calculateDimensionFlags(width, height), [width, height]);
151
+ }
51
152
  /**
52
153
  * Provides a consistent way to access window dimensions and screen size
53
154
  * information across mobile and web.
54
155
  *
55
- * On web the first render can't read the window: server-side there is no DOM,
56
- * and reading it during the browser's first render would disagree with the
57
- * markup it hydrates. The width comes from `SsrViewportContext` when the host
58
- * provides a per-request value (server render + first client render must
59
- * provide the same one), falling back to `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT`.
60
- * A post-mount effect swaps in the real viewport and follows resize from
61
- * there.
156
+ * On web the server render and the browser's hydration pass can't read the
157
+ * window: server-side there is no DOM, and reading it while hydrating would
158
+ * disagree with the markup. Both use the width from `SsrViewportContext` when
159
+ * the host provides a per-request value (server render + first client render
160
+ * must provide the same one), falling back to `DEFAULT_VIEWPORT_WIDTH` /
161
+ * `_HEIGHT`. Right after hydration the real viewport takes over, and resize
162
+ * is followed from there. Every consumer shares one window listener and one
163
+ * snapshot, re-renders only when the width or height changes, and a component
164
+ * that mounts after hydration reads the current viewport straight away.
165
+ *
166
+ * The platform branch is chosen once, at module load (`Platform.OS` never
167
+ * changes at runtime), so each platform calls exactly one set of hooks.
62
168
  */
63
- export const useDimensions = () => {
64
- const isWeb = Platform.OS === "web";
65
- const ssrWidth = use(SsrViewportContext);
66
- // Native reads come from useWindowDimensions, which subscribes to rotation /
67
- // split-screen / resize and tears the listener down for us — no manual
68
- // Dimensions.addEventListener to leak. On web we ignore it and drive layout
69
- // from the seeded frame + the resize listener below.
70
- const native = useWindowDimensions();
71
- const [dimensions, setDimensions] = useState(() => calculateDimensionFlags(ssrWidth ?? DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT));
72
- // Web: read the real viewport after mount and follow resize events. Keeping
73
- // this in an effect (not render) keeps the first render identical to the
74
- // exported markup it hydrates.
75
- useEffect(() => {
76
- if (!isWeb)
77
- return;
78
- const syncFromWindow = () => {
79
- setDimensions(calculateDimensionFlags(window.innerWidth, window.innerHeight));
80
- writeViewportCookie(window.innerWidth);
81
- };
82
- syncFromWindow();
83
- window.addEventListener("resize", syncFromWindow);
84
- return () => {
85
- window.removeEventListener("resize", syncFromWindow);
86
- };
87
- }, [isWeb]);
88
- // Native: useWindowDimensions already reacts to changes; mirror it into our
89
- // enriched flags. (On web `native` is unused — the effect above wins.)
90
- if (!isWeb) {
91
- return calculateDimensionFlags(native.width, native.height);
92
- }
93
- return dimensions;
94
- };
169
+ export const useDimensions = Platform.OS === "web" ? useWebDimensions : useNativeDimensions;
@@ -0,0 +1,30 @@
1
+ import { type NativeSyntheticEvent, type TargetedEvent } from "react-native";
2
+ export type FocusVisibleEvent = NativeSyntheticEvent<TargetedEvent>;
3
+ export interface UseFocusVisibleOptions<E = FocusVisibleEvent> {
4
+ /** Caller's focus handler, invoked after the ring state updates. */
5
+ onFocus?: ((event: E) => void) | null;
6
+ /** Caller's blur handler, invoked after the ring state updates. */
7
+ onBlur?: ((event: E) => void) | null;
8
+ }
9
+ /**
10
+ * Focus-ring state gated on `:focus-visible`.
11
+ *
12
+ * On web a pointer tap focuses the element too, which left the ring visible
13
+ * after every click; `:focus-visible` is only true for keyboard-driven focus,
14
+ * so the ring follows it and falls back to showing when the target can't be
15
+ * queried (non-DOM targets, older engines). Off web every focus shows the
16
+ * ring. Spread `onFocus`/`onBlur` onto the Pressable and layer
17
+ * `getFocusRingStyle()` while `focused` is true and the control is enabled.
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * const focus = useFocusVisible({ onFocus, onBlur });
22
+ * <Pressable onFocus={focus.onFocus} onBlur={focus.onBlur}
23
+ * style={[styles.base, focus.focused && !disabled && getFocusRingStyle()]} />
24
+ * ```
25
+ */
26
+ export declare function useFocusVisible<E = FocusVisibleEvent>(options?: UseFocusVisibleOptions<E>): {
27
+ focused: boolean;
28
+ onFocus: (event: E) => void;
29
+ onBlur: (event: E) => void;
30
+ };
@@ -0,0 +1,45 @@
1
+ import { useCallback, useState } from "react";
2
+ import { Platform } from "react-native";
3
+ /**
4
+ * Focus-ring state gated on `:focus-visible`.
5
+ *
6
+ * On web a pointer tap focuses the element too, which left the ring visible
7
+ * after every click; `:focus-visible` is only true for keyboard-driven focus,
8
+ * so the ring follows it and falls back to showing when the target can't be
9
+ * queried (non-DOM targets, older engines). Off web every focus shows the
10
+ * ring. Spread `onFocus`/`onBlur` onto the Pressable and layer
11
+ * `getFocusRingStyle()` while `focused` is true and the control is enabled.
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * const focus = useFocusVisible({ onFocus, onBlur });
16
+ * <Pressable onFocus={focus.onFocus} onBlur={focus.onBlur}
17
+ * style={[styles.base, focus.focused && !disabled && getFocusRingStyle()]} />
18
+ * ```
19
+ */
20
+ export function useFocusVisible(options = {}) {
21
+ const { onFocus: onFocusProp, onBlur: onBlurProp } = options;
22
+ const [focused, setFocused] = useState(false);
23
+ const onFocus = useCallback((event) => {
24
+ setFocused(isKeyboardFocus(event));
25
+ onFocusProp?.(event);
26
+ }, [onFocusProp]);
27
+ const onBlur = useCallback((event) => {
28
+ setFocused(false);
29
+ onBlurProp?.(event);
30
+ }, [onBlurProp]);
31
+ return { focused, onFocus, onBlur };
32
+ }
33
+ function isKeyboardFocus(event) {
34
+ if (Platform.OS !== "web")
35
+ return true;
36
+ const target = event?.nativeEvent?.target;
37
+ if (!target || typeof target.matches !== "function")
38
+ return true;
39
+ try {
40
+ return target.matches(":focus-visible");
41
+ }
42
+ catch {
43
+ return true;
44
+ }
45
+ }
@@ -1,4 +1,4 @@
1
- import { type FontFamilyWeight, type FontVariant, type ResolvedFontStyle } from "../constants/fonts";
1
+ import { type FontFamilyWeight, type FontStyleOptions, type FontVariant, type ResolvedFontStyle } from "../constants/fonts.js";
2
2
  /**
3
3
  * Resolve a `{ fontFamily, fontWeight? }` style through the theme store's
4
4
  * font overrides, re-rendering when a host app calls `setFonts`.
@@ -8,4 +8,4 @@ import { type FontFamilyWeight, type FontVariant, type ResolvedFontStyle } from
8
8
  * is identical to the package's hardcoded families, so layering it after a
9
9
  * static themed style changes nothing by default.
10
10
  */
11
- export declare function useFontStyle(weight?: FontFamilyWeight, variant?: FontVariant): ResolvedFontStyle;
11
+ export declare function useFontStyle(weight?: FontFamilyWeight, variant?: FontVariant, options?: Pick<FontStyleOptions, "italic">): ResolvedFontStyle;
@@ -9,9 +9,10 @@ import { useThemeStore } from "../state/themeStore.js";
9
9
  * is identical to the package's hardcoded families, so layering it after a
10
10
  * static themed style changes nothing by default.
11
11
  */
12
- export function useFontStyle(weight = "regular", variant = "sansSerif") {
12
+ export function useFontStyle(weight = "regular", variant = "sansSerif", options = {}) {
13
13
  // Subscribed, not read via getState(), so a `setFonts` call after mount
14
14
  // re-renders instead of leaving stale families on screen.
15
15
  const fontOverrides = useThemeStore((s) => s.fontOverrides);
16
- return resolveFontStyle(fontOverrides, variant, weight);
16
+ const serifPreset = useThemeStore((s) => s.serifPreset);
17
+ return resolveFontStyle(fontOverrides, variant, weight, { italic: options.italic, serifPreset });
17
18
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * False during server rendering and the hydration pass, true once the tree
3
+ * is live on the client (and always true on native, which never hydrates).
4
+ *
5
+ * Use it to defer DOM output that cannot be reproduced on the server. The one
6
+ * case in the kit: `useId()`-based `id` / `aria-labelledby` pairs. Under Expo
7
+ * Router's streamed SSR the client's hydration pass computes different tree
8
+ * ids than the server did, so an id emitted on both sides logs "A tree hydrated
9
+ * but some attributes … didn't match" and the link points at a stale id.
10
+ * Adding the attributes in the first client render after hydration is an
11
+ * ordinary update instead.
12
+ */
13
+ export declare function useHydrated(): boolean;
@@ -0,0 +1,19 @@
1
+ import { useSyncExternalStore } from "react";
2
+ const subscribe = () => () => { };
3
+ const clientSnapshot = () => true;
4
+ const serverSnapshot = () => false;
5
+ /**
6
+ * False during server rendering and the hydration pass, true once the tree
7
+ * is live on the client (and always true on native, which never hydrates).
8
+ *
9
+ * Use it to defer DOM output that cannot be reproduced on the server. The one
10
+ * case in the kit: `useId()`-based `id` / `aria-labelledby` pairs. Under Expo
11
+ * Router's streamed SSR the client's hydration pass computes different tree
12
+ * ids than the server did, so an id emitted on both sides logs "A tree hydrated
13
+ * but some attributes … didn't match" and the link points at a stale id.
14
+ * Adding the attributes in the first client render after hydration is an
15
+ * ordinary update instead.
16
+ */
17
+ export function useHydrated() {
18
+ return useSyncExternalStore(subscribe, clientSnapshot, serverSnapshot);
19
+ }
@@ -0,0 +1,20 @@
1
+ import { type NotificationOffset } from "../state/globalUIStore.js";
2
+ /**
3
+ * Keep global toasts clear of a bar this layout owns, for as long as it is
4
+ * mounted. The offset is measured from the window edge and includes the
5
+ * safe-area inset there, so a JS tab layout passes `useBottomTabBarHeight()`
6
+ * as-is and a native tab layout passes the bar height plus the bottom inset;
7
+ * `Notification` then sits 8 pt past the larger of the inset and the offset.
8
+ * Pass `null` (or `undefined`) to register nothing — for instance while the
9
+ * tab bar is hidden behind the keyboard. Several layouts may register at once;
10
+ * the largest value per edge wins. A no-op during server rendering.
11
+ *
12
+ * ```tsx
13
+ * // app/(tabs)/_layout.tsx with Expo Router's JS <Tabs>
14
+ * function TabBarOffset() {
15
+ * useNotificationOffset({ bottom: useBottomTabBarHeight() });
16
+ * return null;
17
+ * }
18
+ * ```
19
+ */
20
+ export declare function useNotificationOffset(offset: NotificationOffset | null | undefined): void;
@@ -0,0 +1,34 @@
1
+ import { useEffect, useId } from "react";
2
+ import { globalUIStore } from "../state/globalUIStore.js";
3
+ /**
4
+ * Keep global toasts clear of a bar this layout owns, for as long as it is
5
+ * mounted. The offset is measured from the window edge and includes the
6
+ * safe-area inset there, so a JS tab layout passes `useBottomTabBarHeight()`
7
+ * as-is and a native tab layout passes the bar height plus the bottom inset;
8
+ * `Notification` then sits 8 pt past the larger of the inset and the offset.
9
+ * Pass `null` (or `undefined`) to register nothing — for instance while the
10
+ * tab bar is hidden behind the keyboard. Several layouts may register at once;
11
+ * the largest value per edge wins. A no-op during server rendering.
12
+ *
13
+ * ```tsx
14
+ * // app/(tabs)/_layout.tsx with Expo Router's JS <Tabs>
15
+ * function TabBarOffset() {
16
+ * useNotificationOffset({ bottom: useBottomTabBarHeight() });
17
+ * return null;
18
+ * }
19
+ * ```
20
+ */
21
+ export function useNotificationOffset(offset) {
22
+ const key = useId();
23
+ const top = offset?.top;
24
+ const bottom = offset?.bottom;
25
+ const active = offset != null && (top !== undefined || bottom !== undefined);
26
+ useEffect(() => {
27
+ if (!active)
28
+ return;
29
+ globalUIStore.getState().setNotificationOffset(key, { top, bottom });
30
+ return () => {
31
+ globalUIStore.getState().clearNotificationOffset(key);
32
+ };
33
+ }, [active, key, top, bottom]);
34
+ }