@multiplatform.one/components 7.80.0 → 7.81.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 (179) hide show
  1. package/dist/cjs/CheckGlyph/MenuCheckColumn.cjs +56 -0
  2. package/dist/cjs/CheckGlyph/MenuCheckColumn.native.js +60 -0
  3. package/dist/cjs/CheckGlyph/MenuCheckColumn.native.js.map +1 -0
  4. package/dist/cjs/CheckGlyph/index.cjs +40 -0
  5. package/dist/cjs/CheckGlyph/index.native.js +44 -0
  6. package/dist/cjs/CheckGlyph/index.native.js.map +1 -0
  7. package/dist/cjs/ContextMenu/index.cjs +5 -22
  8. package/dist/cjs/ContextMenu/index.native.js +7 -25
  9. package/dist/cjs/ContextMenu/index.native.js.map +1 -1
  10. package/dist/cjs/DropdownMenu/index.cjs +5 -17
  11. package/dist/cjs/DropdownMenu/index.native.js +7 -19
  12. package/dist/cjs/DropdownMenu/index.native.js.map +1 -1
  13. package/dist/cjs/Popover.cjs +2 -2
  14. package/dist/cjs/Popover.native.js +2 -2
  15. package/dist/cjs/Popover.native.js.map +1 -1
  16. package/dist/cjs/SchemeToggle/index.cjs +2 -2
  17. package/dist/cjs/SchemeToggle/index.native.js +2 -2
  18. package/dist/cjs/SchemeToggle/index.native.js.map +1 -1
  19. package/dist/cjs/SheetModal/index.cjs +4 -1
  20. package/dist/cjs/SheetModal/index.native.js +1 -1
  21. package/dist/cjs/SheetModal/index.native.js.map +1 -1
  22. package/dist/cjs/TreeSelect/index.cjs +2 -6
  23. package/dist/cjs/TreeSelect/index.native.js +2 -6
  24. package/dist/cjs/TreeSelect/index.native.js.map +1 -1
  25. package/dist/cjs/appShell/AppShell.cjs +23 -17
  26. package/dist/cjs/appShell/AppShell.native.js +28 -22
  27. package/dist/cjs/appShell/AppShell.native.js.map +1 -1
  28. package/dist/cjs/appShell/ScopeMenu.cjs +39 -16
  29. package/dist/cjs/appShell/ScopeMenu.native.js +43 -17
  30. package/dist/cjs/appShell/ScopeMenu.native.js.map +1 -1
  31. package/dist/cjs/appShell/TabHost.native.js +41 -10
  32. package/dist/cjs/appShell/TabHost.native.js.map +1 -1
  33. package/dist/cjs/layouts/sidebar.cjs +29 -8
  34. package/dist/cjs/layouts/sidebar.native.js +30 -8
  35. package/dist/cjs/layouts/sidebar.native.js.map +1 -1
  36. package/dist/esm/CheckGlyph/MenuCheckColumn.mjs +31 -0
  37. package/dist/esm/CheckGlyph/MenuCheckColumn.mjs.map +1 -0
  38. package/dist/esm/CheckGlyph/MenuCheckColumn.native.js +32 -0
  39. package/dist/esm/CheckGlyph/MenuCheckColumn.native.js.map +1 -0
  40. package/dist/esm/CheckGlyph/index.mjs +15 -0
  41. package/dist/esm/CheckGlyph/index.mjs.map +1 -0
  42. package/dist/esm/CheckGlyph/index.native.js +16 -0
  43. package/dist/esm/CheckGlyph/index.native.js.map +1 -0
  44. package/dist/esm/ContextMenu/index.mjs +6 -23
  45. package/dist/esm/ContextMenu/index.mjs.map +1 -1
  46. package/dist/esm/ContextMenu/index.native.js +8 -26
  47. package/dist/esm/ContextMenu/index.native.js.map +1 -1
  48. package/dist/esm/DropdownMenu/index.mjs +6 -18
  49. package/dist/esm/DropdownMenu/index.mjs.map +1 -1
  50. package/dist/esm/DropdownMenu/index.native.js +8 -20
  51. package/dist/esm/DropdownMenu/index.native.js.map +1 -1
  52. package/dist/esm/Popover.mjs +2 -2
  53. package/dist/esm/Popover.mjs.map +1 -1
  54. package/dist/esm/Popover.native.js +2 -2
  55. package/dist/esm/Popover.native.js.map +1 -1
  56. package/dist/esm/SchemeToggle/index.mjs +3 -3
  57. package/dist/esm/SchemeToggle/index.mjs.map +1 -1
  58. package/dist/esm/SchemeToggle/index.native.js +3 -3
  59. package/dist/esm/SchemeToggle/index.native.js.map +1 -1
  60. package/dist/esm/SheetModal/index.mjs +5 -2
  61. package/dist/esm/SheetModal/index.mjs.map +1 -1
  62. package/dist/esm/SheetModal/index.native.js +2 -2
  63. package/dist/esm/SheetModal/index.native.js.map +1 -1
  64. package/dist/esm/TreeSelect/index.mjs +4 -8
  65. package/dist/esm/TreeSelect/index.mjs.map +1 -1
  66. package/dist/esm/TreeSelect/index.native.js +4 -8
  67. package/dist/esm/TreeSelect/index.native.js.map +1 -1
  68. package/dist/esm/appShell/AppShell.mjs +23 -17
  69. package/dist/esm/appShell/AppShell.mjs.map +1 -1
  70. package/dist/esm/appShell/AppShell.native.js +28 -22
  71. package/dist/esm/appShell/AppShell.native.js.map +1 -1
  72. package/dist/esm/appShell/ScopeMenu.mjs +43 -20
  73. package/dist/esm/appShell/ScopeMenu.mjs.map +1 -1
  74. package/dist/esm/appShell/ScopeMenu.native.js +47 -21
  75. package/dist/esm/appShell/ScopeMenu.native.js.map +1 -1
  76. package/dist/esm/appShell/TabBar.mjs.map +1 -1
  77. package/dist/esm/appShell/TabHost.native.js +41 -12
  78. package/dist/esm/appShell/TabHost.native.js.map +1 -1
  79. package/dist/esm/layouts/sidebar.mjs +31 -10
  80. package/dist/esm/layouts/sidebar.mjs.map +1 -1
  81. package/dist/esm/layouts/sidebar.native.js +32 -10
  82. package/dist/esm/layouts/sidebar.native.js.map +1 -1
  83. package/dist/jsx/CheckGlyph/MenuCheckColumn.mjs +31 -0
  84. package/dist/jsx/CheckGlyph/MenuCheckColumn.mjs.map +1 -0
  85. package/dist/jsx/CheckGlyph/MenuCheckColumn.native.js +60 -0
  86. package/dist/jsx/CheckGlyph/MenuCheckColumn.native.js.map +1 -0
  87. package/dist/jsx/CheckGlyph/index.mjs +15 -0
  88. package/dist/jsx/CheckGlyph/index.mjs.map +1 -0
  89. package/dist/jsx/CheckGlyph/index.native.js +44 -0
  90. package/dist/jsx/CheckGlyph/index.native.js.map +1 -0
  91. package/dist/jsx/ContextMenu/index.mjs +6 -23
  92. package/dist/jsx/ContextMenu/index.mjs.map +1 -1
  93. package/dist/jsx/ContextMenu/index.native.js +7 -25
  94. package/dist/jsx/ContextMenu/index.native.js.map +1 -1
  95. package/dist/jsx/DropdownMenu/index.mjs +6 -18
  96. package/dist/jsx/DropdownMenu/index.mjs.map +1 -1
  97. package/dist/jsx/DropdownMenu/index.native.js +7 -19
  98. package/dist/jsx/DropdownMenu/index.native.js.map +1 -1
  99. package/dist/jsx/Popover.mjs +2 -2
  100. package/dist/jsx/Popover.mjs.map +1 -1
  101. package/dist/jsx/Popover.native.js +2 -2
  102. package/dist/jsx/Popover.native.js.map +1 -1
  103. package/dist/jsx/SchemeToggle/index.mjs +3 -3
  104. package/dist/jsx/SchemeToggle/index.mjs.map +1 -1
  105. package/dist/jsx/SchemeToggle/index.native.js +2 -2
  106. package/dist/jsx/SchemeToggle/index.native.js.map +1 -1
  107. package/dist/jsx/SheetModal/index.mjs +5 -2
  108. package/dist/jsx/SheetModal/index.mjs.map +1 -1
  109. package/dist/jsx/SheetModal/index.native.js +1 -1
  110. package/dist/jsx/SheetModal/index.native.js.map +1 -1
  111. package/dist/jsx/TreeSelect/index.mjs +4 -8
  112. package/dist/jsx/TreeSelect/index.mjs.map +1 -1
  113. package/dist/jsx/TreeSelect/index.native.js +2 -6
  114. package/dist/jsx/TreeSelect/index.native.js.map +1 -1
  115. package/dist/jsx/appShell/AppShell.mjs +23 -17
  116. package/dist/jsx/appShell/AppShell.mjs.map +1 -1
  117. package/dist/jsx/appShell/AppShell.native.js +28 -22
  118. package/dist/jsx/appShell/AppShell.native.js.map +1 -1
  119. package/dist/jsx/appShell/ScopeMenu.mjs +43 -20
  120. package/dist/jsx/appShell/ScopeMenu.mjs.map +1 -1
  121. package/dist/jsx/appShell/ScopeMenu.native.js +43 -17
  122. package/dist/jsx/appShell/ScopeMenu.native.js.map +1 -1
  123. package/dist/jsx/appShell/TabBar.mjs.map +1 -1
  124. package/dist/jsx/appShell/TabBar.native.js.map +1 -1
  125. package/dist/jsx/appShell/TabHost.native.js +41 -10
  126. package/dist/jsx/appShell/TabHost.native.js.map +1 -1
  127. package/dist/jsx/layouts/sidebar.mjs +31 -10
  128. package/dist/jsx/layouts/sidebar.mjs.map +1 -1
  129. package/dist/jsx/layouts/sidebar.native.js +30 -8
  130. package/dist/jsx/layouts/sidebar.native.js.map +1 -1
  131. package/package.json +9 -9
  132. package/src/CheckGlyph/CheckGlyph.spec.tsx +46 -0
  133. package/src/CheckGlyph/MenuCheckColumn.tsx +32 -0
  134. package/src/CheckGlyph/index.tsx +12 -0
  135. package/src/ContextMenu/ContextMenu.spec.tsx +46 -1
  136. package/src/ContextMenu/index.tsx +5 -22
  137. package/src/DropdownMenu/DropdownMenu.avatarTrigger.spec.tsx +6 -9
  138. package/src/DropdownMenu/index.tsx +5 -18
  139. package/src/Popover.coverTrigger.spec.tsx +3 -3
  140. package/src/Popover.tsx +5 -5
  141. package/src/SchemeToggle/SchemeToggle.spec.tsx +5 -11
  142. package/src/SchemeToggle/SchemeToggle.stories.tsx +21 -0
  143. package/src/SchemeToggle/index.tsx +2 -1
  144. package/src/SheetModal/index.native.tsx +2 -1
  145. package/src/SheetModal/index.tsx +5 -2
  146. package/src/TreeSelect/index.tsx +2 -4
  147. package/src/appShell/AppShell.spec.tsx +30 -0
  148. package/src/appShell/AppShell.tsx +22 -13
  149. package/src/appShell/ScopeMenu.spec.tsx +22 -0
  150. package/src/appShell/ScopeMenu.tsx +57 -18
  151. package/src/appShell/TabBar.tsx +13 -3
  152. package/src/appShell/TabHost.native.spec.tsx +53 -6
  153. package/src/appShell/TabHost.native.tsx +50 -14
  154. package/src/appShell/TabHost.stories.tsx +72 -0
  155. package/src/layouts/PageHeader.stories.tsx +3 -3
  156. package/src/layouts/Sidebar.spec.tsx +61 -1
  157. package/src/layouts/pageActionButton.spec.tsx +7 -3
  158. package/src/layouts/sidebar.tsx +22 -8
  159. package/types/CheckGlyph/MenuCheckColumn.d.ts +11 -0
  160. package/types/CheckGlyph/MenuCheckColumn.d.ts.map +1 -0
  161. package/types/CheckGlyph/index.d.ts +10 -0
  162. package/types/CheckGlyph/index.d.ts.map +1 -0
  163. package/types/ContextMenu/index.d.ts.map +1 -1
  164. package/types/DropdownMenu/index.d.ts.map +1 -1
  165. package/types/Popover.d.ts +1 -1
  166. package/types/Popover.d.ts.map +1 -1
  167. package/types/SchemeToggle/index.d.ts.map +1 -1
  168. package/types/SheetModal/index.d.ts.map +1 -1
  169. package/types/SheetModal/index.native.d.ts.map +1 -1
  170. package/types/TreeSelect/index.d.ts.map +1 -1
  171. package/types/appShell/AppShell.d.ts.map +1 -1
  172. package/types/appShell/ScopeMenu.d.ts +0 -18
  173. package/types/appShell/ScopeMenu.d.ts.map +1 -1
  174. package/types/appShell/TabBar.d.ts +12 -6
  175. package/types/appShell/TabBar.d.ts.map +1 -1
  176. package/types/appShell/TabHost.native.d.ts +23 -5
  177. package/types/appShell/TabHost.native.d.ts.map +1 -1
  178. package/types/layouts/sidebar.d.ts +7 -1
  179. package/types/layouts/sidebar.d.ts.map +1 -1
