@multiplatform.one/components 7.79.0 → 7.80.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 (268) hide show
  1. package/dist/cjs/appShell/AppShell.cjs +432 -0
  2. package/dist/cjs/appShell/AppShell.native.js +499 -0
  3. package/dist/cjs/appShell/AppShell.native.js.map +1 -0
  4. package/dist/cjs/appShell/index.cjs +1 -0
  5. package/dist/cjs/appShell/index.native.js +1 -0
  6. package/dist/cjs/appShell/index.native.js.map +1 -1
  7. package/dist/cjs/layouts/page.cjs +3 -0
  8. package/dist/cjs/layouts/page.native.js +3 -0
  9. package/dist/cjs/layouts/page.native.js.map +1 -1
  10. package/dist/cjs/layouts/screenScroll.cjs +55 -0
  11. package/dist/cjs/layouts/screenScroll.native.js +59 -0
  12. package/dist/cjs/layouts/screenScroll.native.js.map +1 -1
  13. package/dist/cjs/layouts/sidebar.cjs +46 -9
  14. package/dist/cjs/layouts/sidebar.native.js +49 -9
  15. package/dist/cjs/layouts/sidebar.native.js.map +1 -1
  16. package/dist/esm/Accordion/index.native.js.map +1 -1
  17. package/dist/esm/AnimateHeight/index.native.js.map +1 -1
  18. package/dist/esm/Avatar/getInitials.native.js.map +1 -1
  19. package/dist/esm/Avatar/index.native.js.map +1 -1
  20. package/dist/esm/Badge/index.native.js.map +1 -1
  21. package/dist/esm/Button.native.js.map +1 -1
  22. package/dist/esm/Carousel/index.native.js.map +1 -1
  23. package/dist/esm/Chip/index.native.js.map +1 -1
  24. package/dist/esm/Collapsible.native.js.map +1 -1
  25. package/dist/esm/ContextMenu/index.native.js.map +1 -1
  26. package/dist/esm/CopyField/CopyMenuButton.native.js.map +1 -1
  27. package/dist/esm/CopyField/index.native.js.map +1 -1
  28. package/dist/esm/Dialog.native.js.map +1 -1
  29. package/dist/esm/Diff/diff.native.js.map +1 -1
  30. package/dist/esm/Diff/index.native.js.map +1 -1
  31. package/dist/esm/DotIndicator/index.native.js.map +1 -1
  32. package/dist/esm/DropdownMenu/index.native.js.map +1 -1
  33. package/dist/esm/DropdownMenu/menuSettle.native.js.map +1 -1
  34. package/dist/esm/ElapsedProgress/index.native.js.map +1 -1
  35. package/dist/esm/ErrorBoundary/index.native.js.map +1 -1
  36. package/dist/esm/Heading.native.js.map +1 -1
  37. package/dist/esm/Kbd/index.native.js.map +1 -1
  38. package/dist/esm/Kbd/keys.native.js.map +1 -1
  39. package/dist/esm/Lightbox/MediaControl.native.js.map +1 -1
  40. package/dist/esm/Lightbox/index.gnome.native.js.map +1 -1
  41. package/dist/esm/Lightbox/shared.native.js.map +1 -1
  42. package/dist/esm/Lightbox/useLightbox.native.js.map +1 -1
  43. package/dist/esm/MachineLiteral/index.native.js.map +1 -1
  44. package/dist/esm/Material/index.native.js.map +1 -1
  45. package/dist/esm/Meter/index.native.js.map +1 -1
  46. package/dist/esm/Meter/meter.native.js.map +1 -1
  47. package/dist/esm/OverflowRow/index.native.js.map +1 -1
  48. package/dist/esm/Pagination.native.js.map +1 -1
  49. package/dist/esm/Popover.native.js.map +1 -1
  50. package/dist/esm/ProgressSteps/index.native.js.map +1 -1
  51. package/dist/esm/Quote/index.native.js.map +1 -1
  52. package/dist/esm/SchemeToggle/index.native.js.map +1 -1
  53. package/dist/esm/ScrollView/revealFocusedField.native.js.map +1 -1
  54. package/dist/esm/Separator/index.native.js.map +1 -1
  55. package/dist/esm/Sheet.native.js.map +1 -1
  56. package/dist/esm/SheetModal/SheetHeader.native.js.map +1 -1
  57. package/dist/esm/SheetModal/SheetNavigation.native.js.map +1 -1
  58. package/dist/esm/SheetModal/detents.native.js.map +1 -1
  59. package/dist/esm/SheetModal/useSheetFrameFocus.native.js.map +1 -1
  60. package/dist/esm/Skeleton/index.native.js.map +1 -1
  61. package/dist/esm/SkipLink/index.native.js.map +1 -1
  62. package/dist/esm/SortableList/SortableHandle.native.js.map +1 -1
  63. package/dist/esm/SortableList/index.native.js.map +1 -1
  64. package/dist/esm/SortableList/useSortableRows.native.js.map +1 -1
  65. package/dist/esm/SortableList/useUserOrder.native.js.map +1 -1
  66. package/dist/esm/Tabs/index.native.js.map +1 -1
  67. package/dist/esm/Text.native.js.map +1 -1
  68. package/dist/esm/ThemePresetPicker/index.native.js.map +1 -1
  69. package/dist/esm/Toast/index.native.js.map +1 -1
  70. package/dist/esm/Toast/store.native.js.map +1 -1
  71. package/dist/esm/Tooltip/nativePress.native.js.map +1 -1
  72. package/dist/esm/Tooltip/tooltipShared.native.js.map +1 -1
  73. package/dist/esm/TreeSelect/index.native.js.map +1 -1
  74. package/dist/esm/TreeSelect/treeSelectModel.native.js.map +1 -1
  75. package/dist/esm/Video/PlayerChrome.native.js.map +1 -1
  76. package/dist/esm/Video/VideoFrame.native.js.map +1 -1
  77. package/dist/esm/Video/types.native.js.map +1 -1
  78. package/dist/esm/Wheel/index.native.js.map +1 -1
  79. package/dist/esm/Wizard/index.native.js.map +1 -1
  80. package/dist/esm/actions/ActionBar.native.js.map +1 -1
  81. package/dist/esm/actions/DangerSection.native.js.map +1 -1
  82. package/dist/esm/actions/DestructiveAction.native.js.map +1 -1
  83. package/dist/esm/actions/HeaderActions.native.js.map +1 -1
  84. package/dist/esm/actions/StandardAction.native.js.map +1 -1
  85. package/dist/esm/actions/devWarn.native.js.map +1 -1
  86. package/dist/esm/actions/index.native.js.map +1 -1
  87. package/dist/esm/actions/severity.native.js.map +1 -1
  88. package/dist/esm/appShell/AppShell.mjs +406 -0
  89. package/dist/esm/appShell/AppShell.mjs.map +1 -0
  90. package/dist/esm/appShell/AppShell.native.js +470 -0
  91. package/dist/esm/appShell/AppShell.native.js.map +1 -0
  92. package/dist/esm/appShell/Dock.native.js.map +1 -1
  93. package/dist/esm/appShell/MpoTabHost.native.js.map +1 -1
  94. package/dist/esm/appShell/ScopeMenu.native.js.map +1 -1
  95. package/dist/esm/appShell/SettingsSplit.native.js.map +1 -1
  96. package/dist/esm/appShell/Springboard.native.js.map +1 -1
  97. package/dist/esm/appShell/SystemBar.native.js.map +1 -1
  98. package/dist/esm/appShell/TabBar.native.js.map +1 -1
  99. package/dist/esm/appShell/appTile.native.js.map +1 -1
  100. package/dist/esm/appShell/bottomChrome.native.js.map +1 -1
  101. package/dist/esm/appShell/index.mjs +1 -0
  102. package/dist/esm/appShell/index.mjs.map +1 -1
  103. package/dist/esm/appShell/index.native.js +1 -0
  104. package/dist/esm/appShell/index.native.js.map +1 -1
  105. package/dist/esm/charts/Axis.native.js.map +1 -1
  106. package/dist/esm/charts/BarChart.native.js.map +1 -1
  107. package/dist/esm/charts/ChartDataView.native.js.map +1 -1
  108. package/dist/esm/charts/ChartSurface.native.js.map +1 -1
  109. package/dist/esm/charts/LineChart.native.js.map +1 -1
  110. package/dist/esm/charts/PieChart.native.js.map +1 -1
  111. package/dist/esm/charts/Sparkline.native.js.map +1 -1
  112. package/dist/esm/charts/accessibility.native.js.map +1 -1
  113. package/dist/esm/charts/chartHeight.native.js.map +1 -1
  114. package/dist/esm/charts/composeChart.native.js.map +1 -1
  115. package/dist/esm/charts/index.native.js.map +1 -1
  116. package/dist/esm/charts/math.native.js.map +1 -1
  117. package/dist/esm/charts/series.native.js.map +1 -1
  118. package/dist/esm/charts/svg.native.js.map +1 -1
  119. package/dist/esm/charts/types.native.js.map +1 -1
  120. package/dist/esm/charts/useChartTheme.native.js.map +1 -1
  121. package/dist/esm/code/Code/index.native.js.map +1 -1
  122. package/dist/esm/code/CodeBlock/index.native.js.map +1 -1
  123. package/dist/esm/code/HighlightedCode/index.native.js.map +1 -1
  124. package/dist/esm/code/Highlighter/houseSyntaxTheme.native.js.map +1 -1
  125. package/dist/esm/code/Highlighter/index.native.js.map +1 -1
  126. package/dist/esm/code/Highlighter/namedGroupMatchAll.native.js.map +1 -1
  127. package/dist/esm/code/Pre/index.native.js.map +1 -1
  128. package/dist/esm/code/index.native.js.map +1 -1
  129. package/dist/esm/componentColors.native.js.map +1 -1
  130. package/dist/esm/feedback/ConfirmDialog.native.js.map +1 -1
  131. package/dist/esm/feedback/NotifyHost.native.js.map +1 -1
  132. package/dist/esm/feedback/copy.native.js.map +1 -1
  133. package/dist/esm/feedback/index.native.js.map +1 -1
  134. package/dist/esm/feedback/notify.native.js.map +1 -1
  135. package/dist/esm/feedback/route.native.js.map +1 -1
  136. package/dist/esm/feedback/store.native.js.map +1 -1
  137. package/dist/esm/formLayouts/FormLayouts.native.js.map +1 -1
  138. package/dist/esm/formLayouts/PreferencePanel.native.js.map +1 -1
  139. package/dist/esm/formLayouts/index.native.js.map +1 -1
  140. package/dist/esm/hooks/index.native.js.map +1 -1
  141. package/dist/esm/hooks/useClipboard.native.js.map +1 -1
  142. package/dist/esm/hooks/useDocumentTitle.native.js.map +1 -1
  143. package/dist/esm/hooks/useHoldCue.native.js.map +1 -1
  144. package/dist/esm/icons/CheckRegular.native.js.map +1 -1
  145. package/dist/esm/icons/MinusRegular.native.js.map +1 -1
  146. package/dist/esm/icons/index.native.js.map +1 -1
  147. package/dist/esm/icons/lucide.native.js.map +1 -1
  148. package/dist/esm/icons/themed.native.js.map +1 -1
  149. package/dist/esm/images/Image/ImageFrame.native.js.map +1 -1
  150. package/dist/esm/images/Image/framing.native.js.map +1 -1
  151. package/dist/esm/images/Image/mediaRadius.native.js.map +1 -1
  152. package/dist/esm/images/Image/shared.native.js.map +1 -1
  153. package/dist/esm/images/Image/types.native.js.map +1 -1
  154. package/dist/esm/images/index.native.js.map +1 -1
  155. package/dist/esm/index.native.js.map +1 -1
  156. package/dist/esm/knobImmuneExports.native.js.map +1 -1
  157. package/dist/esm/layouts/AsyncBoundary.native.js.map +1 -1
  158. package/dist/esm/layouts/Debug/index.native.js.map +1 -1
  159. package/dist/esm/layouts/EmptyState.native.js.map +1 -1
  160. package/dist/esm/layouts/IdentityPlate.native.js.map +1 -1
  161. package/dist/esm/layouts/KeyboardAvoidingWrapper/behavior.native.js.map +1 -1
  162. package/dist/esm/layouts/PageTemplates.native.js.map +1 -1
  163. package/dist/esm/layouts/PaneScaffold.native.js.map +1 -1
  164. package/dist/esm/layouts/SafeAreaWrapper/insets.native.js.map +1 -1
  165. package/dist/esm/layouts/StatusDot.native.js.map +1 -1
  166. package/dist/esm/layouts/StatusGlyphStrip.native.js.map +1 -1
  167. package/dist/esm/layouts/ViewSwitcher.native.js.map +1 -1
  168. package/dist/esm/layouts/attachments.native.js.map +1 -1
  169. package/dist/esm/layouts/bottomAccessory.native.js.map +1 -1
  170. package/dist/esm/layouts/breadcrumbs.native.js.map +1 -1
  171. package/dist/esm/layouts/index.native.js.map +1 -1
  172. package/dist/esm/layouts/page.mjs +4 -2
  173. package/dist/esm/layouts/page.mjs.map +1 -1
  174. package/dist/esm/layouts/page.native.js +4 -2
  175. package/dist/esm/layouts/page.native.js.map +1 -1
  176. package/dist/esm/layouts/pageHeadingScope.native.js.map +1 -1
  177. package/dist/esm/layouts/screenScroll.mjs +53 -1
  178. package/dist/esm/layouts/screenScroll.mjs.map +1 -1
  179. package/dist/esm/layouts/screenScroll.native.js +57 -1
  180. package/dist/esm/layouts/screenScroll.native.js.map +1 -1
  181. package/dist/esm/layouts/shellSidebarSlot.native.js.map +1 -1
  182. package/dist/esm/layouts/sidebar.mjs +45 -11
  183. package/dist/esm/layouts/sidebar.mjs.map +1 -1
  184. package/dist/esm/layouts/sidebar.native.js +48 -11
  185. package/dist/esm/layouts/sidebar.native.js.map +1 -1
  186. package/dist/esm/layouts/tags.native.js.map +1 -1
  187. package/dist/esm/layouts/timeline.native.js.map +1 -1
  188. package/dist/esm/layouts/toolbar.native.js.map +1 -1
  189. package/dist/esm/shared/actionScan.native.js.map +1 -1
  190. package/dist/esm/shared/i18n.native.js.map +1 -1
  191. package/dist/esm/shared/restoreRetries.native.js.map +1 -1
  192. package/dist/esm/shared/t.native.js.map +1 -1
  193. package/dist/esm/shared/textRidesText.native.js.map +1 -1
  194. package/dist/esm/shared/useReturnFocusOnClose.native.js.map +1 -1
  195. package/dist/esm/surfaces.native.js.map +1 -1
  196. package/dist/esm/tamagui.native.js.map +1 -1
  197. package/dist/esm/testSetup.native.js.map +1 -1
  198. package/dist/esm/utils/setTinted.native.js.map +1 -1
  199. package/dist/esm/utils/unwrapText.native.js.map +1 -1
  200. package/dist/esm/views/Calendar.native.js.map +1 -1
  201. package/dist/esm/views/Dashboard.native.js.map +1 -1
  202. package/dist/esm/views/Gantt.native.js.map +1 -1
  203. package/dist/esm/views/GanttDateGrip.native.js.map +1 -1
  204. package/dist/esm/views/ImageGrid.native.js.map +1 -1
  205. package/dist/esm/views/List.native.js.map +1 -1
  206. package/dist/esm/views/ListGroup.native.js.map +1 -1
  207. package/dist/esm/views/ListRow.native.js.map +1 -1
  208. package/dist/esm/views/Matrix.native.js.map +1 -1
  209. package/dist/esm/views/ReportBuilder.native.js.map +1 -1
  210. package/dist/esm/views/ShortcutCard.native.js.map +1 -1
  211. package/dist/esm/views/TreeView.native.js.map +1 -1
  212. package/dist/esm/views/calendarMath.native.js.map +1 -1
  213. package/dist/esm/views/ganttIntervals.native.js.map +1 -1
  214. package/dist/esm/views/ganttMath.native.js.map +1 -1
  215. package/dist/esm/views/index.native.js.map +1 -1
  216. package/dist/esm/views/kanbanDnd.native.js.map +1 -1
  217. package/dist/esm/views/matrixMath.native.js.map +1 -1
  218. package/dist/esm/views/sameNameScope.native.js.map +1 -1
  219. package/dist/esm/views/treeViewDnd.native.js.map +1 -1
  220. package/dist/esm/views/treeViewLayout.native.js.map +1 -1
  221. package/dist/esm/views/treeViewMotion.native.js.map +1 -1
  222. package/dist/esm/views/treeViewReveal.native.js.map +1 -1
  223. package/dist/esm/views/usePageFlowLoadMore.native.js.map +1 -1
  224. package/dist/esm/views/widgetChrome.native.js.map +1 -1
  225. package/dist/jsx/appShell/AppShell.mjs +406 -0
  226. package/dist/jsx/appShell/AppShell.mjs.map +1 -0
  227. package/dist/jsx/appShell/AppShell.native.js +499 -0
  228. package/dist/jsx/appShell/AppShell.native.js.map +1 -0
  229. package/dist/jsx/appShell/index.mjs +1 -0
  230. package/dist/jsx/appShell/index.mjs.map +1 -1
  231. package/dist/jsx/appShell/index.native.js +1 -0
  232. package/dist/jsx/appShell/index.native.js.map +1 -1
  233. package/dist/jsx/feedback/NotifyHost.native.js +1 -1
  234. package/dist/jsx/formLayouts/PreferencePanel.native.js +1 -1
  235. package/dist/jsx/index.native.js +1 -1
  236. package/dist/jsx/layouts/AsyncBoundary.native.js +1 -1
  237. package/dist/jsx/layouts/AsyncBoundary.native.js.map +1 -1
  238. package/dist/jsx/layouts/page.mjs +4 -2
  239. package/dist/jsx/layouts/page.mjs.map +1 -1
  240. package/dist/jsx/layouts/page.native.js +3 -0
  241. package/dist/jsx/layouts/page.native.js.map +1 -1
  242. package/dist/jsx/layouts/screenScroll.mjs +53 -1
  243. package/dist/jsx/layouts/screenScroll.mjs.map +1 -1
  244. package/dist/jsx/layouts/screenScroll.native.js +59 -0
  245. package/dist/jsx/layouts/screenScroll.native.js.map +1 -1
  246. package/dist/jsx/layouts/sidebar.mjs +45 -11
  247. package/dist/jsx/layouts/sidebar.mjs.map +1 -1
  248. package/dist/jsx/layouts/sidebar.native.js +49 -9
  249. package/dist/jsx/layouts/sidebar.native.js.map +1 -1
  250. package/package.json +9 -9
  251. package/src/appShell/AppShell.spec.tsx +259 -0
  252. package/src/appShell/AppShell.stories.tsx +112 -0
  253. package/src/appShell/AppShell.tsx +526 -0
  254. package/src/appShell/index.ts +1 -0
  255. package/src/layouts/Sidebar.spec.tsx +31 -0
  256. package/src/layouts/page.tsx +8 -0
  257. package/src/layouts/screenScroll.tsx +71 -0
  258. package/src/layouts/sidebar.tsx +83 -5
  259. package/types/appShell/AppShell.d.ts +47 -0
  260. package/types/appShell/AppShell.d.ts.map +1 -0
  261. package/types/appShell/index.d.ts +1 -0
  262. package/types/appShell/index.d.ts.map +1 -1
  263. package/types/layouts/page.d.ts +5 -0
  264. package/types/layouts/page.d.ts.map +1 -1
  265. package/types/layouts/screenScroll.d.ts +24 -0
  266. package/types/layouts/screenScroll.d.ts.map +1 -1
  267. package/types/layouts/sidebar.d.ts +31 -1
  268. package/types/layouts/sidebar.d.ts.map +1 -1
