@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.
Files changed (150) hide show
  1. package/dist/cjs/Alert/index.cjs +1 -1
  2. package/dist/cjs/Alert/index.native.js +1 -1
  3. package/dist/cjs/Alert/index.native.js.map +1 -1
  4. package/dist/cjs/Carousel/index.cjs +11 -9
  5. package/dist/cjs/Carousel/index.native.js +12 -10
  6. package/dist/cjs/Carousel/index.native.js.map +1 -1
  7. package/dist/cjs/Chip/index.cjs +2 -1
  8. package/dist/cjs/Chip/index.native.js +2 -1
  9. package/dist/cjs/Chip/index.native.js.map +1 -1
  10. package/dist/cjs/Meter/meter.cjs +1 -1
  11. package/dist/cjs/Meter/meter.native.js +1 -1
  12. package/dist/cjs/Meter/meter.native.js.map +1 -1
  13. package/dist/cjs/SheetModal/index.cjs +33 -5
  14. package/dist/cjs/appShell/TabBar.cjs +1 -1
  15. package/dist/cjs/appShell/TabBar.native.js +1 -1
  16. package/dist/cjs/appShell/TabBar.native.js.map +1 -1
  17. package/dist/cjs/appShell/TabHost.native.js +52 -5
  18. package/dist/cjs/appShell/TabHost.native.js.map +1 -1
  19. package/dist/cjs/layouts/PageTemplates.cjs +15 -12
  20. package/dist/cjs/layouts/PageTemplates.native.js +16 -13
  21. package/dist/cjs/layouts/PageTemplates.native.js.map +1 -1
  22. package/dist/cjs/layouts/bottomAccessory.cjs +25 -13
  23. package/dist/cjs/layouts/bottomAccessory.native.js +29 -13
  24. package/dist/cjs/layouts/bottomAccessory.native.js.map +1 -1
  25. package/dist/cjs/layouts/page.cjs +44 -6
  26. package/dist/cjs/layouts/page.native.js +48 -6
  27. package/dist/cjs/layouts/page.native.js.map +1 -1
  28. package/dist/cjs/layouts/tags.cjs +25 -12
  29. package/dist/cjs/layouts/tags.native.js +25 -16
  30. package/dist/cjs/layouts/tags.native.js.map +1 -1
  31. package/dist/esm/Alert/index.mjs +1 -1
  32. package/dist/esm/Alert/index.mjs.map +1 -1
  33. package/dist/esm/Alert/index.native.js +1 -1
  34. package/dist/esm/Alert/index.native.js.map +1 -1
  35. package/dist/esm/Carousel/index.mjs +12 -10
  36. package/dist/esm/Carousel/index.mjs.map +1 -1
  37. package/dist/esm/Carousel/index.native.js +13 -11
  38. package/dist/esm/Carousel/index.native.js.map +1 -1
  39. package/dist/esm/Chip/index.mjs +3 -2
  40. package/dist/esm/Chip/index.mjs.map +1 -1
  41. package/dist/esm/Chip/index.native.js +3 -2
  42. package/dist/esm/Chip/index.native.js.map +1 -1
  43. package/dist/esm/Meter/meter.mjs +1 -1
  44. package/dist/esm/Meter/meter.mjs.map +1 -1
  45. package/dist/esm/Meter/meter.native.js +1 -1
  46. package/dist/esm/Meter/meter.native.js.map +1 -1
  47. package/dist/esm/SheetModal/index.mjs +34 -6
  48. package/dist/esm/SheetModal/index.mjs.map +1 -1
  49. package/dist/esm/appShell/TabBar.mjs +2 -2
  50. package/dist/esm/appShell/TabBar.mjs.map +1 -1
  51. package/dist/esm/appShell/TabBar.native.js +2 -2
  52. package/dist/esm/appShell/TabBar.native.js.map +1 -1
  53. package/dist/esm/appShell/TabHost.native.js +53 -9
  54. package/dist/esm/appShell/TabHost.native.js.map +1 -1
  55. package/dist/esm/layouts/PageTemplates.mjs +16 -13
  56. package/dist/esm/layouts/PageTemplates.mjs.map +1 -1
  57. package/dist/esm/layouts/PageTemplates.native.js +17 -14
  58. package/dist/esm/layouts/PageTemplates.native.js.map +1 -1
  59. package/dist/esm/layouts/bottomAccessory.mjs +26 -15
  60. package/dist/esm/layouts/bottomAccessory.mjs.map +1 -1
  61. package/dist/esm/layouts/bottomAccessory.native.js +30 -15
  62. package/dist/esm/layouts/bottomAccessory.native.js.map +1 -1
  63. package/dist/esm/layouts/page.mjs +44 -8
  64. package/dist/esm/layouts/page.mjs.map +1 -1
  65. package/dist/esm/layouts/page.native.js +48 -8
  66. package/dist/esm/layouts/page.native.js.map +1 -1
  67. package/dist/esm/layouts/tags.mjs +28 -15
  68. package/dist/esm/layouts/tags.mjs.map +1 -1
  69. package/dist/esm/layouts/tags.native.js +28 -19
  70. package/dist/esm/layouts/tags.native.js.map +1 -1
  71. package/dist/jsx/Alert/index.mjs +1 -1
  72. package/dist/jsx/Alert/index.mjs.map +1 -1
  73. package/dist/jsx/Alert/index.native.js +1 -1
  74. package/dist/jsx/Alert/index.native.js.map +1 -1
  75. package/dist/jsx/Carousel/index.mjs +12 -10
  76. package/dist/jsx/Carousel/index.mjs.map +1 -1
  77. package/dist/jsx/Carousel/index.native.js +12 -10
  78. package/dist/jsx/Carousel/index.native.js.map +1 -1
  79. package/dist/jsx/Chip/index.mjs +3 -2
  80. package/dist/jsx/Chip/index.mjs.map +1 -1
  81. package/dist/jsx/Chip/index.native.js +2 -1
  82. package/dist/jsx/Chip/index.native.js.map +1 -1
  83. package/dist/jsx/Meter/meter.mjs +1 -1
  84. package/dist/jsx/Meter/meter.mjs.map +1 -1
  85. package/dist/jsx/Meter/meter.native.js +1 -1
  86. package/dist/jsx/Meter/meter.native.js.map +1 -1
  87. package/dist/jsx/SheetModal/index.mjs +34 -6
  88. package/dist/jsx/SheetModal/index.mjs.map +1 -1
  89. package/dist/jsx/appShell/TabBar.mjs +2 -2
  90. package/dist/jsx/appShell/TabBar.mjs.map +1 -1
  91. package/dist/jsx/appShell/TabBar.native.js +1 -1
  92. package/dist/jsx/appShell/TabBar.native.js.map +1 -1
  93. package/dist/jsx/appShell/TabHost.native.js +52 -5
  94. package/dist/jsx/appShell/TabHost.native.js.map +1 -1
  95. package/dist/jsx/layouts/PageTemplates.mjs +16 -13
  96. package/dist/jsx/layouts/PageTemplates.mjs.map +1 -1
  97. package/dist/jsx/layouts/PageTemplates.native.js +16 -13
  98. package/dist/jsx/layouts/PageTemplates.native.js.map +1 -1
  99. package/dist/jsx/layouts/bottomAccessory.mjs +26 -15
  100. package/dist/jsx/layouts/bottomAccessory.mjs.map +1 -1
  101. package/dist/jsx/layouts/bottomAccessory.native.js +29 -13
  102. package/dist/jsx/layouts/bottomAccessory.native.js.map +1 -1
  103. package/dist/jsx/layouts/page.mjs +44 -8
  104. package/dist/jsx/layouts/page.mjs.map +1 -1
  105. package/dist/jsx/layouts/page.native.js +48 -6
  106. package/dist/jsx/layouts/page.native.js.map +1 -1
  107. package/dist/jsx/layouts/tags.mjs +28 -15
  108. package/dist/jsx/layouts/tags.mjs.map +1 -1
  109. package/dist/jsx/layouts/tags.native.js +25 -16
  110. package/dist/jsx/layouts/tags.native.js.map +1 -1
  111. package/package.json +9 -9
  112. package/src/Alert/Alert.spec.tsx +82 -1
  113. package/src/Alert/index.tsx +1 -1
  114. package/src/Carousel/Carousel.native.spec.tsx +25 -8
  115. package/src/Carousel/index.tsx +18 -11
  116. package/src/Chip/Chip.spec.tsx +33 -0
  117. package/src/Chip/index.tsx +10 -5
  118. package/src/Meter/Meter.spec.tsx +5 -4
  119. package/src/Meter/meter.ts +6 -4
  120. package/src/SheetModal/SheetModal.regularWidth.spec.tsx +87 -0
  121. package/src/SheetModal/index.tsx +52 -5
  122. package/src/Text.stories.tsx +13 -0
  123. package/src/appShell/TabBar.spec.tsx +10 -4
  124. package/src/appShell/TabBar.tsx +4 -8
  125. package/src/appShell/TabHost.native.spec.tsx +109 -1
  126. package/src/appShell/TabHost.native.tsx +84 -13
  127. package/src/hoverTouchPath.coarse.spec.tsx +166 -0
  128. package/src/icons/themedButtonInk.spec.tsx +117 -0
  129. package/src/layouts/PageTemplates.tsx +14 -11
  130. package/src/layouts/Screen.chromeClearance.spec.tsx +28 -0
  131. package/src/layouts/bottomAccessory.tsx +40 -15
  132. package/src/layouts/page.tsx +73 -19
  133. package/src/layouts/pageTitleOneLine.spec.tsx +42 -0
  134. package/src/layouts/pushedPageTitle.spec.tsx +116 -0
  135. package/src/layouts/tags-token-field.spec.tsx +21 -5
  136. package/src/layouts/tags.tsx +36 -20
  137. package/types/Carousel/index.d.ts.map +1 -1
  138. package/types/Chip/index.d.ts.map +1 -1
  139. package/types/Meter/meter.d.ts +6 -4
  140. package/types/Meter/meter.d.ts.map +1 -1
  141. package/types/SheetModal/index.d.ts.map +1 -1
  142. package/types/appShell/TabBar.d.ts.map +1 -1
  143. package/types/appShell/TabHost.native.d.ts +13 -1
  144. package/types/appShell/TabHost.native.d.ts.map +1 -1
  145. package/types/layouts/PageTemplates.d.ts.map +1 -1
  146. package/types/layouts/bottomAccessory.d.ts +6 -1
  147. package/types/layouts/bottomAccessory.d.ts.map +1 -1
  148. package/types/layouts/page.d.ts +16 -1
  149. package/types/layouts/page.d.ts.map +1 -1
  150. package/types/layouts/tags.d.ts.map +1 -1
