@multiplatform.one/components 7.83.2 → 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.
Files changed (108) hide show
  1. package/dist/cjs/Carousel/index.cjs +11 -9
  2. package/dist/cjs/Carousel/index.native.js +12 -10
  3. package/dist/cjs/Carousel/index.native.js.map +1 -1
  4. package/dist/cjs/Meter/meter.cjs +1 -1
  5. package/dist/cjs/Meter/meter.native.js +1 -1
  6. package/dist/cjs/Meter/meter.native.js.map +1 -1
  7. package/dist/cjs/SheetModal/index.cjs +33 -5
  8. package/dist/cjs/appShell/TabBar.cjs +1 -1
  9. package/dist/cjs/appShell/TabBar.native.js +1 -1
  10. package/dist/cjs/appShell/TabBar.native.js.map +1 -1
  11. package/dist/cjs/appShell/TabHost.native.js +52 -5
  12. package/dist/cjs/appShell/TabHost.native.js.map +1 -1
  13. package/dist/cjs/layouts/PageTemplates.cjs +15 -12
  14. package/dist/cjs/layouts/PageTemplates.native.js +16 -13
  15. package/dist/cjs/layouts/PageTemplates.native.js.map +1 -1
  16. package/dist/cjs/layouts/bottomAccessory.cjs +25 -13
  17. package/dist/cjs/layouts/bottomAccessory.native.js +29 -13
  18. package/dist/cjs/layouts/bottomAccessory.native.js.map +1 -1
  19. package/dist/cjs/layouts/page.cjs +44 -6
  20. package/dist/cjs/layouts/page.native.js +48 -6
  21. package/dist/cjs/layouts/page.native.js.map +1 -1
  22. package/dist/esm/Carousel/index.mjs +12 -10
  23. package/dist/esm/Carousel/index.mjs.map +1 -1
  24. package/dist/esm/Carousel/index.native.js +13 -11
  25. package/dist/esm/Carousel/index.native.js.map +1 -1
  26. package/dist/esm/Meter/meter.mjs +1 -1
  27. package/dist/esm/Meter/meter.mjs.map +1 -1
  28. package/dist/esm/Meter/meter.native.js +1 -1
  29. package/dist/esm/Meter/meter.native.js.map +1 -1
  30. package/dist/esm/SheetModal/index.mjs +34 -6
  31. package/dist/esm/SheetModal/index.mjs.map +1 -1
  32. package/dist/esm/appShell/TabBar.mjs +2 -2
  33. package/dist/esm/appShell/TabBar.mjs.map +1 -1
  34. package/dist/esm/appShell/TabBar.native.js +2 -2
  35. package/dist/esm/appShell/TabBar.native.js.map +1 -1
  36. package/dist/esm/appShell/TabHost.native.js +53 -9
  37. package/dist/esm/appShell/TabHost.native.js.map +1 -1
  38. package/dist/esm/layouts/PageTemplates.mjs +16 -13
  39. package/dist/esm/layouts/PageTemplates.mjs.map +1 -1
  40. package/dist/esm/layouts/PageTemplates.native.js +17 -14
  41. package/dist/esm/layouts/PageTemplates.native.js.map +1 -1
  42. package/dist/esm/layouts/bottomAccessory.mjs +26 -15
  43. package/dist/esm/layouts/bottomAccessory.mjs.map +1 -1
  44. package/dist/esm/layouts/bottomAccessory.native.js +30 -15
  45. package/dist/esm/layouts/bottomAccessory.native.js.map +1 -1
  46. package/dist/esm/layouts/page.mjs +44 -8
  47. package/dist/esm/layouts/page.mjs.map +1 -1
  48. package/dist/esm/layouts/page.native.js +48 -8
  49. package/dist/esm/layouts/page.native.js.map +1 -1
  50. package/dist/jsx/Carousel/index.mjs +12 -10
  51. package/dist/jsx/Carousel/index.mjs.map +1 -1
  52. package/dist/jsx/Carousel/index.native.js +12 -10
  53. package/dist/jsx/Carousel/index.native.js.map +1 -1
  54. package/dist/jsx/Meter/meter.mjs +1 -1
  55. package/dist/jsx/Meter/meter.mjs.map +1 -1
  56. package/dist/jsx/Meter/meter.native.js +1 -1
  57. package/dist/jsx/Meter/meter.native.js.map +1 -1
  58. package/dist/jsx/SheetModal/index.mjs +34 -6
  59. package/dist/jsx/SheetModal/index.mjs.map +1 -1
  60. package/dist/jsx/appShell/TabBar.mjs +2 -2
  61. package/dist/jsx/appShell/TabBar.mjs.map +1 -1
  62. package/dist/jsx/appShell/TabBar.native.js +1 -1
  63. package/dist/jsx/appShell/TabBar.native.js.map +1 -1
  64. package/dist/jsx/appShell/TabHost.native.js +52 -5
  65. package/dist/jsx/appShell/TabHost.native.js.map +1 -1
  66. package/dist/jsx/layouts/PageTemplates.mjs +16 -13
  67. package/dist/jsx/layouts/PageTemplates.mjs.map +1 -1
  68. package/dist/jsx/layouts/PageTemplates.native.js +16 -13
  69. package/dist/jsx/layouts/PageTemplates.native.js.map +1 -1
  70. package/dist/jsx/layouts/bottomAccessory.mjs +26 -15
  71. package/dist/jsx/layouts/bottomAccessory.mjs.map +1 -1
  72. package/dist/jsx/layouts/bottomAccessory.native.js +29 -13
  73. package/dist/jsx/layouts/bottomAccessory.native.js.map +1 -1
  74. package/dist/jsx/layouts/page.mjs +44 -8
  75. package/dist/jsx/layouts/page.mjs.map +1 -1
  76. package/dist/jsx/layouts/page.native.js +48 -6
  77. package/dist/jsx/layouts/page.native.js.map +1 -1
  78. package/package.json +9 -9
  79. package/src/Carousel/Carousel.native.spec.tsx +25 -8
  80. package/src/Carousel/index.tsx +18 -11
  81. package/src/Meter/Meter.spec.tsx +5 -4
  82. package/src/Meter/meter.ts +6 -4
  83. package/src/SheetModal/SheetModal.regularWidth.spec.tsx +87 -0
  84. package/src/SheetModal/index.tsx +52 -5
  85. package/src/Text.stories.tsx +13 -0
  86. package/src/appShell/TabBar.spec.tsx +10 -4
  87. package/src/appShell/TabBar.tsx +4 -8
  88. package/src/appShell/TabHost.native.spec.tsx +109 -1
  89. package/src/appShell/TabHost.native.tsx +84 -13
  90. package/src/icons/themedButtonInk.spec.tsx +117 -0
  91. package/src/layouts/PageTemplates.tsx +14 -11
  92. package/src/layouts/Screen.chromeClearance.spec.tsx +28 -0
  93. package/src/layouts/bottomAccessory.tsx +40 -15
  94. package/src/layouts/page.tsx +73 -19
  95. package/src/layouts/pageTitleOneLine.spec.tsx +42 -0
  96. package/src/layouts/pushedPageTitle.spec.tsx +116 -0
  97. package/types/Carousel/index.d.ts.map +1 -1
  98. package/types/Meter/meter.d.ts +6 -4
  99. package/types/Meter/meter.d.ts.map +1 -1
  100. package/types/SheetModal/index.d.ts.map +1 -1
  101. package/types/appShell/TabBar.d.ts.map +1 -1
  102. package/types/appShell/TabHost.native.d.ts +13 -1
  103. package/types/appShell/TabHost.native.d.ts.map +1 -1
  104. package/types/layouts/PageTemplates.d.ts.map +1 -1
  105. package/types/layouts/bottomAccessory.d.ts +6 -1
  106. package/types/layouts/bottomAccessory.d.ts.map +1 -1
  107. package/types/layouts/page.d.ts +16 -1
  108. package/types/layouts/page.d.ts.map +1 -1