@@ -0,0 +1,112 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
2
+ import {
3
+ GearIcon,
4
+ HouseIcon,
5
+ LightningIcon,
6
+ SparkleIcon,
7
+ SquaresFourIcon,
8
+ StackIcon,
9
+ } from "@phosphor-icons/react";
10
+ import { useState } from "react";
11
+ import { XStack, YStack } from "tamagui";
12
+ import { Button } from "@multiplatform.one/forms";
13
+ import { useResolvedKnobs } from "@multiplatform.one/theme";
14
+ import { PageBarButton, PageHeader, PageSection, Screen } from "../layouts/page";
15
+ import { Pagination } from "../Pagination";
16
+ import { Text } from "../Text";
17
+ import { AppShell, type AppShellDestination } from "./AppShell";
18
+
19
+ const destinations: AppShellDestination[] = [
20
+ { key: "/", label: "Home", icon: HouseIcon },
21
+ { key: "/pokemon", label: "Pokemon", icon: SparkleIcon },
22
+ { key: "/moves", label: "Moves", icon: LightningIcon },
23
+ { key: "/backoffice", label: "Backoffice", icon: SquaresFourIcon },
24
+ { key: "/settings", label: "Settings", icon: GearIcon, placement: "more" },
25
+ ];
26
+
27
+ function Mark() {
28
+ const { knobProps } = useResolvedKnobs();
29
+ return (
30
+ <YStack
31
+ width="$2"
32
+ height="$2"
33
+ borderRadius="$3"
34
+ backgroundColor="$color4"
35
+ alignItems="center"
36
+ justifyContent="center"
37
+ >
38
+ <StackIcon size={knobProps.controlIcon.width} weight="bold" />
39
+ </YStack>
40
+ );
41
+ }
42
+
43
+ function DemoPage({ path, open }: { path: string; open: (path: string) => void }) {
44
+ const pushed = path.split("/").filter(Boolean).length > 1;
45
+ const name = destinations.find((item) => path.startsWith(item.key) && item.key !== "/");
46
+ const title = pushed ? "Pikachu" : (name?.label ?? "Home");
47
+ return (
48
+ <Screen>
49
+ <PageHeader title={title} subtitle={pushed ? "#025 · Electric" : "1,025 entries"} />
50
+ <PageSection title="Recent">
51
+ {Array.from({ length: 14 }, (_, index) => (
52
+ <XStack key={index} alignItems="center" minHeight="$4">
53
+ <Text flexGrow={1}>Row {index + 1}</Text>
54
+ {pushed ? null : (
55
+ <Button
56
+ size="$3"
57
+ chromeless
58
+ onPress={() => open(`${path === "/" ? "" : path}/pikachu`)}
59
+ >
60
+ Open
61
+ </Button>
62
+ )}
63
+ </XStack>
64
+ ))}
65
+ </PageSection>
66
+ </Screen>
67
+ );
68
+ }
69
+
70
+ function Demo({ initialPath, accessory }: { initialPath: string; accessory?: boolean }) {
71
+ const [path, setPath] = useState(initialPath);
72
+ return (
73
+ <AppShell
74
+ destinations={destinations}
75
+ path={path}
76
+ onNavigate={setPath}
77
+ onBack={setPath}
78
+ title="multiplatform.one"
79
+ logo={<Mark />}
80
+ actions={<PageBarButton aria-label="Theme" icon={GearIcon} edge="end" />}
81
+ accessory={accessory ? <Pagination total={41} defaultPage={1} variant="dots" /> : undefined}
82
+ ownChrome={["/backoffice"]}
83
+ >
84
+ <DemoPage path={path} open={setPath} />
85
+ </AppShell>
86
+ );
87
+ }
88
+
89
+ /**
90
+ * The composed app frame (LC-175). Size class follows the viewport: below
91
+ * `md` the phone frame (nav bar, content, bottom accessory, tab bar), above
92
+ * it the split frame (sidebar of destinations beside the content column).
93
+ */
94
+ const meta: Meta<typeof AppShell> = {
95
+ title: "Components/AppShell",
96
+ component: AppShell,
97
+ parameters: { layout: "fullscreen", status: { type: "beta" } },
98
+ };
99
+
100
+ export default meta;
101
+ type Story = StoryObj<typeof AppShell>;
102
+
103
+ /** A tab root: the mark leads, the large title sits in the page, tabs below. */
104
+ export const TabRoot: Story = { render: () => <Demo initialPath="/pokemon" /> };
105
+
106
+ /** A pushed route: tinted back to the destination, its tab still selected. */
107
+ export const Pushed: Story = { render: () => <Demo initialPath="/pokemon/pikachu" /> };
108
+
109
+ /** A page that hands the shell a bottom accessory: the tab bar floats it above itself. */
110
+ export const WithAccessory: Story = {
111
+ render: () => <Demo initialPath="/pokemon" accessory />,
112
+ };
@@ -0,0 +1,526 @@
1
+ /**
2
+ * AppShell — the one composed app frame every MPO app mounts at its root
3
+ * (LC-175 MPO-ONLY-REUSE). An app declares its destinations and hands over
4
+ * the current path; the shell draws the rest:
5
+ *
6
+ * - compact (phone): the nav bar on top (UINavigationBar: tinted back on a
7
+ * pushed route, the page's large title collapsing into it, trailing
8
+ * actions), the routed content, and the `tab` destinations in TabHost
9
+ * (FZ-8105): the system tab bar where the binary has one, the floating
10
+ * MPO TabBar (LC-150) elsewhere. The bar publishes the height it covers,
11
+ * which a Screen pads its content by. `more` destinations open from a
12
+ * pull-down in the bar.
13
+ * - regular (tablet, desktop): MPO's Sidebar on the translucent chrome
14
+ * material with the app's name, every destination and the app's actions
15
+ * at its foot (UISplitViewController's sidebar), the content centred at a
16
+ * readable width, and a page's panel stacked under the sidebar's rows
17
+ * (LC-101 ONE-SIDEBAR).
18
+ *
19
+ * A route listed in `ownChrome` mounts its own full chrome (a desk), so the
20
+ * shell draws nothing around it. A page whose `Screen` pins its own top bar
21
+ * (LC-157) owns the nav bar on a phone, so the shell's bar steps aside there.
22
+ */
23
+ import { zIndex } from "@multiplatform.one/forms";
24
+ import { isWeb } from "@multiplatform.one/platform";
25
+ import {
26
+ MIN_PRESS_TARGET,
27
+ Surface,
28
+ hairline,
29
+ transitionProps,
30
+ useLayoutSizeClass,
31
+ useResolvedKnobs,
32
+ } from "@multiplatform.one/theme";
33
+ import { ListIcon } from "@phosphor-icons/react";
34
+ import { createElement, useEffect, useMemo, useState, type ReactNode } from "react";
35
+ import type { LayoutChangeEvent } from "react-native";
36
+ import { SizableText, XStack, YStack } from "tamagui";
37
+ import { componentColors } from "../componentColors";
38
+ import { DropdownMenu, type DropdownMenuEntry } from "../DropdownMenu";
39
+ import { PageBackButton, PageBarButton, READABLE_MAX_WIDTH } from "../layouts/page";
40
+ import { ShellScrollReportScope, useShellScrollReports } from "../layouts/screenScroll";
41
+ import {
42
+ ShellSidebarSlotProvider,
43
+ useShellSidebarSlotState,
44
+ type ShellSidebarPanel,
45
+ } from "../layouts/shellSidebarSlot";
46
+ import { BottomAccessory } from "../layouts/bottomAccessory";
47
+ import { Sidebar } from "../layouts/sidebar";
48
+ import { useTranslation } from "../shared/i18n";
49
+ import type { TabBarIcon, TabBarItem } from "./TabBar";
50
+ import { TabHost } from "./TabHost";
51
+ import { useBottomChromeInset, useFloatingBarMounted } from "./bottomChrome";
52
+
53
+ export interface AppShellDestination {
54
+ /** The destination's route; a path under it keeps it selected. */
55
+ key: string;
56
+ /** One short word: it is the tab caption and the back label. */
57
+ label: string;
58
+ icon: TabBarIcon;
59
+ /** The SF Symbol the iOS system tab bar draws for this destination. */
60
+ nativeIcon?: TabBarItem["nativeIcon"];
61
+ /**
62
+ * `tab` (default) sits in the phone's tab bar; `more` opens from the bar's
63
+ * pull-down. Every destination sits in the regular-width sidebar.
64
+ */
65
+ placement?: "tab" | "more";
66
+ /** Runs instead of `onNavigate(key)`, e.g. an external link. */
67
+ onPress?: () => void;
68
+ }
69
+
70
+ export interface AppShellProps {
71
+ destinations: readonly AppShellDestination[];
72
+ /** The current route path. */
73
+ path: string;
74
+ onNavigate: (key: string) => void;
75
+ /** The app's name: the sidebar's title and the home control's label. */
76
+ title: string;
77
+ /** The app's mark, leading the bar on a phone and the name in the sidebar. */
78
+ logo?: ReactNode;
79
+ /** Pressing the name or mark. Default: the first destination. */
80
+ onTitlePress?: () => void;
81
+ /** Trailing bar actions (theme, account). */
82
+ actions?: ReactNode;
83
+ /** Shows a tinted back on a route pushed below its destination (its key). */
84
+ onBack?: (destinationKey: string) => void;
85
+ /** The phone's bottom accessory (LC-174), handed to the tab bar or pinned to the bottom edge. */
86
+ accessory?: ReactNode;
87
+ /** Route prefixes that mount their own chrome; the shell draws none there. */
88
+ ownChrome?: readonly string[];
89
+ children?: ReactNode;
90
+ testID?: string;
91
+ }
92
+
93
+ function underKey(path: string, key: string): boolean {
94
+ if (key === "/") return path === "/";
95
+ return path === key || path.startsWith(`${key}/`);
96
+ }
97
+
98
+ /** The destination that owns `path` (the longest key it sits under). */
99
+ export function appShellRoute(
100
+ destinations: readonly AppShellDestination[],
101
+ path: string,
102
+ ): { destination: AppShellDestination | undefined; pushed: boolean } {
103
+ const normal = path.length > 1 ? path.replace(/\/+$/, "") : path;
104
+ let destination: AppShellDestination | undefined;
105
+ for (const candidate of destinations) {
106
+ if (candidate.onPress || !underKey(normal, candidate.key)) continue;
107
+ if (!destination || candidate.key.length > destination.key.length) destination = candidate;
108
+ }
109
+ return { destination, pushed: Boolean(destination && normal !== destination.key) };
110
+ }
111
+
112
+ /** Web: the document scrolls under the sticky bar; native reports via Screen. */
113
+ function useDocumentScrolled(enabled: boolean): boolean {
114
+ const [scrolled, setScrolled] = useState(false);
115
+ useEffect(() => {
116
+ if (!enabled || typeof document === "undefined") return;
117
+ const sync = () => setScrolled(window.scrollY > 1);
118
+ sync();
119
+ window.addEventListener("scroll", sync, { passive: true });
120
+ return () => window.removeEventListener("scroll", sync);
121
+ }, [enabled]);
122
+ return scrolled;
123
+ }
124
+
125
+ const sticky = (edge: "top" | "bottom") =>
126
+ isWeb ? { position: "sticky" as const, [edge]: 0, zIndex: zIndex.stickyHeader } : null;
127
+
128
+ function HomeControl({
129
+ title,
130
+ onPress,
131
+ children,
132
+ }: {
133
+ title: string;
134
+ onPress: () => void;
135
+ children: ReactNode;
136
+ }) {
137
+ return (
138
+ <XStack
139
+ role="link"
140
+ aria-label={title}
141
+ alignItems="center"
142
+ minHeight={MIN_PRESS_TARGET}
143
+ flexShrink={1}
144
+ minWidth={0}
145
+ gap="$2"
146
+ cursor="pointer"
147
+ onPress={onPress}
148
+ data-testid="app-shell-home"
149
+ >
150
+ {children}
151
+ </XStack>
152
+ );
153
+ }
154
+
155
+ interface NavBarProps {
156
+ /** Centres the bar's row in the content column's readable width. */
157
+ maxWidth?: number;
158
+ leading: ReactNode;
159
+ trailing: ReactNode;
160
+ title: string | null;
161
+ showTitle: boolean;
162
+ scrolled: boolean;
163
+ }
164
+
165
+ /**
166
+ * The shell's nav bar: one 44pt row on the page fill. Its hairline appears
167
+ * once content scrolls under it (scroll-edge appearance) and the inline
168
+ * title fades in once the page's large title has scrolled away, centred
169
+ * clear of the wider side group, both on the control motion role (LC-68).
170
+ */
171
+ function AppShellNavBar({ maxWidth, leading, trailing, title, showTitle, scrolled }: NavBarProps) {
172
+ const { knobProps } = useResolvedKnobs({ component: "ScreenToolbar" });
173
+ const [sides, setSides] = useState({ leading: 0, trailing: 0 });
174
+ const measure = (side: "leading" | "trailing") => (event: LayoutChangeEvent) => {
175
+ const width = Math.ceil(event.nativeEvent.layout.width);
176
+ setSides((prev) => (prev[side] === width ? prev : { ...prev, [side]: width }));
177
+ };
178
+ const motion = transitionProps(knobProps.motion.control);
179
+ const clearance = Math.max(sides.leading, sides.trailing);
180
+ return (
181
+ <Surface bar>
182
+ <YStack
183
+ role="banner"
184
+ data-testid="app-shell-bar"
185
+ data-scroll-edge={scrolled ? "" : undefined}
186
+ backgroundColor="$background"
187
+ flexShrink={0}
188
+ {...hairline.bottom}
189
+ borderColor={(scrolled ? componentColors.divider : "transparent") as never}
190
+ {...motion}
191
+ {...sticky("top")}
192
+ >
193
+ <XStack
194
+ position="relative"
195
+ width="100%"
196
+ {...(maxWidth ? { maxWidth, alignSelf: "center" as const } : null)}
197
+ minHeight={MIN_PRESS_TARGET}
198
+ alignItems="center"
199
+ flexWrap="nowrap"
200
+ paddingHorizontal={knobProps.panelPadding.padding as "$4"}
201
+ {...knobProps.gap}
202
+ >
203
+ <XStack
204
+ alignItems="center"
205
+ flexShrink={1000}
206
+ minWidth={0}
207
+ {...knobProps.gap}
208
+ onLayout={measure("leading")}
209
+ >
210
+ {leading}
211
+ </XStack>
212
+ <XStack flexGrow={1} />
213
+ <XStack
214
+ alignItems="center"
215
+ flexShrink={1}
216
+ minWidth={0}
217
+ justifyContent="flex-end"
218
+ {...knobProps.gap}
219
+ onLayout={measure("trailing")}
220
+ >
221
+ {trailing}
222
+ </XStack>
223
+ {title ? (
224
+ <XStack
225
+ position="absolute"
226
+ top={0}
227
+ bottom={0}
228
+ left={clearance}
229
+ right={clearance}
230
+ alignItems="center"
231
+ justifyContent="center"
232
+ pointerEvents="none"
233
+ aria-hidden={!showTitle}
234
+ data-testid="app-shell-bar-title"
235
+ opacity={showTitle ? 1 : 0}
236
+ {...motion}
237
+ >
238
+ <SizableText {...knobProps.body} fontWeight="600" color="$color12" numberOfLines={1}>
239
+ {title}
240
+ </SizableText>
241
+ </XStack>
242
+ ) : null}
243
+ </XStack>
244
+ </YStack>
245
+ </Surface>
246
+ );
247
+ }
248
+
249
+ function StackedPanel({ panel }: { panel: ShellSidebarPanel }) {
250
+ const { knobProps } = useResolvedKnobs();
251
+ return (
252
+ <YStack
253
+ paddingHorizontal={knobProps.panelPadding.padding as "$4"}
254
+ {...knobProps.gap}
255
+ data-testid="app-shell-page-panel"
256
+ >
257
+ <SizableText
258
+ {...knobProps.body}
259
+ size="$2"
260
+ textTransform="uppercase"
261
+ color={knobProps.textAccentColor}
262
+ numberOfLines={1}
263
+ >
264
+ {panel.title}
265
+ </SizableText>
266
+ {panel.content}
267
+ </YStack>
268
+ );
269
+ }
270
+
271
+ export function AppShell({
272
+ destinations,
273
+ path,
274
+ onNavigate,
275
+ title,
276
+ logo,
277
+ onTitlePress,
278
+ actions,
279
+ onBack,
280
+ accessory,
281
+ ownChrome = [],
282
+ children,
283
+ testID = "app-shell",
284
+ }: AppShellProps) {
285
+ const { t } = useTranslation();
286
+ const { knobProps } = useResolvedKnobs();
287
+ const compact = useLayoutSizeClass() === "compact";
288
+ const { report, reporter } = useShellScrollReports();
289
+ const sidebarSlot = useShellSidebarSlotState();
290
+ const documentScrolled = useDocumentScrolled(isWeb);
291
+ const chromeInset = useBottomChromeInset();
292
+ const floatingBar = useFloatingBarMounted();
293
+
294
+ const { destination, pushed } = appShellRoute(destinations, path);
295
+ const ownsChrome = ownChrome.some((prefix) => underKey(path, prefix));
296
+ const go = (item: AppShellDestination) => (item.onPress ? item.onPress() : onNavigate(item.key));
297
+ const goHome = onTitlePress ?? (() => destinations[0] && go(destinations[0]));
298
+
299
+ const tabs = useMemo(
300
+ () => destinations.filter((item) => (item.placement ?? "tab") === "tab"),
301
+ [destinations],
302
+ );
303
+ const more = useMemo(
304
+ () => destinations.filter((item) => item.placement === "more"),
305
+ [destinations],
306
+ );
307
+
308
+ if (ownsChrome) {
309
+ return (
310
+ <YStack
311
+ flex={1}
312
+ minHeight={0}
313
+ backgroundColor="$background"
314
+ {...(isWeb ? { height: "100dvh" as never } : null)}
315
+ data-testid={testID}
316
+ data-chrome="own"
317
+ >
318
+ {children}
319
+ </YStack>
320
+ );
321
+ }
322
+
323
+ const pageBar = Boolean(report?.ownTopBar);
324
+ const scrolled = isWeb ? documentScrolled : Boolean(report?.scrolled);
325
+ const largeTitle = report?.title ?? null;
326
+ const barTitle = largeTitle ?? destination?.label ?? null;
327
+ const showTitle = largeTitle ? Boolean(report?.collapsed) : Boolean(barTitle);
328
+ const back =
329
+ pushed && onBack && destination && !pageBar ? (
330
+ <PageBackButton label={destination.label} onPress={() => onBack(destination.key)} />
331
+ ) : null;
332
+ const showBar = compact ? !pageBar : Boolean(back);
333
+
334
+ const moreMenu =
335
+ compact && more.length > 0 ? (
336
+ <DropdownMenu
337
+ placement="bottom-end"
338
+ items={more.map<DropdownMenuEntry>((item) => ({
339
+ key: item.key,
340
+ label: item.label,
341
+ icon: createElement(item.icon),
342
+ onSelect: () => go(item),
343
+ }))}
344
+ >
345
+ <PageBarButton
346
+ edge="end"
347
+ icon={ListIcon}
348
+ aria-label={t("More")}
349
+ data-testid="app-shell-more"
350
+ />
351
+ </DropdownMenu>
352
+ ) : null;
353
+
354
+ const bar = showBar ? (
355
+ <AppShellNavBar
356
+ leading={
357
+ back ??
358
+ (compact && logo ? (
359
+ <HomeControl title={title} onPress={goHome}>
360
+ {logo}
361
+ </HomeControl>
362
+ ) : null)
363
+ }
364
+ trailing={
365
+ compact ? (
366
+ <>
367
+ {actions}
368
+ {moreMenu}
369
+ </>
370
+ ) : null
371
+ }
372
+ maxWidth={compact ? undefined : READABLE_MAX_WIDTH}
373
+ title={barTitle}
374
+ showTitle={showTitle}
375
+ scrolled={scrolled}
376
+ />
377
+ ) : null;
378
+
379
+ const content = (
380
+ <YStack
381
+ role="main"
382
+ flex={1}
383
+ minWidth={0}
384
+ {...(isWeb ? null : { minHeight: 0 })}
385
+ data-testid="app-shell-content"
386
+ >
387
+ {compact ? (
388
+ children
389
+ ) : (
390
+ <YStack
391
+ flex={1}
392
+ width="100%"
393
+ maxWidth={READABLE_MAX_WIDTH}
394
+ alignSelf="center"
395
+ {...(isWeb ? null : { minHeight: 0 })}
396
+ data-testid="app-shell-readable"
397
+ >
398
+ {children}
399
+ </YStack>
400
+ )}
401
+ </YStack>
402
+ );
403
+
404
+ const showTabs = compact && Boolean(destination && tabs.includes(destination));
405
+ const tabItems: TabBarItem[] = tabs.map(({ key, label, icon, nativeIcon }) => ({
406
+ key,
407
+ label,
408
+ icon,
409
+ nativeIcon,
410
+ }));
411
+ // The floating TabBar takes the accessory into its own capsule. Otherwise
412
+ // it renders in place, and on native it clears the system tab bar, which
413
+ // the content runs under.
414
+ const pageAccessory = accessory ? (
415
+ <YStack flexShrink={0} {...(isWeb || floatingBar ? null : { paddingBottom: chromeInset })}>
416
+ <BottomAccessory>{accessory}</BottomAccessory>
417
+ </YStack>
418
+ ) : null;
419
+
420
+ const frame = compact ? (
421
+ <YStack
422
+ flex={1}
423
+ backgroundColor="$background"
424
+ {...(isWeb ? { minHeight: "100dvh" as never } : null)}
425
+ data-testid={testID}
426
+ data-size="compact"
427
+ >
428
+ {bar}
429
+ {tabItems.length > 0 ? (
430
+ <TabHost
431
+ items={tabItems}
432
+ activeKey={showTabs ? destination?.key : undefined}
433
+ onTabPress={(key) => {
434
+ const item = tabs.find((tab) => tab.key === key);
435
+ if (item) go(item);
436
+ }}
437
+ >
438
+ {content}
439
+ {pageAccessory}
440
+ </TabHost>
441
+ ) : (
442
+ <>
443
+ {content}
444
+ {pageAccessory}
445
+ </>
446
+ )}
447
+ </YStack>
448
+ ) : (
449
+ <XStack
450
+ flex={1}
451
+ backgroundColor="$background"
452
+ {...(isWeb ? { minHeight: "100dvh" as never } : null)}
453
+ data-testid={testID}
454
+ data-size="regular"
455
+ >
456
+ <YStack
457
+ flexShrink={0}
458
+ alignSelf="stretch"
459
+ {...(isWeb ? { position: "sticky" as const, top: 0, height: "100dvh" as never } : null)}
460
+ data-testid="app-shell-sidebar"
461
+ >
462
+ <Sidebar
463
+ material
464
+ aria-label={title}
465
+ header={
466
+ <XStack
467
+ paddingHorizontal={knobProps.panelPadding.padding as "$4"}
468
+ alignItems="center"
469
+ minHeight={MIN_PRESS_TARGET}
470
+ >
471
+ <HomeControl title={title} onPress={goHome}>
472
+ {logo}
473
+ <SizableText
474
+ {...knobProps.heading}
475
+ fontWeight="700"
476
+ color="$color12"
477
+ numberOfLines={1}
478
+ >
479
+ {title}
480
+ </SizableText>
481
+ </HomeControl>
482
+ </XStack>
483
+ }
484
+ footer={
485
+ actions ? (
486
+ <XStack
487
+ paddingHorizontal={knobProps.panelPadding.padding as "$4"}
488
+ alignItems="center"
489
+ minHeight={MIN_PRESS_TARGET}
490
+ flexWrap="wrap"
491
+ {...knobProps.gap}
492
+ data-testid="app-shell-sidebar-actions"
493
+ >
494
+ {actions}
495
+ </XStack>
496
+ ) : undefined
497
+ }
498
+ sections={[
499
+ {
500
+ items: destinations.map((item) => ({
501
+ label: item.label,
502
+ value: item.key,
503
+ icon: createElement(item.icon),
504
+ active: item === destination,
505
+ onPress: () => go(item),
506
+ })),
507
+ },
508
+ ]}
509
+ >
510
+ {sidebarSlot.panel ? <StackedPanel panel={sidebarSlot.panel} /> : null}
511
+ </Sidebar>
512
+ </YStack>
513
+ <YStack flex={1} minWidth={0}>
514
+ {bar}
515
+ {content}
516
+ {pageAccessory}
517
+ </YStack>
518
+ </XStack>
519
+ );
520
+
521
+ return (
522
+ <ShellSidebarSlotProvider value={compact ? null : sidebarSlot.value}>
523
+ <ShellScrollReportScope reporter={reporter}>{frame}</ShellScrollReportScope>
524
+ </ShellSidebarSlotProvider>
525
+ );
526
+ }
@@ -6,4 +6,5 @@ export { TabHost, type TabHostProps } from "./TabHost";
6
6
  export * from "./Springboard";