@@ -7,6 +7,7 @@ import { renderWithProviders } from "@multiplatform.one/test-utils";
7
7
  import { act, cleanup, screen } from "@testing-library/react";
8
8
  import { afterEach, expect, it } from "vitest";
9
9
  import { setBottomChromeInset } from "../appShell/bottomChrome";
10
+ import { BottomAccessory } from "./bottomAccessory";
10
11
  import { Screen } from "./page";
11
12
 
12
13
  const bar = Symbol("bar");
@@ -39,3 +40,30 @@ it("clears the bar on a screen that does not scroll", () => {
39
40
  renderWithProviders(<Screen scroll={false}>row</Screen>);
40
41
  expect(screen.getByTestId("screen-chrome-clearance")).toBeTruthy();
41
42
  });
43
+
44
+ it("hands the clearance to a slot accessory that stands on a bar it does not ride (FZ-7995)", () => {
45
+ act(() => setBottomChromeInset(bar, 83));
46
+ renderWithProviders(
47
+ <Screen>
48
+ row
49
+ <BottomAccessory>
50
+ <span data-testid="pager">pager</span>
51
+ </BottomAccessory>
52
+ </Screen>,
53
+ );
54
+ expect(screen.queryByTestId("screen-chrome-clearance")).toBeNull();
55
+ const lift = screen.getByTestId("bottom-accessory-lift");
56
+ expect(lift.contains(screen.getByTestId("pager"))).toBe(true);
57
+ expect(getComputedStyle(lift).paddingBottom === "83px" || /83px/.test(lift.outerHTML)).toBe(true);
58
+ });
59
+
60
+ it("grows its scrolling column to its content, so the clearance ends the content, not the viewport", () => {
61
+ act(() => setBottomChromeInset(bar, 77));
62
+ renderWithProviders(<Screen>row</Screen>);
63
+ const column = screen.getByTestId("screen");
64
+ const style = getComputedStyle(column);
65
+ const html = column.outerHTML;
66
+ expect(style.flexShrink === "0" || /_fs-0\b|flex-shrink: 0/.test(html)).toBe(true);
67
+ expect(style.flexBasis === "0px" || /_fb-0px\b|flex-basis: 0px/.test(html)).toBe(false);
68
+ expect(column.nextElementSibling).toBe(screen.getByTestId("screen-chrome-clearance"));
69
+ });
@@ -4,7 +4,10 @@
4
4
  * UIPageControl under a paged list), not inline after the content.