@@ -1,4 +1,4 @@
1
- import { CircleHalfIcon, MoonIcon, SunIcon, XIcon } from "@phosphor-icons/react";
1
+ import { CircleHalfIcon, MoonIcon, SunIcon } from "@phosphor-icons/react";
2
2
  import { fireEvent, render, waitFor } from "@testing-library/react";
3
3
  import { describe, expect, it, vi } from "vitest";
4
4
  import { renderWithProviders } from "@multiplatform.one/test-utils";
@@ -43,7 +43,7 @@ describe("SchemeToggle (FZ-8158)", () => {
43
43
  expect(set).toHaveBeenCalledWith("system");
44
44
  });
45
45
 
46
- it("opens its menu on the button and draws the button again in place as an X, a press there closing it (FZ-8480)", async () => {
46
+ it("opens its menu on the button with no X, a press outside closing it (FZ-8665)", async () => {
47
47
  const { useUserScheme } = preference("dark", "dark");
48
48
  const { getByRole } = renderWithProviders(<SchemeToggle useUserScheme={useUserScheme} />);
49
49
  const live = getByRole("button", { name: "Appearance: Dark" });
@@ -53,15 +53,9 @@ describe("SchemeToggle (FZ-8158)", () => {
53
53
  expect(found?.querySelector('[role="menu"]')).toBeTruthy();
54
54
  return found as HTMLElement;
55
55
  });
56
- const echo = panel.querySelector<HTMLElement>('[data-testid="floating-panel-icon-echo"]');
57
- expect(echo?.getAttribute("aria-label")).toBe("Close");
58
- const pathOf = (node: ParentNode | null | undefined) =>
59
- node?.querySelector("svg path")?.getAttribute("d");
60
- const x = render(<XIcon weight="bold" />);
61
- expect(pathOf(echo)).toBe(pathOf(x.container));
62
- expect(pathOf(echo)).not.toBe(pathOf(live));
63
- x.unmount();
64
- fireEvent.click(echo as HTMLElement);
56
+ expect(panel.querySelector('[aria-label="Close"]')).toBeNull();
57
+ expect(panel.querySelector(`[aria-label="${live.getAttribute("aria-label")}"]`)).toBeNull();
58
+ fireEvent.click(document.body);
65
59
  await waitFor(() => expect(document.querySelector('[role="menu"]')).toBeFalsy());
66
60
  });
67
61
 
@@ -0,0 +1,21 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
2
+ import { XStack } from "tamagui";
3
+ import { SchemeToggle } from "./index";
4
+
5
+ const meta: Meta<typeof SchemeToggle> = {
6
+ title: "Components/SchemeToggle",
7
+ component: SchemeToggle,
8
+ parameters: { status: { type: "beta" } },
9
+ };
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof SchemeToggle>;
13
+
14
+ /** A tap flips light and dark; a right-click (long-press on native) opens System, Light and Dark. */
15
+ export const Default: Story = {
16
+ render: () => (
17
+ <XStack padding="$4" justifyContent="flex-end">
18
+ <SchemeToggle />
19
+ </XStack>
20
+ ),
21
+ };
@@ -1,6 +1,7 @@
1
1
  import { CircleHalfIcon, MoonIcon, SunIcon } from "@phosphor-icons/react";
2
2
  import { IconButton, type IconButtonProps } from "@multiplatform.one/forms";
3
3
  import {
4
+ menuRowIconGlyph,
4
5
  useSchemePreference,
5
6
  type SchemePreference,
6
7
  type ThemeScheme,
@@ -60,7 +61,7 @@ export function SchemeToggle({
60
61
  const Glyph = schemeGlyphs[value];
61
62
  return {
62
63
  label: name(value),
63
- icon: <Glyph size={18} weight="bold" />,
64
+ icon: <Glyph size={menuRowIconGlyph.size} weight={menuRowIconGlyph.weight} />,
64
65
  checked: scheme.setting === value,
65
66
  onSelect: () => scheme.set(value),
66
67
  };
@@ -28,6 +28,7 @@ import {
28
28
  useResolvedKnobs,
29
29
  type MotionStop,
30
30
  nativeAnimationConfig as animationConfig,
31
+ overlayScrim,
31
32
  } from "@multiplatform.one/theme";
32
33
  import {
33
34
  useCallback,
@@ -424,7 +425,7 @@ export function SheetModal({
424
425
  accessibilityRole="button"
425
426
  accessibilityLabel={t("Close")}
426
427
  >
427
- <View flex={1} backgroundColor="$shadow6" />
428
+ <View flex={1} backgroundColor={overlayScrim} />
428
429
  </Pressable>
429
430
  <Animated.View
430
431
  onLayout={(e) => {
@@ -9,7 +9,7 @@
9
9
  * on device). Dismissal: overlay tap / snap-to-bottom / Escape (LC-21).
10
10
  */
11
11
 
12
- import { transitionProps, useResolvedKnobs } from "@multiplatform.one/theme";
12
+ import { overlayScrim, transitionProps, useResolvedKnobs } from "@multiplatform.one/theme";
13
13
  import { OverlayStackProvider, useOverlayZIndex } from "@multiplatform.one/forms";
14
14
  import { useEffect, useId, useRef, type ReactNode } from "react";
15
15
  import { isWeb, Sheet, View, useWindowDimensions } from "tamagui";
@@ -128,7 +128,7 @@ export function SheetModal({
128
128
  portalProps={{ zIndex: sheetZIndex }}
129
129
  >
130
130
  <Sheet.Overlay
131
- backgroundColor="$shadow6"
131
+ backgroundColor={overlayScrim}
132
132
  {...transitionProps(knobProps.motion.scrim)}
133
133
  enterStyle={{ opacity: 0 }}
134
134
  exitStyle={{ opacity: 0 }}
@@ -160,6 +160,9 @@ export function SheetModal({
160
160
  : null)}
161
161
  borderBottomLeftRadius={0}
162
162
  borderBottomRightRadius={0}
163
+ borderLeftWidth={0}
164
+ borderRightWidth={0}
165
+ borderBottomWidth={0}
163
166
  overflow="hidden"
164
167
  {...frameRingReset}
165
168
  >
@@ -11,11 +11,11 @@ import {
11
11
  panelViewportPadding,
12
12
  useOverlayZIndex,
13
13
  } from "@multiplatform.one/forms";
14
+ import { CheckGlyph } from "../CheckGlyph";
14
15
  import {
15
16
  CaretDownIcon,
16
17
  CaretLeftIcon,
17
18
  CaretRightIcon,
18
- CheckIcon,
19
19
  FileIcon,
20
20
  FolderIcon,
21
21
  FolderOpenIcon,
@@ -27,7 +27,6 @@ import {
27
27
  ensureKeyboardModalityTracking,
28
28
  keyboardFocusRingProps,
29
29
  listHeightForRows,
30
- useMenuRowCheckInk,
31
30
  menuRowChecked,
32
31
  useResolvedKnobs,
33
32
  } from "@multiplatform.one/theme";
@@ -110,7 +109,6 @@ function TreeSelectRows({
110
109
  }) {
111
110
  const { t } = useTranslation();
112
111
  const { knobProps } = useResolvedKnobs();
113
- const checkInk = useMenuRowCheckInk();
114
112
  const checkedFill = menuRowChecked(knobProps.scheme);
115
113
  const nestedPx = knobProps.nestedControl.px;
116
114
  const indentStep = 16;
@@ -271,7 +269,7 @@ function TreeSelectRows({
271
269
  ) : null}
272
270
  {isSelected ? (
273
271
  <View flexShrink={0} width={14} height={14}>
274
- <CheckIcon size={14} weight="bold" color={checkInk} />
272
+ <CheckGlyph />
275
273
  </View>
276
274
  ) : null}
277
275
  </XStack>
@@ -184,6 +184,36 @@ describe("AppShell on a phone", () => {
184
184
  expect(screen.queryByRole("tablist")).toBeNull();
185
185
  expect(screen.getByTestId("page")).toBeTruthy();
186
186
  });
187
+
188
+ it("keeps the tab bar around an own-chrome route that is a tab, and on a route below it", () => {
189
+ const withDesk = [...destinations, { key: "/desk", label: "Desk", icon: HouseIcon }];
190
+ for (const path of ["/desk", "/desk/Pokemon"]) {
191
+ const { onNavigate } = shell(path, { destinations: withDesk });
192
+ expect(screen.getByTestId("app-shell").getAttribute("data-chrome")).toBe("own");
193
+ expect(screen.queryByTestId("app-shell-bar")).toBeNull();
194
+ const tabs = screen.getAllByRole("tab");
195
+ expect(tabs.map((tab) => tab.getAttribute("aria-label"))).toEqual([
196
+ "Home",
197
+ "Pokemon",
198
+ "Desk",
199
+ ]);
200
+ expect(tabs[2]!.getAttribute("aria-selected")).toBe("true");
201
+ fireEvent.click(tabs[0]!);
202
+ expect(onNavigate).toHaveBeenCalledWith("/");
203
+ expect(screen.getByTestId("page")).toBeTruthy();
204
+ cleanup();
205
+ }
206
+ });
207
+
208
+ it("draws no tab bar around an own-chrome route at regular width", () => {
209
+ size.value = "expanded";
210
+ shell("/desk", {
211
+ destinations: [...destinations, { key: "/desk", label: "Desk", icon: HouseIcon }],
212
+ });
213
+ expect(screen.getByTestId("app-shell").getAttribute("data-chrome")).toBe("own");
214
+ expect(screen.queryByRole("tablist")).toBeNull();
215
+ expect(screen.queryByTestId("app-shell-sidebar")).toBeNull();
216
+ });
187
217
  });
188
218
 
189
219
  describe("AppShell at regular width", () => {
@@ -17,7 +17,8 @@
17
17
  * (LC-101 ONE-SIDEBAR).
18
18
  *
19
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
20
+ * shell draws no bar or sidebar around it. On a phone, one that is also a tab
21
+ * destination keeps the tab bar, the only way to the other tabs (FZ-8105). A page whose `Screen` pins its own top bar
21
22
  * (LC-157) owns the nav bar on a phone, so the shell's bar steps aside there.
22
23
  */
23
24
  import { zIndex } from "@multiplatform.one/forms";
@@ -305,6 +306,18 @@ export function AppShell({
305
306
  [destinations],
306
307
  );
307
308
 
309
+ const showTabs = compact && Boolean(destination && tabs.includes(destination));
310
+ const tabItems: TabBarItem[] = tabs.map(({ key, label, icon, nativeIcon }) => ({
311
+ key,
312
+ label,
313
+ icon,
314
+ nativeIcon,
315
+ }));
316
+ const onTabPress = (key: string) => {
317
+ const item = tabs.find((tab) => tab.key === key);
318
+ if (item) go(item);
319
+ };
320
+
308
321
  if (ownsChrome) {
309
322
  return (
310
323
  <YStack
@@ -315,7 +328,13 @@ export function AppShell({
315
328
  data-testid={testID}
316
329
  data-chrome="own"
317
330
  >
318
- {children}
331
+ {showTabs ? (
332
+ <TabHost items={tabItems} activeKey={destination?.key} onTabPress={onTabPress}>
333
+ {children}
334
+ </TabHost>
335
+ ) : (
336
+ children
337
+ )}
319
338
  </YStack>
320
339
  );
321
340
  }
@@ -401,13 +420,6 @@ export function AppShell({
401
420
  </YStack>
402
421
  );
403
422
 
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
423
  // The floating TabBar takes the accessory into its own capsule. Otherwise
412
424
  // it renders in place, and on native it clears the system tab bar, which
413
425
  // the content runs under.
@@ -430,10 +442,7 @@ export function AppShell({
430
442
  <TabHost
431
443
  items={tabItems}
432
444
  activeKey={showTabs ? destination?.key : undefined}
433
- onTabPress={(key) => {
434
- const item = tabs.find((tab) => tab.key === key);
435
- if (item) go(item);
436
- }}
445
+ onTabPress={onTabPress}
437
446
  >
438
447
  {content}
439
448
  {pageAccessory}
@@ -135,3 +135,25 @@ describe("ScopeMenu (LC-105 COVER-TRIGGER)", () => {
135
135
  expect(screen.getByLabelText("Environment: Production")).toBeTruthy();
136
136
  });
137
137
  });
138
+
139
+ describe("ScopeMenu as a phone sheet (FZ-8557)", () => {
140
+ it("titles the sheet with the picker's label instead of echoing the trigger", async () => {
141
+ const width = window.innerWidth;
142
+ Object.defineProperty(window, "innerWidth", { configurable: true, writable: true, value: 390 });
143
+ try {
144
+ renderWithProviders(<Tenant />);
145
+ fireEvent.click(screen.getByTestId("tenant-trigger"));
146
+ const header = await screen.findByTestId("tenant-sheet-header");
147
+ expect(screen.getByRole("heading", { name: "Tenant" })).toBeTruthy();
148
+ expect(header.closest('[data-testid="floating-panel-sheet"]')).not.toBeNull();
149
+ expect(screen.queryByTestId("tenant-first-row")).toBeNull();
150
+ expect(screen.getAllByRole("menuitemradio")).toHaveLength(2);
151
+ } finally {
152
+ Object.defineProperty(window, "innerWidth", {
153
+ configurable: true,
154
+ writable: true,
155
+ value: width,
156
+ });
157
+ }
158
+ });
159
+ });
@@ -14,13 +14,16 @@
14
14
  * side, never one menu with two sections.
15
15
  *
16
16
  * Narrow viewports and native get FloatingPanel's sheet (phones take
17
- * sheets); the first-row contract holds there too.
17
+ * sheets). A sheet isn't over its trigger, so it drops the first row for a
18
+ * sheet title, the picker's label (FZ-8557).
18
19
  */
19
- import { FloatingPanel, type PanelWidth } from "@multiplatform.one/forms";
20
- import { useMenuRowCheckInk, menuRowChecked, useResolvedKnobs } from "@multiplatform.one/theme";
21
- import { CaretDownIcon, CaretUpIcon, CheckIcon } from "@phosphor-icons/react";
20
+ import { CheckGlyph } from "../CheckGlyph";
21
+ import { FloatingPanel, type PanelWidth, useInPanelSheet } from "@multiplatform.one/forms";
22
+ import { menuRowCheckGlyph, menuRowChecked, useResolvedKnobs } from "@multiplatform.one/theme";
23
+ import { CaretDownIcon, CaretUpIcon } from "@phosphor-icons/react";
22
24
  import { useState } from "react";
23
- import { XStack, YStack } from "tamagui";
25
+ import { View, XStack, YStack } from "tamagui";
26
+ import { OverlayHeader } from "../actions/StandardAction";
24
27
  import { SizableText } from "../Text";
25
28
 
26
29
  export interface ScopeMenuOption {
@@ -60,8 +63,8 @@ function ScopeRow({
60
63
  ariaLabel?: string;
61
64
  }) {
62
65
  const { knobProps, control } = useResolvedKnobs();
66
+ const inSheet = useInPanelSheet();
63
67
  const iconSize = Number(knobProps.controlIcon?.width) || undefined;
64
- const checkInk = useMenuRowCheckInk();
65
68
  const Chevron = chevron === "up" ? CaretUpIcon : CaretDownIcon;
66
69
  return (
67
70
  <XStack
@@ -71,8 +74,11 @@ function ScopeRow({
71
74
  {...(role === "menuitemradio" ? { "aria-checked": !!checked } : {})}
72
75
  alignItems="center"
73
76
  gap="$2"
74
- minHeight="$3"
75
- paddingHorizontal="$2.5"
77
+ // A menu row is the menu row recipe's height, a touch target on a phone
78
+ // (44pt, HIG); the bar trigger and its echo keep the bar's size.
79
+ minHeight={role === "menuitemradio" ? knobProps.menuRow.minHeight : "$3"}
80
+ // A phone sheet's rows keep HIG's 16pt gutter; a bar menu's hug the trigger.
81
+ paddingHorizontal={inSheet ? "$4" : "$2.5"}
76
82
  cursor="pointer"
77
83
  onPress={onPress}
78
84
  // The echo row is the trigger itself, laid on the panel (LC-112): the
@@ -91,8 +97,8 @@ function ScopeRow({
91
97
  {...(chevron === "down" ? { borderRadius: knobProps.borderRadius.borderRadius } : undefined)}
92
98
  >
93
99
  {role === "menuitemradio" ? (
94
- <YStack width={iconSize} alignItems="center" aria-hidden>
95
- {checked ? <CheckIcon size={iconSize} weight="bold" color={checkInk} /> : null}
100
+ <YStack width={menuRowCheckGlyph.size} alignItems="center" aria-hidden>
101
+ {checked ? <CheckGlyph /> : null}
96
102
  </YStack>
97
103
  ) : null}
98
104
  <SizableText size="$2" fontWeight={chevron ? "500" : "400"} numberOfLines={1}>
@@ -103,6 +109,42 @@ function ScopeRow({
103
109
  );
104
110
  }
105
111
 
112
+ /**
113
+ * Over its trigger, the menu's first row is the pressed item with its chevron
114
+ * flipped (LC-112). A phone sheet isn't over its trigger, so it takes a sheet
115
+ * title, the picker's label, and a close (HIG, FZ-8557 Round 40).
116
+ */
117
+ function ScopeMenuHead({
118
+ label,
119
+ currentLabel,
120
+ onClose,
121
+ testID,
122
+ }: {
123
+ label: string;
124
+ currentLabel: string;
125
+ onClose: () => void;
126
+ testID: string;
127
+ }) {
128
+ const inSheet = useInPanelSheet();
129
+ if (inSheet) {
130
+ return (
131
+ <View paddingHorizontal="$4" paddingBottom="$2">
132
+ <OverlayHeader title={label} onClose={onClose} testID={`${testID}-sheet-header`} />
133
+ </View>
134
+ );
135
+ }
136
+ return (
137
+ <ScopeRow
138
+ role="button"
139
+ label={currentLabel}
140
+ chevron="up"
141
+ ariaLabel={`${label}: ${currentLabel}`}
142
+ onPress={onClose}
143
+ testID={`${testID}-first-row`}
144
+ />
145
+ );
146
+ }
147
+
106
148
  export function ScopeMenu({
107
149
  label,
108
150
  options,
@@ -144,14 +186,11 @@ export function ScopeMenu({
144
186
  }
145
187
  >
146
188
  <YStack role="menu" aria-label={label} data-testid={`${testID}-panel`}>
147
- {/* The pressed item, kept as the first row with its chevron flipped. */}
148
- <ScopeRow
149
- role="button"
150
- label={currentLabel}
151
- chevron="up"
152
- ariaLabel={`${label}: ${currentLabel}`}
153
- onPress={() => setOpen(false)}
154
- testID={`${testID}-first-row`}
189
+ <ScopeMenuHead
190
+ label={label}
191
+ currentLabel={currentLabel}
192
+ onClose={() => setOpen(false)}
193
+ testID={testID}
155
194
  />
156
195
  {options.map((option) => (
157
196
  <ScopeRow
@@ -21,6 +21,7 @@ import {
21
21
  import { zIndex } from "@multiplatform.one/forms";
22
22
  import { isWeb } from "@multiplatform.one/platform";
23
23
  import { type ComponentType, type ReactNode, useEffect, useState } from "react";
24
+ import type { ImageSourcePropType } from "react-native";
24
25
  import { XStack, YStack } from "tamagui";
25
26
  import { materials, useMaterial } from "../Material";
26
27
  import { MaterialBlurFill } from "../Material/blur";
@@ -41,10 +42,19 @@ export interface TabBarItem {
41
42
  label: string;
42
43
  icon: TabBarIcon;
43
44
  /**
44
- * The SF Symbols the system tab bar draws on iOS (TabHost). Without them
45
- * on every item, iOS keeps the MPO TabBar.
45
+ * The glyphs the system tab bar draws (TabHost): SF Symbols on iOS, a
46
+ * raster template image on Android (Material's bar draws no SF Symbol or
47
+ * SVG; a bundled PNG, so an OTA can carry it). A platform keeps the MPO
48
+ * TabBar unless every item names its glyph.
46
49
  */
47
- nativeIcon?: { sfSymbol: string; sfSymbolSelected?: string };
50
+ nativeIcon?: TabBarNativeIcon;
51
+ }
52
+
53
+ export interface TabBarNativeIcon {
54
+ sfSymbol?: string;
55
+ sfSymbolSelected?: string;
56
+ /** A monochrome PNG (24dp, @2x and @3x beside it); the bar tints it. */
57
+ android?: ImageSourcePropType;
48
58
  }
49
59
 
50
60
  export interface TabBarProps {
@@ -16,23 +16,36 @@ import {
16
16
  NativeTabHost,
17
17
  TabHost,
18
18
  itemsHaveNativeIcons,
19
+ nativeIconFor,
19
20
  nativeTabBarHeight,
21
+ nativeTabsPlatforms,
20
22
  resolveNativeTabs,
21
23
  } from "./TabHost.native";
22
24
 
23
25
  afterEach(cleanup);
24
26
 
25
27
  const Glyph = () => null;
28
+ const png = (name: string) => ({ uri: `${name}.png`, width: 24, height: 24 });
26
29
 
27
30
  const items: TabBarItem[] = [
28
31
  {
29
32
  key: "/",
30
33
  label: "Home",
31
34
  icon: Glyph,
32
- nativeIcon: { sfSymbol: "house", sfSymbolSelected: "house.fill" },
35
+ nativeIcon: { sfSymbol: "house", sfSymbolSelected: "house.fill", android: png("house") },
36
+ },
37
+ {
38
+ key: "/pokemon",
39
+ label: "Pokemon",
40
+ icon: Glyph,
41
+ nativeIcon: { sfSymbol: "sparkles", android: png("sparkle") },
42
+ },
43
+ {
44
+ key: "/moves",
45
+ label: "Moves",
46
+ icon: Glyph,
47
+ nativeIcon: { sfSymbol: "bolt", android: png("lightning") },
33
48
  },
34
- { key: "/pokemon", label: "Pokemon", icon: Glyph, nativeIcon: { sfSymbol: "sparkles" } },
35
- { key: "/moves", label: "Moves", icon: Glyph, nativeIcon: { sfSymbol: "bolt" } },
36
49
  ];
37
50
 
38
51
  const fakeTabs: NativeTabs = {
@@ -58,7 +71,8 @@ const fakeTabs: NativeTabs = {
58
71
  data-testid={`native-screen-${tabKey}`}
59
72
  data-focused={String(Boolean(isFocused))}
60
73
  data-inset-adjust={String(overrideScrollViewContentInsetAdjustmentBehavior)}
61
- data-symbol={icon?.ios.name}
74
+ data-symbol={icon?.ios?.name}
75
+ data-android-icon={(icon?.android?.imageSource as { uri?: string } | undefined)?.uri}
62
76
  aria-label={title}
63
77
  >
64
78
  {children}
@@ -113,19 +127,49 @@ describe("native tabs detection", () => {
113
127
  expect(resolveNativeTabs(sources(["RNSTabsHost", "RNSTabsScreen"], throws), "ios")).toBeNull();
114
128
  });
115
129
 
116
- it("keeps the MPO TabBar on Android and the web", () => {
130
+ it("keeps the MPO TabBar on Android and the web by default", () => {
117
131
  const screens = vi.fn(() => ({ Tabs: fakeTabs }));
118
132
  const all = sources(["RNSTabsHost", "RNSTabsScreen"], screens);
133
+ expect(nativeTabsPlatforms).toEqual({ ios: true, android: false });
119
134
  expect(resolveNativeTabs(all, "android")).toBeNull();
120
135
  expect(resolveNativeTabs(all, "web")).toBeNull();
121
136
  expect(screens).not.toHaveBeenCalled();
122
137
  });
123
138
 
124
- it("needs an SF Symbol on every item", () => {
139
+ it("picks the Material bar on Android once the platform is switched on (FZ-8140)", () => {
140
+ const on = { ios: true, android: true };
141
+ expect(
142
+ resolveNativeTabs(
143
+ sources(["RNSTabsHost", "RNSTabsScreen"], () => ({ Tabs: fakeTabs })),
144
+ "android",
145
+ on,
146
+ ),
147
+ ).toBe(fakeTabs);
148
+ const screens = vi.fn(() => ({ Tabs: fakeTabs }));
149
+ expect(resolveNativeTabs(sources([], screens), "android", on)).toBeNull();
150
+ expect(screens).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it("needs this platform's glyph on every item: an SF Symbol on iOS, a PNG on Android", () => {
125
154
  expect(itemsHaveNativeIcons(items)).toBe(true);
155
+ expect(itemsHaveNativeIcons(items, "android")).toBe(true);
126
156
  expect(itemsHaveNativeIcons([...items, { key: "/x", label: "X", icon: Glyph }])).toBe(false);
157
+ const symbolsOnly = items.map(({ nativeIcon, ...item }) => ({
158
+ ...item,
159
+ nativeIcon: { sfSymbol: nativeIcon?.sfSymbol },
160
+ }));
161
+ expect(itemsHaveNativeIcons(symbolsOnly, "ios")).toBe(true);
162
+ expect(itemsHaveNativeIcons(symbolsOnly, "android")).toBe(false);
127
163
  expect(itemsHaveNativeIcons([])).toBe(false);
128
164
  });
165
+
166
+ it("hands react-native-screens each platform's glyph from the one item", () => {
167
+ expect(nativeIconFor(items[0]!)).toEqual({
168
+ ios: { type: "sfSymbol", name: "house" },
169
+ android: { type: "imageSource", imageSource: png("house") },
170
+ });
171
+ expect(nativeIconFor({ key: "/x", label: "X", icon: Glyph })).toEqual({});
172
+ });
129
173
  });
130
174
 
131
175
  describe("NativeTabHost", () => {
@@ -136,6 +180,9 @@ describe("NativeTabHost", () => {
136
180
  expect(pokemon.contains(screen.getByTestId("page"))).toBe(true);
137
181
  expect(screen.getByTestId("native-screen-/").getAttribute("data-focused")).toBe("false");
138
182
  expect(screen.getByTestId("native-screen-/").getAttribute("data-symbol")).toBe("house");
183
+ expect(screen.getByTestId("native-screen-/").getAttribute("data-android-icon")).toBe(
184
+ "house.png",
185
+ );
139
186
  expect(screen.getByTestId("native-host").getAttribute("data-hidden")).toBe("false");
140
187
  expect(screen.queryByTestId("tab-bar")).toBeNull();
141
188
  });