7
7
  export type { AppEntry } from "./appTile";
8
8
  export * from "./SettingsSplit";
9
+ export * from "./AppShell";
9
10
  export { useBottomChromeInset } from "./bottomChrome";
@@ -120,3 +120,34 @@ describe("Sidebar footer", () => {
120
120
  expect(result.getByTestId("rail").querySelector("[data-sidebar-footer]")).toBeNull();
121
121
  });
122
122
  });
123
+
124
+ describe("Sidebar on the shell material (FZ-7996)", () => {
125
+ const sections = [
126
+ {
127
+ items: [
128
+ { value: "home", label: "Home", active: true, onPress: () => {} },
129
+ { value: "pokemon", label: "Pokemon", onPress: () => {} },
130
+ { value: "moves", label: "Moves", onPress: () => {} },
131
+ ],
132
+ },
133
+ ];
134
+
135
+ it("sets every row as a rounded pill with no rule between rows", () => {
136
+ const result = renderWithProviders(<Sidebar material sections={sections} />);
137
+ const rows = [...result.container.querySelectorAll("[data-sidebar-row]")];
138
+ expect(rows).toHaveLength(3);
139
+ for (const row of rows) {
140
+ expect(row.className).toMatch(/_btlr-/);
141
+ expect(row.className).toMatch(/_bbrr-/);
142
+ }
143
+ expect(rows[1]!.previousElementSibling).toBe(rows[0]);
144
+ expect(rows[2]!.previousElementSibling).toBe(rows[1]);
145
+ });
146
+
147
+ it("keeps the stacked group with rules between rows off the material", () => {
148
+ const result = renderWithProviders(<Sidebar sections={sections} />);
149
+ const rows = [...result.container.querySelectorAll("[data-sidebar-row]")];
150
+ expect(rows[1]!.previousElementSibling).not.toBe(rows[0]);
151
+ expect(rows[1]!.previousElementSibling?.hasAttribute("data-sidebar-row")).toBe(false);
152
+ });
153
+ });