@@ -16,7 +16,7 @@ import {
16
16
  useResolvedKnobs,
17
17
  } from "@multiplatform.one/theme";
18
18
  import type { KeyboardEvent, ReactNode } from "react";
19
- import { Platform } from "react-native";
19
+ import { I18nManager } from "react-native";
20
20
  import { useState, useRef, useCallback, useEffect, Children } from "react";
21
21
  import { useTranslation } from "../shared/i18n";
22
22
  import { CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react";
@@ -194,7 +194,10 @@ export function Carousel({
194
194
  const arrowHitSlop = pressTargetHitSlop(arrowSize);
195
195
  const [activeIndex, setActiveIndex] = useState(initialSlide);
196
196
  const isRTL = useDirection() === "rtl";
197
- const isAndroidRTL = !isWeb && isRTL && Platform.OS === "android";
197
+ // A native RTL track lays slide 0 at the physical right, but contentOffset
198
+ // and scrollTo stay physical on iOS (Fabric) and Android alike, so slide i
199
+ // sits at offset (count - 1 - i) × pitch (FZ-7677, iOS 27 simulator).
200
+ const isNativeRTL = !isWeb && isRTL;
198
201
  const activeIndexRef = useRef(activeIndex);
199
202
  activeIndexRef.current = activeIndex;
200
203
  const trackRef = useRef<TamaguiScrollView | null>(null);
@@ -219,10 +222,10 @@ export function Carousel({
219
222
  (index: number) => {
220
223
  if (!trackRef.current) return;
221
224
  if (index < 0 || index >= slideCount) return;
222
- const offset = (isAndroidRTL ? slideCount - 1 - index : index) * (slideWidth + gapPx);
225
+ const offset = (isNativeRTL ? slideCount - 1 - index : index) * (slideWidth + gapPx);
223
226
  trackRef.current.scrollTo({ x: offset, animated: animateSnaps });
224
227
  },
225
- [slideCount, slideWidth, gapPx, animateSnaps, isAndroidRTL],
228
+ [slideCount, slideWidth, gapPx, animateSnaps, isNativeRTL],
226
229
  );
227
230
 
228
231
  const goToSlide = useCallback(
@@ -250,7 +253,7 @@ export function Carousel({
250
253
  (event: { nativeEvent: { contentOffset: { x: number } } }) => {
251
254
  if (!slideWidth || slideCount === 0) return;
252
255
  const offset = event.nativeEvent.contentOffset.x;
253
- const scrollX = isAndroidRTL ? (slideCount - 1) * (slideWidth + gapPx) - offset : offset;
256
+ const scrollX = isNativeRTL ? (slideCount - 1) * (slideWidth + gapPx) - offset : offset;
254
257
  const roundedIndex = Math.round(scrollX / (slideWidth + gapPx));
255
258
  const newIndex = isWeb ? roundedIndex : Math.max(0, Math.min(roundedIndex, slideCount - 1));
256
259
  if (newIndex !== activeIndexRef.current && newIndex >= 0 && newIndex < slideCount) {
@@ -259,7 +262,7 @@ export function Carousel({
259
262
  onSlideChange?.(newIndex);
260
263
  }
261
264
  },
262
- [slideCount, onSlideChange, slideWidth, gapPx, isAndroidRTL],
265
+ [slideCount, onSlideChange, slideWidth, gapPx, isNativeRTL],
263
266
  );
264
267
 
265
268
  const handleScrollEndDrag = useCallback(
@@ -302,10 +305,10 @@ export function Carousel({
302
305
  if (!slideWidth || slideCount === 0 || !trackRef.current) return;
303
306
  const index = Math.max(0, Math.min(activeIndexRef.current, slideCount - 1));
304
307
  trackRef.current.scrollTo({
305
- x: (isAndroidRTL ? slideCount - 1 - index : index) * (slideWidth + gapPx),
308
+ x: (isNativeRTL ? slideCount - 1 - index : index) * (slideWidth + gapPx),
306
309
  animated: false,
307
310
  });
308
- }, [slideWidth, gapPx, slideCount, isAndroidRTL]);
311
+ }, [slideWidth, gapPx, slideCount, isNativeRTL]);
309
312
 
310
313
  const canGoPrev = loop || activeIndex > 0;
311
314
  const canGoNext = loop || activeIndex < slideCount - 1;
@@ -317,9 +320,13 @@ export function Carousel({
317
320
  // and the glyph mirror together (glyph swap keyed on useDirection — the
318
321
  // one mechanism for directional chrome): each edge button keeps pointing
319
322
  // outward, toward the slide it navigates to. Semantics (labels, disabled)
320
- // stay bound to the prev/next action, not the physical side.
321
- const prevSide = isRTL ? ("right" as const) : ("left" as const);
322
- const nextSide = isRTL ? ("left" as const) : ("right" as const);
323
+ // stay bound to the prev/next action, not the physical side. Native RN
324
+ // already swaps left and right in RTL (doLeftAndRightSwapInRTL), so there
325
+ // the side prop stays logical or the arrows land back where LTR has them.
326
+ const swapsSides = isNativeRTL && I18nManager.getConstants?.().doLeftAndRightSwapInRTL !== false;
327
+ const mirrorSides = isRTL && !swapsSides;
328
+ const prevSide = mirrorSides ? ("right" as const) : ("left" as const);
329
+ const nextSide = mirrorSides ? ("left" as const) : ("right" as const);
323
330
  const PrevGlyph = isRTL ? CaretRightIcon : CaretLeftIcon;
324
331
  const NextGlyph = isRTL ? CaretLeftIcon : CaretRightIcon;
325
332
 
@@ -78,13 +78,13 @@ describe("threshold intents map to theme intents, never raw brand", () => {
78
78
  expect(resolveMeterZone({ value: 80, ...BATTERY })).toBe("ok");
79
79
  });
80
80
 
81
- it("paints each zone with theme intent tokens, never hex or $accent", () => {
81
+ it("paints each zone with theme tokens, never hex or the accent solid", () => {
82
82
  const token = /^\$(color|orange|red)\d+$/;
83
83
  for (const zone of Object.keys(METER_ZONE_PAINT) as MeterZone[]) {
84
84
  const paint = METER_ZONE_PAINT[zone];
85
- expect(paint.fill, `${zone} fill`).toMatch(token);
86
85
  expect(paint.fill).not.toMatch(/^#/);
87
- expect(paint.fill).not.toContain("accent");
86
+ expect(paint.fill).not.toBe("$accentBackground");
87
+ if (zone !== "ok") expect(paint.fill, `${zone} fill`).toMatch(token);
88
88
  if ("value" in paint) {
89
89
  expect(paint.value, `${zone} value`).toMatch(token);
90
90
  expect(paint.value).not.toMatch(/^#/);
@@ -93,7 +93,8 @@ describe("threshold intents map to theme intents, never raw brand", () => {
93
93
  }
94
94
  // MPO-154: ok declares no value ink — the resolver owns it.
95
95
  expect("value" in METER_ZONE_PAINT.ok).toBe(false);
96
- expect(METER_ZONE_PAINT.ok.fill).toBe("$color11");
96
+ // LC-172 (FZ-8044): ok is a fill on a track, the theme's accent ink, as Progress paints it.
97
+ expect(METER_ZONE_PAINT.ok.fill).toBe("$accentColor");
97
98
  expect(METER_ZONE_PAINT.warn.fill).toBe("$orange9");
98
99
  expect(METER_ZONE_PAINT.warn.value).toBe("$orange11");
99
100
  expect(METER_ZONE_PAINT.critical.fill).toBe("$red9");
@@ -23,9 +23,11 @@ export type MeterLayout = "adjacent" | "inline" | "bare";
23
23
  export const METER_RADIUS_CLASS = "DEFAULT" as const;
24
24
 
25
25
  /**
26
- * Zone paint — theme intent tokens, never raw brand or the accent solid.
27
- * ok is the LAW pairing (no verdict under threshold, DG-FB-06). warn/critical
28
- * are the same step-9 solid / step-11 ink channel Progress uses (DG-FB-05).
26
+ * Zone paint — theme tokens, never raw brand or the accent solid. ok carries
27
+ * no verdict (DG-FB-06), so it is the plain fill on a track Progress paints:
28
+ * the theme's accent ink, `formSelectedColors.trackFill` (LC-172, FZ-8044).
29
+ * warn/critical are the same step-9 solid / step-11 ink channel Progress uses
30
+ * (DG-FB-05).
29
31
  *
30
32
  * MPO-154: ok carries no `value`. Under no verdict the readout is ordinary
31
33
  * secondary text, so its ink is `knobProps.textAccentColor` — a static
@@ -33,7 +35,7 @@ export const METER_RADIUS_CLASS = "DEFAULT" as const;
33
35
  * and it pinned the readout dim at `textAccent: high`.
34
36
  */
35
37
  export const METER_ZONE_PAINT = {
36
- ok: { fill: "$color11" },
38
+ ok: { fill: "$accentColor" },
37
39
  warn: { fill: "$orange9", value: "$orange11" },
38
40
  critical: { fill: "$red9", value: "$red11" },
39
41
  } as const;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * FZ-8939 (LC-266): the web SheetModal at regular width is the native
3
+ * panel's twin (FZ-8116). On a desktop window the Tier 2 cells
4
+ * `components-sheetframe--default` and `components-formlayouts--sign-in-in-sheet`
5
+ * drew the sheet as a band across the whole window. It is a bottom panel
6
+ * SHEET_PANEL_WIDTH wide, centred, with the scrim showing on both sides, and a
7
+ * press beside it dismisses. At compact width it stays the full-width sheet.
8
+ */
9
+ import { SHEET_PANEL_WIDTH } from "@multiplatform.one/forms";
10
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
11
+ import { act, cleanup, fireEvent, waitFor } from "@testing-library/react";
12
+ import { afterEach, describe, expect, it, vi } from "vitest";
13
+ import { Text } from "tamagui";
14
+ import { SheetModal } from "./index";
15
+
16
+ // The DOM under test reports no window width of its own, so the window is
17
+ // set here, as the native regular-width spec does.
18
+ const screen = vi.hoisted(() => ({ width: 1280, height: 800 }));
19
+ vi.mock("tamagui", async (importOriginal) => ({
20
+ ...(await importOriginal<typeof import("tamagui")>()),
21
+ useWindowDimensions: () => ({ ...screen, scale: 1, fontScale: 1 }),
22
+ }));
23
+
24
+ function setWindowWidth(width: number) {
25
+ screen.width = width;
26
+ }
27
+
28
+ afterEach(() => {
29
+ cleanup();
30
+ screen.width = 1280;
31
+ });
32
+
33
+ async function openAt(width: number) {
34
+ setWindowWidth(width);
35
+ const onOpenChange = vi.fn();
36
+ const view = renderWithProviders(
37
+ <SheetModal open onOpenChange={onOpenChange}>
38
+ <Text>Sign in</Text>
39
+ </SheetModal>,
40
+ );
41
+ // tamagui draws a cover under the frame with the frame's own props; the
42
+ // frame is the one that is not the cover.
43
+ let frame: HTMLElement | null = null;
44
+ await waitFor(() => {
45
+ frame = document.querySelector('[role="dialog"]:not([data-sheet-cover])');
46
+ expect(frame).not.toBeNull();
47
+ });
48
+ return { view, frame: frame as unknown as HTMLElement, onOpenChange };
49
+ }
50
+
51
+ const atoms = (node: Element) => String(node.className).split(/\s+/).filter(Boolean);
52
+
53
+ describe("SheetModal web at regular width (FZ-8939, LC-266)", () => {
54
+ it("is a centred panel capped at the panel width on a desktop window", async () => {
55
+ const { frame } = await openAt(1280);
56
+ expect(frame.getAttribute("data-sheet-form")).toBe("panel");
57
+ const width = frame.style.width || atoms(frame).find((a) => a.startsWith("_w-")) || "";
58
+ expect(width).toContain(`${SHEET_PANEL_WIDTH}px`);
59
+ expect(atoms(frame).some((a) => a === "_als-center")).toBe(true);
60
+ // A panel keeps its side edges; the compact band drops them.
61
+ expect(atoms(frame)).not.toContain("_blw-0px");
62
+ expect(atoms(frame)).not.toContain("_brw-0px");
63
+ });
64
+
65
+ it("dismisses on a press beside the panel, not on a press inside it", async () => {
66
+ const { frame, onOpenChange } = await openAt(1280);
67
+ await act(async () => {
68
+ fireEvent.click(frame);
69
+ });
70
+ expect(onOpenChange).not.toHaveBeenCalled();
71
+ await act(async () => {
72
+ fireEvent.click(frame.parentElement as HTMLElement);
73
+ });
74
+ expect(onOpenChange).toHaveBeenCalledWith(false);
75
+ });
76
+
77
+ it("stays the full-width bottom sheet at phone width", async () => {
78
+ const { frame, onOpenChange } = await openAt(390);
79
+ expect(frame.getAttribute("data-sheet-form")).toBe("bottom");
80
+ expect(atoms(frame)).toContain("_blw-0px");
81
+ expect(atoms(frame)).toContain("_brw-0px");
82
+ await act(async () => {
83
+ fireEvent.click(frame.parentElement as HTMLElement);
84
+ });
85
+ expect(onOpenChange).not.toHaveBeenCalled();
86
+ });
87
+ });
@@ -15,7 +15,12 @@ import {
15
15
  transitionProps,
16
16
  useResolvedKnobs,
17
17
  } from "@multiplatform.one/theme";
18
- import { OverlayStackProvider, useOverlayZIndex } from "@multiplatform.one/forms";
18
+ import {
19
+ OverlayStackProvider,
20
+ overlaySizeClass,
21
+ sheetPanelWidth,
22
+ useOverlayZIndex,
23
+ } from "@multiplatform.one/forms";
19
24
  import { useEffect, useId, useRef, type ReactNode } from "react";
20
25
  import { isWeb, Sheet, View, useWindowDimensions } from "tamagui";
21
26
  import { OverlayTitleIdContext } from "../actions/StandardAction";
@@ -69,6 +74,47 @@ const IN_DIALOG_REGION = { inDialogRegion: true } as const;
69
74
  const fillSlot = { flex: 1, minHeight: 0 } as const;
70
75
  const nonShrinking = { flexShrink: 0 } as const;
71
76
 
77
+ /** A compact sheet spans the window: only its top edge meets the page. */
78
+ const bottomEdges = { borderLeftWidth: 0, borderRightWidth: 0, borderBottomWidth: 0 } as const;
79
+
80
+ /**
81
+ * LC-266 (FZ-8939): at regular width the sheet is the native panel's twin, a
82
+ * bottom panel `sheetPanelWidth` wide and centred with the scrim on both
83
+ * sides. Its side edges stay, so it reads as a panel rather than a band.
84
+ */
85
+ function regularPanel(windowWidth: number) {
86
+ return {
87
+ width: sheetPanelWidth(windowWidth),
88
+ alignSelf: "center",
89
+ borderBottomWidth: 0,
90
+ } as const;
91
+ }
92
+
93
+ /**
94
+ * The sheet's frame sits in a window-wide band that tamagui's pan responder
95
+ * owns, so a press on the band beside a regular-width panel never reaches the
96
+ * scrim. A press that lands on the band itself, not inside the panel or the
97
+ * grabber, is a press outside the sheet, and dismisses it (LC-266).
98
+ */
99
+ function useBandDismiss(
100
+ enabled: boolean,
101
+ frameRef: { current: HTMLElement | null },
102
+ onDismiss: () => void,
103
+ ) {
104
+ const onDismissRef = useRef(onDismiss);
105
+ onDismissRef.current = onDismiss;
106
+ useEffect(() => {
107
+ if (!isWeb || !enabled) return;
108
+ const band = frameRef.current?.parentElement;
109
+ if (!band) return;
110
+ const onClick = (event: MouseEvent) => {
111
+ if (event.target === band) onDismissRef.current();
112
+ };
113
+ band.addEventListener("click", onClick);
114
+ return () => band.removeEventListener("click", onClick);
115
+ });
116
+ }
117
+
72
118
  /** LC-71: the sheet frame is not a control. Auto-focus must not paint a ring. */
73
119
  const frameRingReset = {
74
120
  outlineWidth: 0,
@@ -112,7 +158,8 @@ export function SheetModal({
112
158
  />
113
159
  ) : null;
114
160
  const snap = resolveWebSnap({ snapPoint, fill });
115
- const { height: windowHeight } = useWindowDimensions();
161
+ const { height: windowHeight, width: windowWidth } = useWindowDimensions();
162
+ const regular = overlaySizeClass(windowWidth) === "regular";
116
163
  const { knobProps } = useResolvedKnobs();
117
164
  const sheetZIndex = useOverlayZIndex("sheet");
118
165
  const transition = knobProps.motion.slide.transition;
@@ -120,6 +167,7 @@ export function SheetModal({
120
167
 
121
168
  useSheetFrameFocus(open, frameRef);
122
169
  useSheetEscapeDismiss(open, () => onOpenChange(false));
170
+ useBandDismiss(open && regular, frameRef, () => onOpenChange(false));
123
171
 
124
172
  return (
125
173
  <Sheet
@@ -147,6 +195,7 @@ export function SheetModal({
147
195
  role: "dialog",
148
196
  "aria-modal": true,
149
197
  "aria-labelledby": headerContent ? titleId : undefined,
198
+ "data-sheet-form": regular ? "panel" : "bottom",
150
199
  } as Record<string, unknown>)}
151
200
  {...knobProps.gap}
152
201
  {...(snap.snapPointsMode === "fit"
@@ -154,9 +203,7 @@ export function SheetModal({
154
203
  : null)}
155
204
  borderBottomLeftRadius={0}
156
205
  borderBottomRightRadius={0}
157
- borderLeftWidth={0}
158
- borderRightWidth={0}
159
- borderBottomWidth={0}
206
+ {...(regular ? regularPanel(windowWidth) : bottomEdges)}
160
207
  overflow="hidden"
161
208
  {...frameRingReset}
162
209
  >
@@ -71,6 +71,19 @@ export const FontWeightFlip: Story = {
71
71
  ),
72
72
  };
73
73
 
74
+ export const InterFaces: Story = {
75
+ name: "Inter faces (400 to 800)",
76
+ render: () => (
77
+ <YStack gap="$2" maxWidth={520}>
78
+ {(["400", "500", "600", "700", "800"] as const).map((weight) => (
79
+ <SizableText key={weight} size="$6" fontWeight={weight}>
80
+ {weight} Hamburgefonstiv 0123
81
+ </SizableText>
82
+ ))}
83
+ </YStack>
84
+ ),
85
+ };
86
+
74
87
  const digitPresets = {
75
88
  Default: defaultPreset,
76
89
  Bold: boldPreset,
@@ -10,8 +10,9 @@ import {
10
10
  Preset,
11
11
  createDefaultThemeConfig,
12
12
  normalizeToHex,
13
- useAccentOnSurface,
13
+ useActiveTabInk,
14
14
  useGlyphColor,
15
+ useIdentityInkOnSurface,
15
16
  } from "@multiplatform.one/theme";
16
17
  import { act, cleanup, fireEvent, renderHook, screen } from "@testing-library/react";
17
18
  import fs from "node:fs";
@@ -67,9 +68,10 @@ function probeItems(seen: Record<string, GlyphProps>): TabBarItem[] {
67
68
  ];
68
69
  }
69
70
 
70
- const expected: { accent?: string; secondary?: string; primary?: string } = {};
71
+ const expected: { accent?: string; secondary?: string; primary?: string; identity?: string } = {};
71
72
  function InkProbe() {
72
- expected.accent = useAccentOnSurface();
73
+ expected.accent = useActiveTabInk();
74
+ expected.identity = useIdentityInkOnSurface();
73
75
  expected.secondary = useGlyphColor(tabBarInactiveInk);
74
76
  expected.primary = useGlyphColor("$color");
75
77
  return null;
@@ -152,9 +154,13 @@ describe("TabBar (LC-150 TAB-BAR-ANATOMY)", () => {
152
154
  });
153
155
  }
154
156
 
155
- it(`${scheme}, no hue: the active tab is the preset's accent ink, inactive the secondary ink`, () => {
157
+ it(`${scheme}, no hue: the active tab is the neutral accent's ink, bold mono, never grey`, () => {
156
158
  const seen = renderBar(scheme, "/");
157
159
  expect(expected.accent).toBeTruthy();
160
+ // The house accent is the neutral inverse: the active tab is the same
161
+ // ink the system bar draws, never the grey accent-ramp step (Round 54).
162
+ expect(expected.accent).toBe(expected.identity);
163
+ expect(expected.accent).not.toBe(expected.secondary);
158
164
  expect(seen["/"]).toMatchObject({ color: expected.accent, weight: "fill" });
159
165
  expect(seen["/moves"]).toMatchObject({ color: expected.secondary, weight: "regular" });
160
166
  });
@@ -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 on an accent-wash pill, inactive items are the secondary text
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 = useAccentOnSurface();
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
+ });