@multiplatform.one/components 7.83.1 → 7.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/Alert/index.cjs +1 -1
- package/dist/cjs/Alert/index.native.js +1 -1
- package/dist/cjs/Alert/index.native.js.map +1 -1
- package/dist/cjs/Carousel/index.cjs +11 -9
- package/dist/cjs/Carousel/index.native.js +12 -10
- package/dist/cjs/Carousel/index.native.js.map +1 -1
- package/dist/cjs/Chip/index.cjs +2 -1
- package/dist/cjs/Chip/index.native.js +2 -1
- package/dist/cjs/Chip/index.native.js.map +1 -1
- package/dist/cjs/Meter/meter.cjs +1 -1
- package/dist/cjs/Meter/meter.native.js +1 -1
- package/dist/cjs/Meter/meter.native.js.map +1 -1
- package/dist/cjs/SheetModal/index.cjs +33 -5
- package/dist/cjs/appShell/TabBar.cjs +1 -1
- package/dist/cjs/appShell/TabBar.native.js +1 -1
- package/dist/cjs/appShell/TabBar.native.js.map +1 -1
- package/dist/cjs/appShell/TabHost.native.js +52 -5
- package/dist/cjs/appShell/TabHost.native.js.map +1 -1
- package/dist/cjs/layouts/PageTemplates.cjs +15 -12
- package/dist/cjs/layouts/PageTemplates.native.js +16 -13
- package/dist/cjs/layouts/PageTemplates.native.js.map +1 -1
- package/dist/cjs/layouts/bottomAccessory.cjs +25 -13
- package/dist/cjs/layouts/bottomAccessory.native.js +29 -13
- package/dist/cjs/layouts/bottomAccessory.native.js.map +1 -1
- package/dist/cjs/layouts/page.cjs +44 -6
- package/dist/cjs/layouts/page.native.js +48 -6
- package/dist/cjs/layouts/page.native.js.map +1 -1
- package/dist/cjs/layouts/tags.cjs +25 -12
- package/dist/cjs/layouts/tags.native.js +25 -16
- package/dist/cjs/layouts/tags.native.js.map +1 -1
- package/dist/esm/Alert/index.mjs +1 -1
- package/dist/esm/Alert/index.mjs.map +1 -1
- package/dist/esm/Alert/index.native.js +1 -1
- package/dist/esm/Alert/index.native.js.map +1 -1
- package/dist/esm/Carousel/index.mjs +12 -10
- package/dist/esm/Carousel/index.mjs.map +1 -1
- package/dist/esm/Carousel/index.native.js +13 -11
- package/dist/esm/Carousel/index.native.js.map +1 -1
- package/dist/esm/Chip/index.mjs +3 -2
- package/dist/esm/Chip/index.mjs.map +1 -1
- package/dist/esm/Chip/index.native.js +3 -2
- package/dist/esm/Chip/index.native.js.map +1 -1
- package/dist/esm/Meter/meter.mjs +1 -1
- package/dist/esm/Meter/meter.mjs.map +1 -1
- package/dist/esm/Meter/meter.native.js +1 -1
- package/dist/esm/Meter/meter.native.js.map +1 -1
- package/dist/esm/SheetModal/index.mjs +34 -6
- package/dist/esm/SheetModal/index.mjs.map +1 -1
- package/dist/esm/appShell/TabBar.mjs +2 -2
- package/dist/esm/appShell/TabBar.mjs.map +1 -1
- package/dist/esm/appShell/TabBar.native.js +2 -2
- package/dist/esm/appShell/TabBar.native.js.map +1 -1
- package/dist/esm/appShell/TabHost.native.js +53 -9
- package/dist/esm/appShell/TabHost.native.js.map +1 -1
- package/dist/esm/layouts/PageTemplates.mjs +16 -13
- package/dist/esm/layouts/PageTemplates.mjs.map +1 -1
- package/dist/esm/layouts/PageTemplates.native.js +17 -14
- package/dist/esm/layouts/PageTemplates.native.js.map +1 -1
- package/dist/esm/layouts/bottomAccessory.mjs +26 -15
- package/dist/esm/layouts/bottomAccessory.mjs.map +1 -1
- package/dist/esm/layouts/bottomAccessory.native.js +30 -15
- package/dist/esm/layouts/bottomAccessory.native.js.map +1 -1
- package/dist/esm/layouts/page.mjs +44 -8
- package/dist/esm/layouts/page.mjs.map +1 -1
- package/dist/esm/layouts/page.native.js +48 -8
- package/dist/esm/layouts/page.native.js.map +1 -1
- package/dist/esm/layouts/tags.mjs +28 -15
- package/dist/esm/layouts/tags.mjs.map +1 -1
- package/dist/esm/layouts/tags.native.js +28 -19
- package/dist/esm/layouts/tags.native.js.map +1 -1
- package/dist/jsx/Alert/index.mjs +1 -1
- package/dist/jsx/Alert/index.mjs.map +1 -1
- package/dist/jsx/Alert/index.native.js +1 -1
- package/dist/jsx/Alert/index.native.js.map +1 -1
- package/dist/jsx/Carousel/index.mjs +12 -10
- package/dist/jsx/Carousel/index.mjs.map +1 -1
- package/dist/jsx/Carousel/index.native.js +12 -10
- package/dist/jsx/Carousel/index.native.js.map +1 -1
- package/dist/jsx/Chip/index.mjs +3 -2
- package/dist/jsx/Chip/index.mjs.map +1 -1
- package/dist/jsx/Chip/index.native.js +2 -1
- package/dist/jsx/Chip/index.native.js.map +1 -1
- package/dist/jsx/Meter/meter.mjs +1 -1
- package/dist/jsx/Meter/meter.mjs.map +1 -1
- package/dist/jsx/Meter/meter.native.js +1 -1
- package/dist/jsx/Meter/meter.native.js.map +1 -1
- package/dist/jsx/SheetModal/index.mjs +34 -6
- package/dist/jsx/SheetModal/index.mjs.map +1 -1
- package/dist/jsx/appShell/TabBar.mjs +2 -2
- package/dist/jsx/appShell/TabBar.mjs.map +1 -1
- package/dist/jsx/appShell/TabBar.native.js +1 -1
- package/dist/jsx/appShell/TabBar.native.js.map +1 -1
- package/dist/jsx/appShell/TabHost.native.js +52 -5
- package/dist/jsx/appShell/TabHost.native.js.map +1 -1
- package/dist/jsx/layouts/PageTemplates.mjs +16 -13
- package/dist/jsx/layouts/PageTemplates.mjs.map +1 -1
- package/dist/jsx/layouts/PageTemplates.native.js +16 -13
- package/dist/jsx/layouts/PageTemplates.native.js.map +1 -1
- package/dist/jsx/layouts/bottomAccessory.mjs +26 -15
- package/dist/jsx/layouts/bottomAccessory.mjs.map +1 -1
- package/dist/jsx/layouts/bottomAccessory.native.js +29 -13
- package/dist/jsx/layouts/bottomAccessory.native.js.map +1 -1
- package/dist/jsx/layouts/page.mjs +44 -8
- package/dist/jsx/layouts/page.mjs.map +1 -1
- package/dist/jsx/layouts/page.native.js +48 -6
- package/dist/jsx/layouts/page.native.js.map +1 -1
- package/dist/jsx/layouts/tags.mjs +28 -15
- package/dist/jsx/layouts/tags.mjs.map +1 -1
- package/dist/jsx/layouts/tags.native.js +25 -16
- package/dist/jsx/layouts/tags.native.js.map +1 -1
- package/package.json +9 -9
- package/src/Alert/Alert.spec.tsx +82 -1
- package/src/Alert/index.tsx +1 -1
- package/src/Carousel/Carousel.native.spec.tsx +25 -8
- package/src/Carousel/index.tsx +18 -11
- package/src/Chip/Chip.spec.tsx +33 -0
- package/src/Chip/index.tsx +10 -5
- package/src/Meter/Meter.spec.tsx +5 -4
- package/src/Meter/meter.ts +6 -4
- package/src/SheetModal/SheetModal.regularWidth.spec.tsx +87 -0
- package/src/SheetModal/index.tsx +52 -5
- package/src/Text.stories.tsx +13 -0
- package/src/appShell/TabBar.spec.tsx +10 -4
- package/src/appShell/TabBar.tsx +4 -8
- package/src/appShell/TabHost.native.spec.tsx +109 -1
- package/src/appShell/TabHost.native.tsx +84 -13
- package/src/hoverTouchPath.coarse.spec.tsx +166 -0
- package/src/icons/themedButtonInk.spec.tsx +117 -0
- package/src/layouts/PageTemplates.tsx +14 -11
- package/src/layouts/Screen.chromeClearance.spec.tsx +28 -0
- package/src/layouts/bottomAccessory.tsx +40 -15
- package/src/layouts/page.tsx +73 -19
- package/src/layouts/pageTitleOneLine.spec.tsx +42 -0
- package/src/layouts/pushedPageTitle.spec.tsx +116 -0
- package/src/layouts/tags-token-field.spec.tsx +21 -5
- package/src/layouts/tags.tsx +36 -20
- package/types/Carousel/index.d.ts.map +1 -1
- package/types/Chip/index.d.ts.map +1 -1
- package/types/Meter/meter.d.ts +6 -4
- package/types/Meter/meter.d.ts.map +1 -1
- package/types/SheetModal/index.d.ts.map +1 -1
- package/types/appShell/TabBar.d.ts.map +1 -1
- package/types/appShell/TabHost.native.d.ts +13 -1
- package/types/appShell/TabHost.native.d.ts.map +1 -1
- package/types/layouts/PageTemplates.d.ts.map +1 -1
- package/types/layouts/bottomAccessory.d.ts +6 -1
- package/types/layouts/bottomAccessory.d.ts.map +1 -1
- package/types/layouts/page.d.ts +16 -1
- package/types/layouts/page.d.ts.map +1 -1
- package/types/layouts/tags.d.ts.map +1 -1
package/src/appShell/TabBar.tsx
CHANGED
|
@@ -10,14 +10,10 @@
|
|
|
10
10
|
* (`useBottomChromeInset`), so the last row clears it. The bottom accessory
|
|
11
11
|
* (LC-174, e.g. a pager) floats as its own smaller capsule above it. A theme
|
|
12
12
|
* changes the colours only: the active item is the theme's accent as
|
|
13
|
-
* readable ink
|
|
14
|
-
* ink.
|
|
13
|
+
* readable ink (`useActiveTabInk`, the same pick as the system tab bar's) on
|
|
14
|
+
* an accent-wash pill, inactive items are the secondary text ink.
|
|
15
15
|
*/
|
|
16
|
-
import {
|
|
17
|
-
useAccentOnSurface,
|
|
18
|
-
useAccentTintedSurface,
|
|
19
|
-
useGlyphColor,
|
|
20
|
-
} from "@multiplatform.one/theme";
|
|
16
|
+
import { useAccentTintedSurface, useActiveTabInk, useGlyphColor } from "@multiplatform.one/theme";
|
|
21
17
|
import { zIndex } from "@multiplatform.one/forms";
|
|
22
18
|
import { isWeb } from "@multiplatform.one/platform";
|
|
23
19
|
import { type ComponentType, type ReactNode, useEffect, useState } from "react";
|
|
@@ -134,7 +130,7 @@ function TabBarButton({
|
|
|
134
130
|
onPress?: () => void;
|
|
135
131
|
testID: string;
|
|
136
132
|
}) {
|
|
137
|
-
const accent =
|
|
133
|
+
const accent = useActiveTabInk();
|
|
138
134
|
const wash = useAccentTintedSurface();
|
|
139
135
|
const secondaryInk = useGlyphColor(tabBarInactiveInk);
|
|
140
136
|
const ink = active ? accent : secondaryInk;
|
|
@@ -8,6 +8,12 @@ import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
|
8
8
|
import { cleanup, fireEvent, screen } from "@testing-library/react";
|
|
9
9
|
import type { ReactNode } from "react";
|
|
10
10
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import {
|
|
12
|
+
BottomAccessoryHost,
|
|
13
|
+
BottomAccessoryScope,
|
|
14
|
+
BottomAccessory,
|
|
15
|
+
createBottomAccessoryStore,
|
|
16
|
+
} from "../layouts/bottomAccessory";
|
|
11
17
|
import { useBottomChromeInset } from "./bottomChrome";
|
|
12
18
|
import type { TabBarItem } from "./TabBar";
|
|
13
19
|
import {
|
|
@@ -18,7 +24,10 @@ import {
|
|
|
18
24
|
itemsHaveNativeIcons,
|
|
19
25
|
nativeIconFor,
|
|
20
26
|
nativeTabBarHeight,
|
|
27
|
+
nativeTabsAccessoryComponent,
|
|
28
|
+
nativeTabsAccessoryHeight,
|
|
21
29
|
nativeTabsPlatforms,
|
|
30
|
+
resolveNativeAccessory,
|
|
22
31
|
resolveNativeTabs,
|
|
23
32
|
} from "./TabHost.native";
|
|
24
33
|
|
|
@@ -49,9 +58,12 @@ const items: TabBarItem[] = [
|
|
|
49
58
|
];
|
|
50
59
|
|
|
51
60
|
const fakeTabs: NativeTabs = {
|
|
52
|
-
Host: ({ children, onNativeFocusChange, tabBarHidden }) => (
|
|
61
|
+
Host: ({ children, onNativeFocusChange, tabBarHidden, bottomAccessory }) => (
|
|
53
62
|
<div data-testid="native-host" data-hidden={String(Boolean(tabBarHidden))}>
|
|
54
63
|
{children}
|
|
64
|
+
{bottomAccessory ? (
|
|
65
|
+
<aside data-testid="native-bottom-accessory">{bottomAccessory("regular")}</aside>
|
|
66
|
+
) : null}
|
|
55
67
|
<button
|
|
56
68
|
type="button"
|
|
57
69
|
data-testid="native-press-moves"
|
|
@@ -271,3 +283,99 @@ describe("TabHost fallback", () => {
|
|
|
271
283
|
expect(screen.queryByTestId("tab-bar")).toBeNull();
|
|
272
284
|
});
|
|
273
285
|
});
|
|
286
|
+
|
|
287
|
+
describe("bottom accessory under the system bar (FZ-7995, LC-174)", () => {
|
|
288
|
+
const registered = [...["RNSTabsHost", "RNSTabsScreen"], nativeTabsAccessoryComponent];
|
|
289
|
+
|
|
290
|
+
it("hosts it on iOS 26 when the binary carries the accessory view", () => {
|
|
291
|
+
expect(
|
|
292
|
+
resolveNativeAccessory(
|
|
293
|
+
sources(registered, () => ({})),
|
|
294
|
+
"ios",
|
|
295
|
+
"26.0",
|
|
296
|
+
),
|
|
297
|
+
).toBe(true);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("keeps the page's slot before iOS 26, on a binary without the view, off iOS, or when the probe throws", () => {
|
|
301
|
+
expect(
|
|
302
|
+
resolveNativeAccessory(
|
|
303
|
+
sources(registered, () => ({})),
|
|
304
|
+
"ios",
|
|
305
|
+
"18.5",
|
|
306
|
+
),
|
|
307
|
+
).toBe(false);
|
|
308
|
+
expect(
|
|
309
|
+
resolveNativeAccessory(
|
|
310
|
+
sources(["RNSTabsHost", "RNSTabsScreen"], () => ({})),
|
|
311
|
+
"ios",
|
|
312
|
+
"26.1",
|
|
313
|
+
),
|
|
314
|
+
).toBe(false);
|
|
315
|
+
expect(
|
|
316
|
+
resolveNativeAccessory(
|
|
317
|
+
sources(registered, () => ({})),
|
|
318
|
+
"android",
|
|
319
|
+
36,
|
|
320
|
+
),
|
|
321
|
+
).toBe(false);
|
|
322
|
+
const throws = () => {
|
|
323
|
+
throw new Error("unavailable");
|
|
324
|
+
};
|
|
325
|
+
expect(resolveNativeAccessory({ hasComponent: throws }, "ios", "26.0")).toBe(false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
function renderWithPager(hostsAccessory: boolean, pushed = false) {
|
|
329
|
+
const activeKey = pushed ? undefined : "/moves";
|
|
330
|
+
const store = createBottomAccessoryStore();
|
|
331
|
+
return renderWithProviders(
|
|
332
|
+
<NativeTabHost
|
|
333
|
+
tabs={fakeTabs}
|
|
334
|
+
items={items}
|
|
335
|
+
activeKey={activeKey}
|
|
336
|
+
onTabPress={vi.fn()}
|
|
337
|
+
hostsAccessory={hostsAccessory}
|
|
338
|
+
>
|
|
339
|
+
<BottomAccessoryScope store={store}>
|
|
340
|
+
<div data-testid="page">
|
|
341
|
+
<BottomAccessory>
|
|
342
|
+
<span data-testid="pager">1–15 of 900</span>
|
|
343
|
+
</BottomAccessory>
|
|
344
|
+
</div>
|
|
345
|
+
<BottomAccessoryHost store={store} />
|
|
346
|
+
</BottomAccessoryScope>
|
|
347
|
+
<Inset />
|
|
348
|
+
</NativeTabHost>,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
it("moves the page's pager into the system bar's accessory and clears it", () => {
|
|
353
|
+
renderWithPager(true);
|
|
354
|
+
const accessory = screen.getByTestId("native-bottom-accessory");
|
|
355
|
+
expect(accessory.contains(screen.getByTestId("pager"))).toBe(true);
|
|
356
|
+
expect(screen.getByTestId("native-tab-accessory")).toBeTruthy();
|
|
357
|
+
expect(document.querySelector('[data-bottom-accessory="slot"]')).toBeNull();
|
|
358
|
+
expect(Number(screen.getByTestId("inset").textContent)).toBeGreaterThanOrEqual(
|
|
359
|
+
nativeTabBarHeight + nativeTabsAccessoryHeight,
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("falls back to the page's slot, standing on the bar, where the system bar cannot host it", () => {
|
|
364
|
+
renderWithPager(false);
|
|
365
|
+
expect(screen.queryByTestId("native-bottom-accessory")).toBeNull();
|
|
366
|
+
const slot = document.querySelector('[data-bottom-accessory="slot"]');
|
|
367
|
+
expect(slot?.contains(screen.getByTestId("pager"))).toBe(true);
|
|
368
|
+
expect(screen.getByTestId("bottom-accessory-lift")).toBeTruthy();
|
|
369
|
+
const inset = Number(screen.getByTestId("inset").textContent);
|
|
370
|
+
expect(inset).toBeGreaterThanOrEqual(nativeTabBarHeight);
|
|
371
|
+
expect(inset).toBeLessThan(nativeTabBarHeight + nativeTabsAccessoryHeight);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("returns the pager to the slot on a pushed screen, where the bar is hidden", () => {
|
|
375
|
+
renderWithPager(true, true);
|
|
376
|
+
expect(screen.queryByTestId("native-bottom-accessory")).toBeNull();
|
|
377
|
+
const slot = document.querySelector('[data-bottom-accessory="slot"]');
|
|
378
|
+
expect(slot?.contains(screen.getByTestId("pager"))).toBe(true);
|
|
379
|
+
expect(screen.queryByTestId("bottom-accessory-lift")).toBeNull();
|
|
380
|
+
});
|
|
381
|
+
});
|
|
@@ -15,21 +15,23 @@
|
|
|
15
15
|
* (Material bottom navigation, PNG template icons) is built and switched off
|
|
16
16
|
* until an Android build proves it (FZ-8140).
|
|
17
17
|
*
|
|
18
|
+
* A page's bottom accessory (LC-174, e.g. a DataTable pager) rides the
|
|
19
|
+
* system bar too: on iOS 26, where the binary carries RNSTabsBottomAccessory,
|
|
20
|
+
* it is UITabBarController's bottom accessory, the system's own glass capsule
|
|
21
|
+
* above the bar (FZ-7995). Anywhere else it stays in the page's slot, which
|
|
22
|
+
* sits above the bar.
|
|
23
|
+
*
|
|
18
24
|
* The bar's clearance is the inset this host publishes, as with the MPO
|
|
19
25
|
* TabBar, so a Screen pads by it whether or not it scrolls. Each tab screen
|
|
20
26
|
* leaves its scroll view's inset adjustment off: react-native-screens would
|
|
21
27
|
* otherwise flip the first scroll view to `automatic`, and UIKit would add
|
|
22
28
|
* the bar's height a second time (FZ-8640).
|
|
23
29
|
*/
|
|
24
|
-
import {
|
|
25
|
-
useAccentTintedSurface,
|
|
26
|
-
useGlyphColor,
|
|
27
|
-
useIdentityInkOnSurface,
|
|
28
|
-
} from "@multiplatform.one/theme";
|
|
30
|
+
import { useAccentTintedSurface, useActiveTabInk, useGlyphColor } from "@multiplatform.one/theme";
|
|
29
31
|
import { type ComponentType, type ReactNode, useEffect, useRef, useState } from "react";
|
|
30
32
|
import { type ImageSourcePropType, Platform, UIManager } from "react-native";
|
|
31
|
-
import { YStack } from "tamagui";
|
|
32
|
-
import { setBottomChromeInset } from "./bottomChrome";
|
|
33
|
+
import { XStack, YStack } from "tamagui";
|
|
34
|
+
import { setBottomChromeInset, setFloatingBarMounted, useBottomAccessory } from "./bottomChrome";
|
|
33
35
|
import { useBottomInset } from "./bottomInset";
|
|
34
36
|
import { type TabBarItem, tabBarInactiveInk } from "./TabBar";
|
|
35
37
|
import { MpoTabHost, type TabHostProps } from "./MpoTabHost";
|
|
@@ -42,6 +44,12 @@ export const nativeTabsComponents = ["RNSTabsHost", "RNSTabsScreen"] as const;
|
|
|
42
44
|
/** Points the system tab bar covers above the home-indicator band. */
|
|
43
45
|
export const nativeTabBarHeight = 49;
|
|
44
46
|
|
|
47
|
+
/** The Fabric component behind UITabBarController's bottom accessory (iOS 26). */
|
|
48
|
+
export const nativeTabsAccessoryComponent = "RNSTabsBottomAccessory";
|
|
49
|
+
|
|
50
|
+
/** Points the bottom accessory's capsule adds above the system bar, its gap included. */
|
|
51
|
+
export const nativeTabsAccessoryHeight = 56;
|
|
52
|
+
|
|
45
53
|
type ImageIcon = { type: "imageSource"; imageSource: ImageSourcePropType };
|
|
46
54
|
type IconProp = { ios?: { type: "sfSymbol"; name: string }; android?: ImageIcon };
|
|
47
55
|
|
|
@@ -66,6 +74,7 @@ export interface NativeTabs {
|
|
|
66
74
|
tabBarBackgroundColor?: string;
|
|
67
75
|
tabBarItemActiveIndicatorColor?: string;
|
|
68
76
|
experimentalControlNavigationStateInJS?: boolean;
|
|
77
|
+
bottomAccessory?: (environment: "regular" | "inline") => ReactNode;
|
|
69
78
|
onNativeFocusChange?: (event: { nativeEvent: { tabKey: string } }) => void;
|
|
70
79
|
}>;
|
|
71
80
|
Screen: ComponentType<{
|
|
@@ -107,13 +116,39 @@ export function resolveNativeTabs(
|
|
|
107
116
|
}
|
|
108
117
|
}
|
|
109
118
|
|
|
119
|
+
/**
|
|
120
|
+
* True when the system bar can host the page's bottom accessory: iOS 26 or
|
|
121
|
+
* later (UITabAccessory) and a binary that carries react-native-screens'
|
|
122
|
+
* accessory view. Otherwise the accessory stays in the page's slot.
|
|
123
|
+
*/
|
|
124
|
+
export function resolveNativeAccessory(
|
|
125
|
+
sources: Pick<NativeTabsSources, "hasComponent">,
|
|
126
|
+
os: string,
|
|
127
|
+
version: string | number,
|
|
128
|
+
): boolean {
|
|
129
|
+
if (os !== "ios" || Number.parseInt(String(version), 10) < 26) return false;
|
|
130
|
+
try {
|
|
131
|
+
return sources.hasComponent(nativeTabsAccessoryComponent);
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
110
137
|
let cached: NativeTabs | null | undefined;
|
|
138
|
+
let cachedAccessory: boolean | undefined;
|
|
111
139
|
|
|
112
140
|
function loadNativeTabs(): NativeTabs | null {
|
|
113
141
|
if (cached === undefined) cached = resolveNativeTabs(appSources, Platform.OS);
|
|
114
142
|
return cached;
|
|
115
143
|
}
|
|
116
144
|
|
|
145
|
+
function loadNativeAccessory(): boolean {
|
|
146
|
+
if (cachedAccessory === undefined) {
|
|
147
|
+
cachedAccessory = resolveNativeAccessory(appSources, Platform.OS, Platform.Version);
|
|
148
|
+
}
|
|
149
|
+
return cachedAccessory;
|
|
150
|
+
}
|
|
151
|
+
|
|
117
152
|
/** True when every item names the glyph this platform's system bar draws. */
|
|
118
153
|
export function itemsHaveNativeIcons(items: readonly TabBarItem[], os = "ios"): boolean {
|
|
119
154
|
const glyph = (item: TabBarItem) =>
|
|
@@ -130,15 +165,31 @@ export function nativeIconFor(item: TabBarItem): IconProp {
|
|
|
130
165
|
};
|
|
131
166
|
}
|
|
132
167
|
|
|
168
|
+
/** The accessory's content inside the system capsule: one centred row. */
|
|
169
|
+
function NativeAccessoryContent({ children }: { children: ReactNode }) {
|
|
170
|
+
return (
|
|
171
|
+
<XStack
|
|
172
|
+
flex={1}
|
|
173
|
+
alignItems="center"
|
|
174
|
+
justifyContent="center"
|
|
175
|
+
paddingHorizontal="$3"
|
|
176
|
+
data-testid="native-tab-accessory"
|
|
177
|
+
>
|
|
178
|
+
{children}
|
|
179
|
+
</XStack>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
133
183
|
export function NativeTabHost({
|
|
134
184
|
tabs,
|
|
135
185
|
items,
|
|
136
186
|
activeKey,
|
|
137
187
|
onTabPress,
|
|
138
188
|
children,
|
|
139
|
-
|
|
189
|
+
hostsAccessory = false,
|
|
190
|
+
}: TabHostProps & { tabs: NativeTabs; hostsAccessory?: boolean }) {
|
|
140
191
|
const { Host, Screen } = tabs;
|
|
141
|
-
const accent =
|
|
192
|
+
const accent = useActiveTabInk();
|
|
142
193
|
const ink = useGlyphColor(tabBarInactiveInk);
|
|
143
194
|
const surface = useGlyphColor("$color2");
|
|
144
195
|
const indicator = useAccentTintedSurface();
|
|
@@ -147,11 +198,28 @@ export function NativeTabHost({
|
|
|
147
198
|
const lastKey = useRef(activeKey ?? items[0]?.key);
|
|
148
199
|
if (activeKey) lastKey.current = activeKey;
|
|
149
200
|
const focusedKey = lastKey.current;
|
|
201
|
+
const hosting = hostsAccessory && Boolean(activeKey);
|
|
202
|
+
const pageAccessory = useBottomAccessory();
|
|
203
|
+
const accessory = hosting ? pageAccessory : null;
|
|
150
204
|
|
|
151
205
|
useEffect(() => {
|
|
152
|
-
setBottomChromeInset(
|
|
153
|
-
|
|
154
|
-
|
|
206
|
+
setBottomChromeInset(
|
|
207
|
+
id,
|
|
208
|
+
activeKey
|
|
209
|
+
? nativeTabBarHeight + deviceInset + (accessory ? nativeTabsAccessoryHeight : 0)
|
|
210
|
+
: null,
|
|
211
|
+
);
|
|
212
|
+
}, [activeKey, accessory, deviceInset, id]);
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
setFloatingBarMounted(id, hosting);
|
|
215
|
+
}, [hosting, id]);
|
|
216
|
+
useEffect(
|
|
217
|
+
() => () => {
|
|
218
|
+
setBottomChromeInset(id, null);
|
|
219
|
+
setFloatingBarMounted(id, false);
|
|
220
|
+
},
|
|
221
|
+
[id],
|
|
222
|
+
);
|
|
155
223
|
|
|
156
224
|
return (
|
|
157
225
|
<Host
|
|
@@ -164,6 +232,9 @@ export function NativeTabHost({
|
|
|
164
232
|
tabBarBackgroundColor={surface}
|
|
165
233
|
tabBarItemActiveIndicatorColor={indicator}
|
|
166
234
|
experimentalControlNavigationStateInJS
|
|
235
|
+
bottomAccessory={
|
|
236
|
+
accessory ? () => <NativeAccessoryContent>{accessory}</NativeAccessoryContent> : undefined
|
|
237
|
+
}
|
|
167
238
|
onNativeFocusChange={(event) => {
|
|
168
239
|
const key = event.nativeEvent.tabKey;
|
|
169
240
|
if (key !== activeKey) onTabPress(key);
|
|
@@ -199,5 +270,5 @@ export function NativeTabHost({
|
|
|
199
270
|
export function TabHost(props: TabHostProps) {
|
|
200
271
|
const tabs = loadNativeTabs();
|
|
201
272
|
if (!tabs || !itemsHaveNativeIcons(props.items, Platform.OS)) return <MpoTabHost {...props} />;
|
|
202
|
-
return <NativeTabHost tabs={tabs} {...props} />;
|
|
273
|
+
return <NativeTabHost tabs={tabs} hostsAccessory={loadNativeAccessory()} {...props} />;
|
|
203
274
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LC-177 HOVER-HAS-TOUCH-PATH, the coarse-pointer probe (FZ-8031).
|
|
3
|
+
*
|
|
4
|
+
* `scripts/hover-touch-path.spec.mjs` declares every public/* file that
|
|
5
|
+
* listens for hover, and names the touch or focus path of each one that
|
|
6
|
+
* reveals something. This probe walks each declared reveal the way a phone
|
|
7
|
+
* or a keyboard does: the pointer is coarse with no hover, no hover event is
|
|
8
|
+
* ever dispatched, and the revealed thing must still be reached.
|
|
9
|
+
*
|
|
10
|
+
* happy-dom cascades no Tamagui CSS, so paint is read from the atomic
|
|
11
|
+
* classes each node asks for (`_bg-`, `_o-`, `_pe-`) and its inline style.
|
|
12
|
+
*/
|
|
13
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
14
|
+
import { act, cleanup, fireEvent } from "@testing-library/react";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import React from "react";
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
20
|
+
import { BarChart } from "./charts/BarChart";
|
|
21
|
+
import type { ChartDatum } from "./charts/types";
|
|
22
|
+
import { Video } from "./Video";
|
|
23
|
+
import { GanttView, type GanttTask } from "./views/Gantt";
|
|
24
|
+
|
|
25
|
+
vi.mock("expo-video", () => ({
|
|
26
|
+
useVideoPlayer: () => ({ loop: false, muted: false, play: vi.fn(), pause: vi.fn() }),
|
|
27
|
+
VideoView: () => null,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const registry = readFileSync(join(here, "../../../scripts/hover-touch-path.spec.mjs"), "utf8");
|
|
32
|
+
const declaredReveals = [...registry.matchAll(/"(public\/[^"]+)": \{\s*kind: "reveal"/g)].map(
|
|
33
|
+
(m) => m[1],
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const hoverEvents = ["mouseover", "mouseenter", "pointerover", "pointerenter"] as const;
|
|
37
|
+
let hovered: string[];
|
|
38
|
+
const recordHover = (event: Event) => hovered.push(event.type);
|
|
39
|
+
let originalMatchMedia: typeof window.matchMedia;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
hovered = [];
|
|
43
|
+
for (const type of hoverEvents) document.addEventListener(type, recordHover, true);
|
|
44
|
+
originalMatchMedia = window.matchMedia;
|
|
45
|
+
window.matchMedia = vi.fn((query: string) => ({
|
|
46
|
+
matches: /hover:\s*none|pointer:\s*coarse/.test(query),
|
|
47
|
+
media: query,
|
|
48
|
+
onchange: null,
|
|
49
|
+
addEventListener: vi.fn(),
|
|
50
|
+
removeEventListener: vi.fn(),
|
|
51
|
+
addListener: vi.fn(),
|
|
52
|
+
removeListener: vi.fn(),
|
|
53
|
+
dispatchEvent: () => false,
|
|
54
|
+
})) as unknown as typeof window.matchMedia;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
for (const type of hoverEvents) document.removeEventListener(type, recordHover, true);
|
|
59
|
+
window.matchMedia = originalMatchMedia;
|
|
60
|
+
cleanup();
|
|
61
|
+
vi.useRealTimers();
|
|
62
|
+
vi.restoreAllMocks();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function paint(el: Element, prefix: string, styleKey: string): string {
|
|
66
|
+
const resting = Array.from(el.classList).filter(
|
|
67
|
+
(c) => c.startsWith(prefix) && !/^_[a-zA-Z]+-\d*(hover|active|focus|press)/.test(c),
|
|
68
|
+
);
|
|
69
|
+
const inline = (el as HTMLElement).style?.getPropertyValue(styleKey) ?? "";
|
|
70
|
+
return [...resting, inline].filter(Boolean).join(" ");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const describeNode = (el: Element) => `class="${el.className}" style="${el.getAttribute("style")}"`;
|
|
74
|
+
|
|
75
|
+
/** Each declared reveal and how a coarse pointer reaches it without hover. */
|
|
76
|
+
const probes: Record<string, () => void | Promise<void>> = {
|
|
77
|
+
"public/components/src/views/Gantt.tsx": () => {
|
|
78
|
+
const tasks: GanttTask[] = [
|
|
79
|
+
{
|
|
80
|
+
id: "t1",
|
|
81
|
+
title: "Implementation",
|
|
82
|
+
start: new Date(2026, 2, 12),
|
|
83
|
+
end: new Date(2026, 2, 18),
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
const { container } = renderWithProviders(<GanttView tasks={tasks} onItemMove={vi.fn()} />);
|
|
87
|
+
const bar = container.querySelector('[data-gantt-bar="t1"]') as HTMLElement;
|
|
88
|
+
expect(bar).toBeTruthy();
|
|
89
|
+
const handles = () =>
|
|
90
|
+
Array.from(bar.querySelectorAll("[data-gantt-resize-handle]")) as HTMLElement[];
|
|
91
|
+
expect(handles().length).toBe(2);
|
|
92
|
+
for (const handle of handles()) {
|
|
93
|
+
expect(paint(handle, "_bg-", "background-color"), describeNode(handle)).toMatch(
|
|
94
|
+
/transparent/,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
act(() => bar.focus());
|
|
98
|
+
for (const handle of handles()) {
|
|
99
|
+
expect(paint(handle, "_bg-", "background-color"), describeNode(handle)).not.toMatch(
|
|
100
|
+
/transparent/,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
"public/components/src/Video/PlayerChrome.tsx": () => {
|
|
106
|
+
vi.useFakeTimers();
|
|
107
|
+
const { container, getByTestId, getByLabelText } = renderWithProviders(
|
|
108
|
+
<Video src="https://example.com/clip.mp4" title="Demo" controls muted={false} />,
|
|
109
|
+
);
|
|
110
|
+
const video = container.querySelector("video") as HTMLVideoElement;
|
|
111
|
+
expect(video).toBeTruthy();
|
|
112
|
+
act(() => {
|
|
113
|
+
video.dispatchEvent(new Event("play"));
|
|
114
|
+
});
|
|
115
|
+
act(() => {
|
|
116
|
+
vi.advanceTimersByTime(5000);
|
|
117
|
+
});
|
|
118
|
+
const chrome = getByTestId("video-chrome");
|
|
119
|
+
const shut = () => /(^|\s)(_pe-none|none)(\s|$)/.test(paint(chrome, "_pe-", "pointer-events"));
|
|
120
|
+
expect(shut(), describeNode(chrome)).toBe(true);
|
|
121
|
+
act(() => getByLabelText("Mute").focus());
|
|
122
|
+
expect(shut(), describeNode(chrome)).toBe(false);
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
"public/components/src/charts/series.tsx": () => {
|
|
126
|
+
const data: ChartDatum[] = [
|
|
127
|
+
{ label: "Jan", value: 400 },
|
|
128
|
+
{ label: "Feb", value: 380 },
|
|
129
|
+
];
|
|
130
|
+
const onDatumPress = vi.fn();
|
|
131
|
+
const onDatumHoverIn = vi.fn();
|
|
132
|
+
renderWithProviders(
|
|
133
|
+
<BarChart
|
|
134
|
+
data={data}
|
|
135
|
+
width={400}
|
|
136
|
+
onDatumPress={onDatumPress}
|
|
137
|
+
onDatumHoverIn={onDatumHoverIn}
|
|
138
|
+
/>,
|
|
139
|
+
);
|
|
140
|
+
const target = document.querySelector('svg rect[aria-label="Feb: 380"]');
|
|
141
|
+
expect(target).toBeTruthy();
|
|
142
|
+
fireEvent.click(target as Element);
|
|
143
|
+
expect(onDatumPress).toHaveBeenCalledWith(data[1], 1);
|
|
144
|
+
expect(onDatumHoverIn).not.toHaveBeenCalled();
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
describe("LC-177: a coarse pointer reaches every hover reveal without hover", () => {
|
|
149
|
+
it("probes exactly the reveals scripts/hover-touch-path.spec.mjs declares", () => {
|
|
150
|
+
expect(declaredReveals.length).toBeGreaterThan(0);
|
|
151
|
+
expect(Object.keys(probes).sort()).toEqual([...declaredReveals].sort());
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("runs under a coarse pointer with no hover", () => {
|
|
155
|
+
expect(window.matchMedia("(hover: none)").matches).toBe(true);
|
|
156
|
+
expect(window.matchMedia("(pointer: coarse)").matches).toBe(true);
|
|
157
|
+
expect(window.matchMedia("(hover: hover)").matches).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
for (const file of Object.keys(probes)) {
|
|
161
|
+
it(`reaches ${file.replace("public/components/src/", "")}'s reveal`, async () => {
|
|
162
|
+
await probes[file]();
|
|
163
|
+
expect(hovered, "the probe must never hover").toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* FZ-8577: a `themed` glyph inside a Button fills with the Button's ink on
|
|
5
|
+
* web, as it does on native. Before, an accentInk icon Button painted its
|
|
6
|
+
* frame in the bar tint while the svg filled with the theme's `var(--color)`,
|
|
7
|
+
* so the SHC console's search, graph and gauge glyphs drew near-black on web
|
|
8
|
+
* and purple on iOS. The same held for an intent icon button and a disabled
|
|
9
|
+
* one (FZ-8044, LC-40: the disabled twin keeps its ink, dimmed).
|
|
10
|
+
*
|
|
11
|
+
* jsdom cascades no Tamagui CSS, so the frame's ink is read from the rule
|
|
12
|
+
* behind its `_col-` class, and the glyph's from its own fill: either that
|
|
13
|
+
* ink itself or `currentColor`, which inherits it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
17
|
+
import { Button } from "@multiplatform.one/forms";
|
|
18
|
+
import { createDefaultThemeConfig, Surface } from "@multiplatform.one/theme";
|
|
19
|
+
import { MagnifyingGlassIcon } from "@phosphor-icons/react";
|
|
20
|
+
import type { ReactElement } from "react";
|
|
21
|
+
import { TamaguiProvider, YStack } from "tamagui";
|
|
22
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
23
|
+
import { themed } from "./themed";
|
|
24
|
+
|
|
25
|
+
afterEach(cleanup);
|
|
26
|
+
|
|
27
|
+
const houseConfig = createDefaultThemeConfig();
|
|
28
|
+
const SearchGlyph = themed(MagnifyingGlassIcon);
|
|
29
|
+
|
|
30
|
+
function renderUnder(ui: ReactElement, theme: string) {
|
|
31
|
+
return render(
|
|
32
|
+
<TamaguiProvider config={houseConfig.tamagui} defaultTheme={theme}>
|
|
33
|
+
<YStack backgroundColor="$background">{ui}</YStack>
|
|
34
|
+
</TamaguiProvider>,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ruleValue(className: string, property: string): string | null {
|
|
39
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
40
|
+
for (const rule of Array.from(sheet.cssRules)) {
|
|
41
|
+
if (!(rule instanceof CSSStyleRule)) continue;
|
|
42
|
+
if (!rule.selectorText.endsWith(`.${className}`)) continue;
|
|
43
|
+
const value = rule.style.getPropertyValue(property);
|
|
44
|
+
if (value) return value.trim();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function frameInk(label: string): string | null {
|
|
51
|
+
const frame = screen.getByLabelText(label);
|
|
52
|
+
const cls = Array.from(frame.classList).find(
|
|
53
|
+
(c) => c.startsWith("_col-") && !/^_col-0(hover|active|focus|press)/.test(c),
|
|
54
|
+
);
|
|
55
|
+
return cls ? ruleValue(cls, "color") : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function glyphFill(label: string): string | null {
|
|
59
|
+
const svg = screen.getByLabelText(label).querySelector("svg");
|
|
60
|
+
return svg?.getAttribute("fill") ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The glyph takes the frame's ink: the same value, or currentColor, which inherits it. */
|
|
64
|
+
function expectGlyphTakesFrameInk(label: string) {
|
|
65
|
+
const ink = frameInk(label);
|
|
66
|
+
const fill = glyphFill(label);
|
|
67
|
+
expect(ink, `${label} frame ink`).toBeTruthy();
|
|
68
|
+
expect(fill, `${label} glyph fill`).toBeTruthy();
|
|
69
|
+
expect(fill).not.toBe("var(--color)");
|
|
70
|
+
if (fill !== "currentColor") expect(fill?.toLowerCase()).toBe(ink?.toLowerCase());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("FZ-8577: a themed glyph in a Button fills with the Button's ink on web", () => {
|
|
74
|
+
for (const theme of ["light_purple", "dark_purple", "light_blue", "dark_blue"]) {
|
|
75
|
+
it(`${theme}: an accentInk icon button in a bar`, () => {
|
|
76
|
+
renderUnder(
|
|
77
|
+
<Surface bar>
|
|
78
|
+
<Button chromeless accentInk icon={SearchGlyph} aria-label="Search" />
|
|
79
|
+
</Surface>,
|
|
80
|
+
theme,
|
|
81
|
+
);
|
|
82
|
+
expectGlyphTakesFrameInk("Search");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it(`${theme}: an accentInk icon button in content`, () => {
|
|
86
|
+
renderUnder(<Button chromeless accentInk icon={SearchGlyph} aria-label="Find" />, theme);
|
|
87
|
+
expectGlyphTakesFrameInk("Find");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it(`${theme}: an intent icon button`, () => {
|
|
91
|
+
renderUnder(<Button error icon={SearchGlyph} aria-label="Remove" />, theme);
|
|
92
|
+
expectGlyphTakesFrameInk("Remove");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it(`${theme}: a disabled accentInk icon button keeps the enabled twin's ink`, () => {
|
|
96
|
+
renderUnder(
|
|
97
|
+
<Surface bar>
|
|
98
|
+
<Button chromeless accentInk icon={SearchGlyph} aria-label="Next" />
|
|
99
|
+
<Button chromeless accentInk disabled icon={SearchGlyph} aria-label="Previous" />
|
|
100
|
+
</Surface>,
|
|
101
|
+
theme,
|
|
102
|
+
);
|
|
103
|
+
expectGlyphTakesFrameInk("Previous");
|
|
104
|
+
expect(frameInk("Previous")).toBe(frameInk("Next"));
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
it("a glyph with its own colour keeps it", () => {
|
|
109
|
+
renderUnder(
|
|
110
|
+
<Surface bar>
|
|
111
|
+
<Button chromeless accentInk icon={<SearchGlyph color="red" />} aria-label="Own" />
|
|
112
|
+
</Surface>,
|
|
113
|
+
"light_purple",
|
|
114
|
+
);
|
|
115
|
+
expect(glyphFill("Own")).toBe("red");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
} from "@multiplatform.one/theme";
|
|
33
33
|
import { useState, type ReactNode } from "react";
|
|
34
34
|
import { XStack, YStack, isWeb, type SizeTokens } from "tamagui";
|
|
35
|
-
import { PageHeader, Screen, ScreenToolbar, type ScreenProps } from "./page";
|
|
35
|
+
import { PageHeader, PushedPage, Screen, ScreenToolbar, type ScreenProps } from "./page";
|
|
36
36
|
import { SupportingPaneLayout } from "./PaneScaffold";
|
|
37
37
|
import { useShellSidebarPanel } from "./shellSidebarSlot";
|
|
38
38
|
|
|
@@ -266,16 +266,19 @@ export function DetailPageLayout({
|
|
|
266
266
|
return (
|
|
267
267
|
// LC-157: the toolbar is the Screen's pinned top bar, so it stays put
|
|
268
268
|
// while the record scrolls and takes the title once the header scrolls away.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
269
|
+
// LC-270: a detail page is pushed, so on a phone its title starts there.
|
|
270
|
+
<PushedPage>
|
|
271
|
+
<Screen
|
|
272
|
+
data-canonical="detail-page"
|
|
273
|
+
topBar={toolbar}
|
|
274
|
+
// LC-263: one readable column; a supporting pane gets the full width.
|
|
275
|
+
maxWidth={aside == null ? DETAIL_READABLE_WIDTH : undefined}
|
|
276
|
+
{...rest}
|
|
277
|
+
>
|
|
278
|
+
{renderTemplateHeader({ title, subtitle, actions, header, nav })}
|
|
279
|
+
{body}
|
|
280
|
+
</Screen>
|
|
281
|
+
</PushedPage>
|
|
279
282
|
);
|
|
280
283
|
}
|
|
281
284
|
|