5
5
  *
6
6
  * Under a floating TabBar the accessory moves into the bar, which floats it
7
- * as its own capsule above the tab capsule (LC-150). Otherwise a page shell
7
+ * as its own capsule above the tab capsule (LC-150), and under the system tab
8
+ * bar on iOS 26 into its bottom accessory (FZ-7995). A tab bar that hosts no
9
+ * accessory (the system bar before iOS 26) still covers the bottom of the
10
+ * screen, so the slot's bar stands on it instead of on the home indicator. Otherwise a page shell
8
11
  * (`Screen`) owns one bottom-accessory slot. Any component
9
12
  * hands it a control with `<BottomAccessory>`; the shell draws it in a bar
10
13
  * under its scroll view that stays put while the content scrolls, so the
@@ -30,7 +33,11 @@ import {
30
33
  type ReactNode,
31
34
  } from "react";
32
35
  import { YStack, isWeb } from "tamagui";
33
- import { setBottomAccessory, useFloatingBarMounted } from "../appShell/bottomChrome";
36
+ import {
37
+ setBottomAccessory,
38
+ useBottomChromeInset,
39
+ useFloatingBarMounted,
40
+ } from "../appShell/bottomChrome";
34
41
  import { componentColors } from "../componentColors";
35
42
  import { BottomSafeArea } from "./bottomSafeArea";
36
43
 
@@ -140,6 +147,15 @@ export function BottomAccessory({ children }: BottomAccessoryProps) {
140
147
  }
141
148
 
142
149
  /** A page shell's slot: the bar the registered accessories render in. */
150
+ /** True while a page's slot holds an accessory. */
151
+ export function useBottomAccessoryOccupied(store: BottomAccessoryStore): boolean {
152
+ return useSyncExternalStore(
153
+ store.subscribe,
154
+ () => store.getSnapshot().length > 0,
155
+ () => false,
156
+ );
157
+ }
158
+
143
159
  export function BottomAccessoryHost({
144
160
  store,
145
161
  inset,
@@ -161,8 +177,8 @@ export function BottomAccessoryHost({
161
177
  }
162
178
 
163
179
  /**
164
- * The bar: the page's fill under a top hairline, and the home indicator's
165
- * inset below the controls.
180
+ * The bar: the page's fill under a top hairline, and below the controls the
181
+ * home indicator's inset, or in a page's slot the height a tab bar covers.
166
182
  */
167
183
  function BottomAccessoryBar({
168
184
  children,
@@ -176,6 +192,20 @@ function BottomAccessoryBar({
176
192
  hosted?: boolean;
177
193
  }) {
178
194
  const { knobProps } = useResolvedKnobs({ component: "ScreenToolbar" });
195
+ const chromeInset = useBottomChromeInset();
196
+ const lift = hosted && chromeInset > 0;
197
+ const controls = (
198
+ <YStack
199
+ width="100%"
200
+ {...(maxWidth ? { maxWidth, alignSelf: "center" as const } : null)}
201
+ alignItems="center"
202
+ paddingHorizontal={(inset ?? knobProps.panelPadding.padding) as "$4"}
203
+ paddingVertical={knobProps.gap.gap as "$2"}
204
+ {...knobProps.gap}
205
+ >
206
+ {children}
207
+ </YStack>
208
+ );
179
209
  return (
180
210
  <Surface bar>
181
211
  <YStack
@@ -188,18 +218,13 @@ function BottomAccessoryBar({
188
218
  borderColor={componentColors.divider as never}
189
219
  style={webSticky as never}
190
220
  >
191
- <BottomSafeArea>
192
- <YStack
193
- width="100%"
194
- {...(maxWidth ? { maxWidth, alignSelf: "center" as const } : null)}
195
- alignItems="center"
196
- paddingHorizontal={(inset ?? knobProps.panelPadding.padding) as "$4"}
197
- paddingVertical={knobProps.gap.gap as "$2"}
198
- {...knobProps.gap}
199
- >
200
- {children}
221
+ {lift ? (
222
+ <YStack width="100%" paddingBottom={chromeInset} data-testid="bottom-accessory-lift">
223
+ {controls}
201
224
  </YStack>
202
- </BottomSafeArea>
225
+ ) : (
226
+ <BottomSafeArea>{controls}</BottomSafeArea>
227
+ )}
203
228
  </YStack>
204
229
  </Surface>
205
230
  );
@@ -40,7 +40,7 @@ import {
40
40
  type ButtonProps,
41
41
  } from "@multiplatform.one/forms";
42
42
  import { CaretLeftIcon, CaretRightIcon, DotsThreeIcon } from "@phosphor-icons/react";
43
- import { useState, type ReactNode } from "react";
43
+ import { createContext, useContext, useState, type ReactNode } from "react";
44
44
  import { barTitleSpan, sideExtents } from "./barTitleSpan";
45
45
  import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
46
46
  import type { ScrollViewProps as TamaguiScrollViewProps, XStackProps, YStackProps } from "tamagui";
@@ -66,6 +66,7 @@ import { ScrollView } from "../ScrollView";
66
66
  import { KeyboardAvoidingWrapper } from "./KeyboardAvoidingWrapper";
67
67
  import {
68
68
  BottomAccessoryHost,
69
+ useBottomAccessoryOccupied,
69
70
  BottomAccessoryScope,
70
71
  createBottomAccessoryStore,
71
72
  } from "./bottomAccessory";
@@ -293,7 +294,10 @@ function PageShell({
293
294
  <YStack
294
295
  ref={scrollState.contentRoot as never}
295
296
  data-testid="screen"
296
- flex={1}
297
+ // In a scroll view the column grows to its content (basis auto, no
298
+ // shrink): with flex 1 it was held to the viewport on web, its rows
299
+ // overflowed it, and the chrome clearance after it landed mid-list.
300
+ {...(scroll ? { flexGrow: 1, flexShrink: 0, flexBasis: "auto" as const } : { flex: 1 })}
297
301
  width="100%"
298
302
  {...(maxWidth && !bleed ? { maxWidth, alignSelf: "center" } : null)}
299
303
  {...(noPadding ? null : knobProps.panelPadding)}
@@ -334,8 +338,12 @@ function PageShell({
334
338
  };
335
339
 
336
340
  // The floating tab bar covers the bottom of the screen; content scrolls
337
- // under it, and this clears its last row.
338
- const chromeClearance = <BottomChromeClearance testID="screen-chrome-clearance" />;
341
+ // under it, and this clears its last row. A slot accessory stands on the bar
342
+ // and ends the scroll view above it, so it takes the clearance instead.
343
+ const accessoryOccupied = useBottomAccessoryOccupied(accessories);
344
+ const chromeClearance = accessoryOccupied ? null : (
345
+ <BottomChromeClearance testID="screen-chrome-clearance" />
346
+ );
339
347
 
340
348
  if (!scroll) {
341
349
  return shell(
@@ -416,6 +424,32 @@ export interface PageHeaderProps extends YStackProps {
416
424
  titleScale?: PageTitleScale;
417
425
  }
418
426
 
427
+ const PushedPageContext = createContext<"inline" | "hero" | null>(null);
428
+
429
+ /**
430
+ * LC-270 PUSHED-PAGE-INLINE-TITLE: marks a pushed page, a detail or form page
431
+ * reached through a back control. At compact width its title rides the top
432
+ * bar, centred between back and the actions, and its `PageHeader` draws no
433
+ * large title. Root pages keep LC-157's large title. `hero` is the App Store
434
+ * and Music variant: the page's own title (an identity block's name) stays in
435
+ * the content, the bar shows nothing at rest, and the same title fades into
436
+ * the bar once that name scrolls out of view (LC-157's measured collapse).
437
+ */
438
+ export function PushedPage({ hero = false, children }: { hero?: boolean; children: ReactNode }) {
439
+ return (
440
+ <PushedPageContext.Provider value={hero ? "hero" : "inline"}>
441
+ {children}
442
+ </PushedPageContext.Provider>
443
+ );
444
+ }
445
+
446
+ /** True when the page's title belongs in its top bar from rest (LC-270). */
447
+ export function useInlinePageTitle(): boolean {
448
+ const pushed = useContext(PushedPageContext);
449
+ const compact = useLayoutSizeClass() === "compact";
450
+ return pushed === "inline" && compact;
451
+ }
452
+
419
453
  /**
420
454
  * Standard page header (Polaris Page / Primer PageHeader / Linear).
421
455
  *
@@ -452,24 +486,32 @@ export function PageHeader({
452
486
  // LC-157: the page title is the Screen's large title; its top bar shows it
453
487
  // once it scrolls away.
454
488
  const largeTitle = useLargeTitle(title, headingLevel === 1 && !heading);
489
+ // LC-270: a pushed page's title is in its top bar at compact width.
490
+ const inlineTitle = useInlinePageTitle() && headingLevel === 1;
491
+ // LC-143: a phone's page title takes the width its actions leave, on one
492
+ // line, so wide actions truncate it instead of breaking it into letters.
493
+ const compact = useLayoutSizeClass() === "compact";
455
494
  // D-11: the page-title step comes from the knob (`moderate` by default), so
456
495
  // one dial re-scales every composed screen; `titleScale` is the explicit
457
496
  // per-instance eject and resolves through the same table. The semantic tag
458
497
  // still follows headingLevel — the step is emphasis, not structure.
459
498
  const titleSize = titleScale ? resolvePageTitleScale(titleScale) : knobProps.pageTitle;
460
- const titleNode =
461
- heading ??
462
- (title ? (
463
- <TitleTag
464
- margin={0}
465
- {...largeTitle}
466
- {...titleSize}
467
- {...knobProps.heading}
468
- {...(knobProps.heading.fontWeight ? { "data-font-weight-knob": "bold" } : null)}
469
- >
470
- {title}
471
- </TitleTag>
472
- ) : null);
499
+ const titleNode = inlineTitle
500
+ ? null
501
+ : (heading ??
502
+ (title ? (
503
+ <TitleTag
504
+ margin={0}
505
+ {...(compact ? { numberOfLines: 1 } : null)}
506
+ {...largeTitle}
507
+ {...titleSize}
508
+ {...knobProps.heading}
509
+ {...(knobProps.heading.fontWeight ? { "data-font-weight-knob": "bold" } : null)}
510
+ >
511
+ {title}
512
+ </TitleTag>
513
+ ) : null));
514
+ if (inlineTitle && !subtitle && !status && !actions && !nav && !children) return null;
473
515
 
474
516
  // When nav is present the strip's active indicator sits ON this rule, so
475
517
  // there is one hairline — not a second competing line. The header then
@@ -688,6 +730,9 @@ function useBarTitleSpan() {
688
730
  };
689
731
  }
690
732
 
733
+ // LC-270: the pushed page's bar title is the page's heading.
734
+ const pageTitleRole = { role: "heading", "aria-level": 1 } as const;
735
+
691
736
  function BarTitle({
692
737
  title,
693
738
  span,
@@ -766,6 +811,7 @@ function InlineToolbar({ leading, trailing, title, children, ...rest }: ScreenTo
766
811
  const bleed = insetToken ? { marginHorizontal: negateSpaceToken(insetToken) as "$4" } : null;
767
812
  // LC-145: a phone top bar is one row; the leading group shrinks first.
768
813
  const oneRow = useLayoutSizeClass() === "compact";
814
+ const pushedTitle = useInlinePageTitle();
769
815
  // LC-153: the toolbar is a bar, so its neutral buttons paint plain.
770
816
  return (
771
817
  <Surface bar>
@@ -804,7 +850,12 @@ function InlineToolbar({ leading, trailing, title, children, ...rest }: ScreenTo
804
850
  {trailing}
805
851
  </IconButtonGroup>
806
852
  {title ? (
807
- <BarTitle title={title} span={titleSpan.span} onTitleWidth={titleSpan.measureTitle} />
853
+ <BarTitle
854
+ title={title}
855
+ span={titleSpan.span}
856
+ onTitleWidth={titleSpan.measureTitle}
857
+ {...(pushedTitle ? pageTitleRole : null)}
858
+ />
808
859
  ) : null}
809
860
  </XStack>
810
861
  </Surface>
@@ -834,6 +885,8 @@ function PinnedTopBar({
834
885
  const titleSpan = useBarTitleSpan();
835
886
  const motion = transitionProps(knobProps.motion.control);
836
887
  const title = inlineTitle ?? largeTitle;
888
+ // LC-270: a pushed page shows its title in the bar from rest, as its heading.
889
+ const pushedTitle = useInlinePageTitle();
837
890
  // LC-153: the pinned bar is a bar, so its neutral buttons paint plain.
838
891
  return (
839
892
  <Surface bar>
@@ -888,7 +941,8 @@ function PinnedTopBar({
888
941
  title={title}
889
942
  span={titleSpan.span}
890
943
  onTitleWidth={titleSpan.measureTitle}
891
- shown={Boolean(inlineTitle) || collapsed}
944
+ shown={Boolean(inlineTitle) || collapsed || pushedTitle}
945
+ {...(pushedTitle ? pageTitleRole : null)}
892
946
  {...motion}
893
947
  />
894
948
  ) : null}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * LC-143: on a phone a page title takes the width its actions leave, on one
3
+ * line with an ellipsis. Wide actions beside it (a view switcher, "…", Add)
4
+ * once squeezed the title column until it broke one letter per line.
5
+ */
6
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
7
+ import { cleanup, screen } from "@testing-library/react";
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ import { PageHeader } from "./page";
10
+
11
+ const layout = vi.hoisted(() => ({ size: "wide" }));
12
+ vi.mock("@multiplatform.one/theme", async (original) => {
13
+ const actual = await original<typeof import("@multiplatform.one/theme")>();
14
+ return { ...actual, useLayoutSizeClass: () => layout.size };
15
+ });
16
+
17
+ afterEach(() => {
18
+ layout.size = "wide";
19
+ cleanup();
20
+ });
21
+
22
+ function header() {
23
+ return (
24
+ <PageHeader title="Pokemon" actions={<div style={{ width: 320 }}>List · Report · … · +</div>} />
25
+ );
26
+ }
27
+
28
+ describe("page title width (LC-143)", () => {
29
+ it("keeps a phone's page title on one line and truncates it beside wide actions", () => {
30
+ layout.size = "compact";
31
+ renderWithProviders(header());
32
+ const style = getComputedStyle(screen.getByRole("heading", { name: "Pokemon" }));
33
+ expect(style.textOverflow).toBe("ellipsis");
34
+ expect(style.whiteSpace).toBe("nowrap");
35
+ });
36
+
37
+ it("lets a wide screen's page title wrap", () => {
38
+ renderWithProviders(header());
39
+ const style = getComputedStyle(screen.getByRole("heading", { name: "Pokemon" }));
40
+ expect(style.textOverflow).not.toBe("ellipsis");
41
+ });
42
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * LC-270 PUSHED-PAGE-INLINE-TITLE: on a phone a pushed page (a detail or form
3
+ * page under a back control) shows its title centred in its top bar from rest,
4
+ * with no large title under the bar. Root pages keep LC-157's large title,
5
+ * and a pushed page keeps it at regular width.
6
+ */
7
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
8
+ import { act, cleanup, fireEvent, screen } from "@testing-library/react";
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+ import { PageBackButton, PageHeader, PushedPage, Screen, ScreenToolbar } from "./page";
11
+ import { DetailPageLayout } from "./PageTemplates";
12
+
13
+ const layout = vi.hoisted(() => ({ size: "wide" }));
14
+ vi.mock("@multiplatform.one/theme", async (original) => {
15
+ const actual = await original<typeof import("@multiplatform.one/theme")>();
16
+ return { ...actual, useLayoutSizeClass: () => layout.size };
17
+ });
18
+
19
+ const TITLE_BOTTOM = 120;
20
+
21
+ beforeEach(() => {
22
+ vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
23
+ const bottom = this.tagName === "H1" ? TITLE_BOTTOM : 0;
24
+ return { top: 0, bottom, left: 0, right: 0, width: 0, height: bottom, x: 0, y: 0 } as DOMRect;
25
+ });
26
+ });
27
+
28
+ afterEach(() => {
29
+ layout.size = "wide";
30
+ vi.restoreAllMocks();
31
+ cleanup();
32
+ });
33
+
34
+ function detail() {
35
+ return (
36
+ <DetailPageLayout
37
+ toolbarLeading={<PageBackButton label="Pokemon" onPress={vi.fn()} />}
38
+ header={<PageHeader title="caterpie" divider={false} />}
39
+ >
40
+ <div>fields</div>
41
+ </DetailPageLayout>
42
+ );
43
+ }
44
+
45
+ function barTitle(): HTMLElement | null {
46
+ return document.querySelector("[data-top-bar] [data-bar-title]");
47
+ }
48
+
49
+ describe("pushed page title (LC-270)", () => {
50
+ it("puts a pushed page's title in its phone bar at rest, as the page heading", () => {
51
+ layout.size = "compact";
52
+ renderWithProviders(detail());
53
+ expect(document.querySelector("h1")).toBeNull();
54
+ const title = barTitle();
55
+ expect(title?.textContent).toBe("caterpie");
56
+ expect(title?.getAttribute("aria-hidden")).not.toBe("true");
57
+ expect(title?.getAttribute("role")).toBe("heading");
58
+ expect(screen.getByRole("heading", { name: "caterpie" })).toBe(title);
59
+ });
60
+
61
+ it("keeps the large title under the bar on a wide screen", () => {
62
+ renderWithProviders(detail());
63
+ expect(screen.getByRole("heading", { name: "caterpie" }).tagName).toBe("H1");
64
+ expect(barTitle()?.getAttribute("aria-hidden")).toBe("true");
65
+ });
66
+
67
+ it("keeps a root page's large title on a phone", () => {
68
+ layout.size = "compact";
69
+ renderWithProviders(
70
+ <Screen topBar={<ScreenToolbar trailing={<span>New</span>} />}>
71
+ <PageHeader title="Pokemon" />
72
+ </Screen>,
73
+ );
74
+ expect(screen.getByRole("heading", { name: "Pokemon" }).tagName).toBe("H1");
75
+ });
76
+
77
+ it("keeps a pushed page's status under the bar when the title moves up", () => {
78
+ layout.size = "compact";
79
+ renderWithProviders(
80
+ <DetailPageLayout
81
+ toolbarLeading={<PageBackButton label="Pokemon" onPress={vi.fn()} />}
82
+ header={<PageHeader title="caterpie" status={<span>Draft</span>} divider={false} />}
83
+ >
84
+ <div>fields</div>
85
+ </DetailPageLayout>,
86
+ );
87
+ expect(screen.getByText("Draft")).toBeTruthy();
88
+ expect(document.querySelector("h1")).toBeNull();
89
+ });
90
+
91
+ it("keeps a hero page's name in the content at rest and fades it into the bar once scrolled away", async () => {
92
+ layout.size = "compact";
93
+ renderWithProviders(
94
+ <PushedPage hero>
95
+ <Screen
96
+ scrollViewProps={{ testID: "page-scroll" } as never}
97
+ topBar={<ScreenToolbar leading={<PageBackButton label="Pokemon" onPress={vi.fn()} />} />}
98
+ >
99
+ <div>art stage</div>
100
+ <PageHeader title="caterpie" divider={false} />
101
+ <div style={{ height: 2000 }} />
102
+ </Screen>
103
+ </PushedPage>,
104
+ );
105
+ expect(screen.getByRole("heading", { name: "caterpie" }).tagName).toBe("H1");
106
+ expect(barTitle()?.getAttribute("aria-hidden")).toBe("true");
107
+ await act(async () => {
108
+ fireEvent.scroll(screen.getByTestId("page-scroll"), {
109
+ target: { scrollTop: TITLE_BOTTOM + 10 },
110
+ });
111
+ await new Promise((resolve) => setTimeout(resolve, 120));
112
+ });
113
+ expect(barTitle()?.getAttribute("aria-hidden")).not.toBe("true");
114
+ expect(barTitle()?.textContent).toBe("caterpie");
115
+ });
116
+ });
@@ -7,7 +7,7 @@ import { TamaguiProvider } from "tamagui";
7
7
  // asserts on the atomic classes (same approach as timeline-radius.spec.tsx).
8
8
  import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
9
9
  import { renderWithProviders } from "@multiplatform.one/test-utils";
10
- import { Preset, createDefaultThemeConfig } from "@multiplatform.one/theme";
10
+ import { Preset, createDefaultThemeConfig, sizeRecipeForToken } from "@multiplatform.one/theme";
11
11
  import * as React from "react";
12
12
  import { afterEach, describe, expect, it, vi } from "vitest";
13
13
  import { Tags } from "./tags";
@@ -49,9 +49,12 @@ describe("Tags token field: one way to add (FZ-8018)", () => {
49
49
  expect(addInput().getAttribute("placeholder")).toBe("Add tag...");
50
50
  });
51
51
 
52
- it("the input's font size class matches the chip label", () => {
52
+ it("the input takes the text field's font size, not the chip label's (FZ-8018 round 3)", () => {
53
53
  renderTags();
54
- expect(classesByPrefix(addInput(), ["_fos-"])).toEqual(
54
+ expect(classesByPrefix(addInput(), ["_fos-"])).toEqual([
55
+ `_fos-${sizeRecipeForToken("$4").fontSize}px`,
56
+ ]);
57
+ expect(classesByPrefix(addInput(), ["_fos-"])).not.toEqual(
55
58
  classesByPrefix(screen.getByText("urgent"), ["_fos-"]),
56
59
  );
57
60
  });
@@ -80,14 +83,27 @@ describe("Tags token field: one way to add (FZ-8018)", () => {
80
83
  },
81
84
  );
82
85
 
83
- it("the chip label keeps the $3 body line height", () => {
86
+ it("chips sit one size below the field: the label takes the $2 body line height (FZ-8018 round 3)", () => {
84
87
  renderWithProviders(
85
88
  <TamaguiProvider config={createDefaultThemeConfig().tamagui} defaultTheme="light">
86
89
  <Tags tags={tags} onAdd={vi.fn()} onRemove={vi.fn()} />
87
90
  </TamaguiProvider>,
88
91
  { tamagui: false },
89
92
  );
90
- expect(getComputedStyle(screen.getByText("urgent")).lineHeight).toBe("21px");
93
+ expect(getComputedStyle(screen.getByText("urgent")).lineHeight).toBe("20px");
94
+ });
95
+
96
+ it("with chips, the entry's text starts at a chip label's inset: edge plus padding (FZ-8018 round 3b)", () => {
97
+ renderTags();
98
+ const [chipPad] = classesByPrefix(chipFrameOf(screen.getByText("urgent")), ["_pl-"]);
99
+ const chipInset = Number.parseFloat(chipPad.slice("_pl-".length));
100
+ expect(chipInset).toBeGreaterThan(0);
101
+ expect(classesByPrefix(addInput(), ["_pl-"])).toEqual([`_pl-${chipInset + 1}px`]);
102
+ });
103
+
104
+ it("with no chips, the entry sits on the field's own inset (FZ-8018 round 3b)", () => {
105
+ renderWithProviders(<Tags tags={[]} onAdd={vi.fn()} onRemove={vi.fn()} />);
106
+ expect(classesByPrefix(addInput(), ["_pl-"]).filter((c) => c !== "_pl-0px")).toEqual([]);
91
107
  });
92
108
 
93
109
  it("Return and comma commit the typed tag", () => {
@@ -6,7 +6,9 @@ import {
6
6
  ensureKeyboardModalityTracking,
7
7
  menuRowFrame,
8
8
  sizeKeyBelow,
9
+ sizeRecipeForToken,
9
10
  sizeTokenStep,
11
+ textFieldInset,
10
12
  useGlyphColor,
11
13
  useResolvedKnobs,
12
14
  } from "@multiplatform.one/theme";
@@ -23,16 +25,21 @@ import {
23
25
  XStack,
24
26
  YStack,
25
27
  isWeb,
28
+ styled,
26
29
  type InputProps,
27
30
  type SizeTokens,
28
31
  type YStackProps,
29
32
  } from "tamagui";
30
33
  import { ScrollView } from "../ScrollView";
31
- import { Chip, chipTextSize, type ChipColor, type ChipSize, type ChipVariant } from "../Chip";
34
+ import { Chip, chipFrameGeometry, type ChipColor, type ChipSize, type ChipVariant } from "../Chip";
32
35
  import { componentColors } from "../componentColors";
33
36
  import { withInterp } from "../shared/t";
34
37
  import { Text } from "../Text";
35
38
 
39
+ // The text field's component theme, so the token field's fill and edge are the
40
+ // sibling text field's in every preset (FZ-8018 round 3).
41
+ const TagsFieldFrame = styled(XStack, { name: "InputContainerFrame" });
42
+
36
43
  // ---------------------------------------------------------------------------
37
44
  // Types (generic -- no Frappe dependency)
38
45
  // ---------------------------------------------------------------------------
@@ -163,7 +170,12 @@ export function Tags({
163
170
  ensureKeyboardModalityTracking();
164
171
  }
165
172
 
166
- const chipSize = (small ? sizeKeyBelow(knobProps.sizeToken) : knobProps.sizeToken) as ChipSize;
173
+ const chipSize = sizeKeyBelow(knobProps.sizeToken) as ChipSize;
174
+ const fieldRecipe = sizeRecipeForToken(String(knobProps.sizeToken));
175
+ const [chipHeight, setChipHeight] = useState<number | null>(null);
176
+ // The entry's text starts where a chip's label would: the chip's 1px edge
177
+ // plus its padding, so it gets the room a tag gets (FZ-8018 round 3b).
178
+ const entryInset = tags.length > 0 ? 1 + chipFrameGeometry(chipSize).paddingHorizontal : 0;
167
179
 
168
180
  const [inputValue, setInputValue] = useState("");
169
181
  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
@@ -361,6 +373,11 @@ export function Tags({
361
373
  ? (chipTheme as ChipColor)
362
374
  : "gray";
363
375
 
376
+ const onChipLayout = (e: { nativeEvent: { layout: { height: number } } }) => {
377
+ const h = e.nativeEvent.layout.height;
378
+ if (h > 0 && Math.abs((chipHeight ?? 0) - h) > 0.5) setChipHeight(h);
379
+ };
380
+
364
381
  const renderTagItem = (tag: TagItem) => {
365
382
  const actions = {
366
383
  onRemove: () => onRemove?.(tag),
@@ -385,6 +402,7 @@ export function Tags({
385
402
  key={tag.id}
386
403
  cursor="pointer"
387
404
  data-tag-chip=""
405
+ onLayout={onChipLayout}
388
406
  onPress={(e) => {
389
407
  e.stopPropagation?.();
390
408
  onTagClick(tag);
@@ -397,7 +415,9 @@ export function Tags({
397
415
  {chip}
398
416
  </XStack>
399
417
  ) : (
400
- <View data-tag-chip="">{chip}</View>
418
+ <View data-tag-chip="" onLayout={onChipLayout}>
419
+ {chip}
420
+ </View>
401
421
  );
402
422
 
403
423
  if (renderTag) {
@@ -512,27 +532,25 @@ export function Tags({
512
532
  setIsPopoverOpen(true);
513
533
  }, [canAdd]);
514
534
 
515
- const fieldPadH = small ? ("$2" as const) : ("$2.5" as const);
516
- const fieldPadV = small ? ("$1.5" as const) : ("$2" as const);
517
-
518
535
  const field = (
519
- <XStack
536
+ <TagsFieldFrame
520
537
  data-testid="tags-field"
521
538
  flexWrap="wrap"
522
539
  alignItems="center"
523
- gap="$1.5"
540
+ alignContent="center"
541
+ gap="$2"
524
542
  width="100%"
525
- minHeight={knobProps.sizeToken}
526
- paddingHorizontal={fieldPadH}
527
- paddingVertical={fieldPadV}
528
- backgroundColor={componentColors.surface.background}
543
+ minHeight={fieldRecipe.height}
544
+ paddingHorizontal={textFieldInset(fieldRecipe)}
545
+ paddingVertical="$2"
546
+ backgroundColor={knobProps.inputBackground}
529
547
  {...knobProps.inputSurface}
530
548
  {...knobProps.borderRadius}
531
549
  overflow="hidden"
532
550
  cursor={canAdd ? "text" : "default"}
533
551
  {...(isWeb ? { className: "mp-composite-ring" } : {})}
534
552
  outlineWidth={0}
535
- hoverStyle={canAdd ? { borderColor: componentColors.interactive.border } : undefined}
553
+ hoverStyle={canAdd ? { borderColor: "$borderColorHover" } : undefined}
536
554
  focusStyle={{ outlineWidth: 0 }}
537
555
  focusVisibleStyle={{ outlineWidth: 0 }}
538
556
  {...(disabled
@@ -566,21 +584,19 @@ export function Tags({
566
584
  flexBasis={96}
567
585
  minWidth={96}
568
586
  height="auto"
587
+ minHeight={chipHeight ?? undefined}
569
588
  padding={0}
570
589
  margin={0}
590
+ paddingLeft={entryInset}
571
591
  borderWidth={0}
572
592
  backgroundColor="transparent"
573
593
  outlineWidth={0}
574
594
  focusStyle={{ outlineWidth: 0, borderWidth: 0 }}
575
595
  focusVisibleStyle={{ outlineWidth: 0 }}
576
596
  {...knobProps.body}
577
- // Chip-scale type: the input shares its row with the tag chips, so
578
- // it rides the same recipe font channel their labels do -- with
579
- // the body size it set the text a step larger and the placeholder
580
- // sat off the chips' optical line.
581
- fontSize={chipTextSize(chipSize)}
597
+ fontSize={fieldRecipe.fontSize}
582
598
  color={componentColors.text.primary}
583
- placeholderTextColor={knobProps.textAccentColor as InputProps["placeholderTextColor"]}
599
+ placeholderTextColor={"$placeholderColor" as InputProps["placeholderTextColor"]}
584
600
  autoComplete="off"
585
601
  role="combobox"
586
602
  aria-label={addTagLabel}
@@ -590,7 +606,7 @@ export function Tags({
590
606
  aria-activedescendant={activeOptionId}
591
607
  />
592
608
  )}
593
- </XStack>
609
+ </TagsFieldFrame>
594
610
  );
595
611
 
596
612
  return (
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/Carousel/index.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAUH,OAAO,KAAK,EAAiB,SAAS,EAAE,MAAM,OAAO,CAAC;AAOtD,OAAO,KAAK,EAAE,QAAQ,EAAmC,MAAM,SAAS,CAAC;AAGzE,OAAO,EAAY,KAAK,YAAY,EAAe,MAAM,aAAa,CAAC;AAOvE,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,SAAS,CAAC;IACpB,6BAA6B;IAC7B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,0BAA0B;IAC1B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,wDAAwD;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+CAA+C;IAC/C,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,wBAAwB;IACxB,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACtB,2CAA2C;IAC3C,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,0BAA0B;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,SAAS,YAAY,EAAE,CAAC;CACpC;AAkCD,QAAA,MAAM,iBAAiB,kPAKrB,CAAC;AAQH,QAAA,MAAM,aAAa,kPAGjB,CAAC;AAgDH,wBAAgB,QAAQ,CAAC,EACvB,QAAQ,EACR,UAAiB,EACjB,QAAe,EACf,QAAY,EACZ,IAAY,EACZ,GAAG,EAAE,OAAO,EACZ,aAAa,EACb,YAAgB,EAChB,QAAQ,GACT,EAAE,aAAa,2CA4Wf;yBAtXe,QAAQ;;;AA4XxB,MAAM,MAAM,sBAAsB,GAAG,QAAQ,CAAC,OAAO,iBAAiB,CAAC,CAAC;AACxE,MAAM,MAAM,kBAAkB,GAAG,QAAQ,CAAC,OAAO,aAAa,CAAC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/Carousel/index.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAUH,OAAO,KAAK,EAAiB,SAAS,EAAE,MAAM,OAAO,CAAC;AAOtD,OAAO,KAAK,EAAE,QAAQ,EAAmC,MAAM,SAAS,CAAC;AAGzE,OAAO,EAAY,KAAK,YAAY,EAAe,MAAM,aAAa,CAAC;AAOvE,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,SAAS,CAAC;IACpB,6BAA6B;IAC7B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,0BAA0B;IAC1B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,wDAAwD;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+CAA+C;IAC/C,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,wBAAwB;IACxB,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACtB,2CAA2C;IAC3C,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,0BAA0B;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,SAAS,YAAY,EAAE,CAAC;CACpC;AAkCD,QAAA,MAAM,iBAAiB,kPAKrB,CAAC;AAQH,QAAA,MAAM,aAAa,kPAGjB,CAAC;AAgDH,wBAAgB,QAAQ,CAAC,EACvB,QAAQ,EACR,UAAiB,EACjB,QAAe,EACf,QAAY,EACZ,IAAY,EACZ,GAAG,EAAE,OAAO,EACZ,aAAa,EACb,YAAgB,EAChB,QAAQ,GACT,EAAE,aAAa,2CAmXf;yBA7Xe,QAAQ;;;AAmYxB,MAAM,MAAM,sBAAsB,GAAG,QAAQ,CAAC,OAAO,iBAAiB,CAAC,CAAC;AACxE,MAAM,MAAM,kBAAkB,GAAG,QAAQ,CAAC,OAAO,aAAa,CAAC,CAAC"}