@multiplatform.one/forms 7.47.4 → 7.49.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 (180) hide show
  1. package/dist/cjs/AdaptivePopup/index.cjs +13 -7
  2. package/dist/cjs/AdaptivePopup/index.native.js +13 -7
  3. package/dist/cjs/AdaptivePopup/index.native.js.map +1 -1
  4. package/dist/cjs/Button/index.cjs +22 -20
  5. package/dist/cjs/Button/index.native.js +22 -20
  6. package/dist/cjs/Button/index.native.js.map +1 -1
  7. package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.cjs +3 -1
  8. package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.native.js +3 -1
  9. package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
  10. package/dist/cjs/FloatingPanel/index.cjs +21 -10
  11. package/dist/cjs/FloatingPanel/index.native.js +116 -110
  12. package/dist/cjs/FloatingPanel/index.native.js.map +1 -1
  13. package/dist/cjs/Spinner/index.cjs +2 -1
  14. package/dist/cjs/Spinner/index.native.js +2 -1
  15. package/dist/cjs/Spinner/index.native.js.map +1 -1
  16. package/dist/cjs/Spinner/spinnerSize.native.js.map +1 -1
  17. package/dist/cjs/fields/Combobox/index.cjs +3 -2
  18. package/dist/cjs/fields/Combobox/index.native.js +3 -2
  19. package/dist/cjs/fields/Combobox/index.native.js.map +1 -1
  20. package/dist/cjs/fields/Geolocation/index.cjs +3 -2
  21. package/dist/cjs/fields/Geolocation/index.native.js +3 -2
  22. package/dist/cjs/fields/Geolocation/index.native.js.map +1 -1
  23. package/dist/cjs/fields/MentionInput/index.cjs +3 -2
  24. package/dist/cjs/fields/MentionInput/index.native.js +3 -2
  25. package/dist/cjs/fields/MentionInput/index.native.js.map +1 -1
  26. package/dist/cjs/fields/Select/index.cjs +3 -2
  27. package/dist/cjs/fields/Select/index.native.js +3 -2
  28. package/dist/cjs/fields/Select/index.native.js.map +1 -1
  29. package/dist/cjs/index.cjs +9 -0
  30. package/dist/cjs/index.native.js +9 -0
  31. package/dist/cjs/index.native.js.map +1 -1
  32. package/dist/cjs/shared/OverlayWindow.cjs +38 -0
  33. package/dist/cjs/shared/OverlayWindow.native.js +54 -0
  34. package/dist/cjs/shared/OverlayWindow.native.js.map +1 -0
  35. package/dist/cjs/shared/overlayStack.cjs +92 -0
  36. package/dist/cjs/shared/overlayStack.native.js +97 -0
  37. package/dist/cjs/shared/overlayStack.native.js.map +1 -0
  38. package/dist/esm/AdaptivePopup/index.mjs +13 -7
  39. package/dist/esm/AdaptivePopup/index.mjs.map +1 -1
  40. package/dist/esm/AdaptivePopup/index.native.js +13 -7
  41. package/dist/esm/AdaptivePopup/index.native.js.map +1 -1
  42. package/dist/esm/Button/index.mjs +22 -20
  43. package/dist/esm/Button/index.mjs.map +1 -1
  44. package/dist/esm/Button/index.native.js +22 -20
  45. package/dist/esm/Button/index.native.js.map +1 -1
  46. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.mjs +3 -1
  47. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.mjs.map +1 -1
  48. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.native.js +3 -1
  49. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
  50. package/dist/esm/FloatingPanel/index.mjs +21 -10
  51. package/dist/esm/FloatingPanel/index.mjs.map +1 -1
  52. package/dist/esm/FloatingPanel/index.native.js +116 -110
  53. package/dist/esm/FloatingPanel/index.native.js.map +1 -1
  54. package/dist/esm/Spinner/index.mjs +2 -1
  55. package/dist/esm/Spinner/index.mjs.map +1 -1
  56. package/dist/esm/Spinner/index.native.js +2 -1
  57. package/dist/esm/Spinner/index.native.js.map +1 -1
  58. package/dist/esm/Spinner/spinnerSize.mjs.map +1 -1
  59. package/dist/esm/Spinner/spinnerSize.native.js.map +1 -1
  60. package/dist/esm/fields/Combobox/index.mjs +3 -2
  61. package/dist/esm/fields/Combobox/index.mjs.map +1 -1
  62. package/dist/esm/fields/Combobox/index.native.js +3 -2
  63. package/dist/esm/fields/Combobox/index.native.js.map +1 -1
  64. package/dist/esm/fields/Geolocation/index.mjs +3 -2
  65. package/dist/esm/fields/Geolocation/index.mjs.map +1 -1
  66. package/dist/esm/fields/Geolocation/index.native.js +3 -2
  67. package/dist/esm/fields/Geolocation/index.native.js.map +1 -1
  68. package/dist/esm/fields/MentionInput/index.mjs +3 -2
  69. package/dist/esm/fields/MentionInput/index.mjs.map +1 -1
  70. package/dist/esm/fields/MentionInput/index.native.js +3 -2
  71. package/dist/esm/fields/MentionInput/index.native.js.map +1 -1
  72. package/dist/esm/fields/Select/index.mjs +3 -2
  73. package/dist/esm/fields/Select/index.mjs.map +1 -1
  74. package/dist/esm/fields/Select/index.native.js +3 -2
  75. package/dist/esm/fields/Select/index.native.js.map +1 -1
  76. package/dist/esm/index.mjs +3 -1
  77. package/dist/esm/index.mjs.map +1 -1
  78. package/dist/esm/index.native.js +3 -1
  79. package/dist/esm/index.native.js.map +1 -1
  80. package/dist/esm/shared/OverlayWindow.mjs +13 -0
  81. package/dist/esm/shared/OverlayWindow.mjs.map +1 -0
  82. package/dist/esm/shared/OverlayWindow.native.js +26 -0
  83. package/dist/esm/shared/OverlayWindow.native.js.map +1 -0
  84. package/dist/esm/shared/overlayStack.mjs +62 -0
  85. package/dist/esm/shared/overlayStack.mjs.map +1 -0
  86. package/dist/esm/shared/overlayStack.native.js +64 -0
  87. package/dist/esm/shared/overlayStack.native.js.map +1 -0
  88. package/dist/jsx/AdaptivePopup/index.mjs +13 -7
  89. package/dist/jsx/AdaptivePopup/index.mjs.map +1 -1
  90. package/dist/jsx/AdaptivePopup/index.native.js +13 -7
  91. package/dist/jsx/AdaptivePopup/index.native.js.map +1 -1
  92. package/dist/jsx/Button/index.mjs +22 -20
  93. package/dist/jsx/Button/index.mjs.map +1 -1
  94. package/dist/jsx/Button/index.native.js +22 -20
  95. package/dist/jsx/Button/index.native.js.map +1 -1
  96. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.mjs +3 -1
  97. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.mjs.map +1 -1
  98. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.native.js +3 -1
  99. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
  100. package/dist/jsx/FloatingPanel/index.mjs +21 -10
  101. package/dist/jsx/FloatingPanel/index.mjs.map +1 -1
  102. package/dist/jsx/FloatingPanel/index.native.js +116 -110
  103. package/dist/jsx/FloatingPanel/index.native.js.map +1 -1
  104. package/dist/jsx/Spinner/index.mjs +2 -1
  105. package/dist/jsx/Spinner/index.mjs.map +1 -1
  106. package/dist/jsx/Spinner/index.native.js +2 -1
  107. package/dist/jsx/Spinner/index.native.js.map +1 -1
  108. package/dist/jsx/Spinner/spinnerSize.mjs.map +1 -1
  109. package/dist/jsx/Spinner/spinnerSize.native.js.map +1 -1
  110. package/dist/jsx/fields/Combobox/index.mjs +3 -2
  111. package/dist/jsx/fields/Combobox/index.mjs.map +1 -1
  112. package/dist/jsx/fields/Combobox/index.native.js +3 -2
  113. package/dist/jsx/fields/Combobox/index.native.js.map +1 -1
  114. package/dist/jsx/fields/Geolocation/index.mjs +3 -2
  115. package/dist/jsx/fields/Geolocation/index.mjs.map +1 -1
  116. package/dist/jsx/fields/Geolocation/index.native.js +3 -2
  117. package/dist/jsx/fields/Geolocation/index.native.js.map +1 -1
  118. package/dist/jsx/fields/MentionInput/index.mjs +3 -2
  119. package/dist/jsx/fields/MentionInput/index.mjs.map +1 -1
  120. package/dist/jsx/fields/MentionInput/index.native.js +3 -2
  121. package/dist/jsx/fields/MentionInput/index.native.js.map +1 -1
  122. package/dist/jsx/fields/Select/index.mjs +3 -2
  123. package/dist/jsx/fields/Select/index.mjs.map +1 -1
  124. package/dist/jsx/fields/Select/index.native.js +3 -2
  125. package/dist/jsx/fields/Select/index.native.js.map +1 -1
  126. package/dist/jsx/index.js +3 -1
  127. package/dist/jsx/index.js.map +1 -1
  128. package/dist/jsx/index.mjs +3 -1
  129. package/dist/jsx/index.mjs.map +1 -1
  130. package/dist/jsx/index.native.js +9 -0
  131. package/dist/jsx/index.native.js.map +1 -1
  132. package/dist/jsx/shared/OverlayWindow.mjs +13 -0
  133. package/dist/jsx/shared/OverlayWindow.mjs.map +1 -0
  134. package/dist/jsx/shared/OverlayWindow.native.js +54 -0
  135. package/dist/jsx/shared/OverlayWindow.native.js.map +1 -0
  136. package/dist/jsx/shared/overlayStack.mjs +62 -0
  137. package/dist/jsx/shared/overlayStack.mjs.map +1 -0
  138. package/dist/jsx/shared/overlayStack.native.js +97 -0
  139. package/dist/jsx/shared/overlayStack.native.js.map +1 -0
  140. package/package.json +8 -8
  141. package/src/AdaptivePopup/index.tsx +11 -7
  142. package/src/Button/Button.loading.spec.tsx +63 -16
  143. package/src/Button/index.tsx +20 -24
  144. package/src/ContextualSaveBar/UnsavedChangesDialog.tsx +5 -2
  145. package/src/FloatingPanel/index.native.tsx +102 -97
  146. package/src/FloatingPanel/index.tsx +17 -12
  147. package/src/Spinner/index.native.tsx +2 -2
  148. package/src/Spinner/index.tsx +2 -2
  149. package/src/Spinner/spinnerSize.ts +2 -0
  150. package/src/fields/Combobox/index.tsx +3 -2
  151. package/src/fields/Geolocation/index.tsx +3 -2
  152. package/src/fields/MentionInput/index.tsx +3 -2
  153. package/src/fields/Select/index.tsx +3 -2
  154. package/src/index.ts +11 -0
  155. package/src/shared/OverlayWindow.native.tsx +28 -0
  156. package/src/shared/OverlayWindow.tsx +17 -0
  157. package/src/shared/overlayStack.spec.tsx +59 -0
  158. package/src/shared/overlayStack.tsx +102 -0
  159. package/types/AdaptivePopup/index.d.ts.map +1 -1
  160. package/types/Button/index.d.ts.map +1 -1
  161. package/types/ContextualSaveBar/UnsavedChangesDialog.d.ts.map +1 -1
  162. package/types/FloatingPanel/index.native.d.ts.map +1 -1
  163. package/types/Spinner/index.d.ts +1 -1
  164. package/types/Spinner/index.d.ts.map +1 -1
  165. package/types/Spinner/index.native.d.ts +1 -1
  166. package/types/Spinner/index.native.d.ts.map +1 -1
  167. package/types/Spinner/spinnerSize.d.ts +2 -0
  168. package/types/Spinner/spinnerSize.d.ts.map +1 -1
  169. package/types/fields/Combobox/index.d.ts.map +1 -1
  170. package/types/fields/Geolocation/index.d.ts.map +1 -1
  171. package/types/fields/MentionInput/index.d.ts.map +1 -1
  172. package/types/fields/Select/index.d.ts.map +1 -1
  173. package/types/index.d.ts +4 -0
  174. package/types/index.d.ts.map +1 -1
  175. package/types/shared/OverlayWindow.d.ts +13 -0
  176. package/types/shared/OverlayWindow.d.ts.map +1 -0
  177. package/types/shared/OverlayWindow.native.d.ts +15 -0
  178. package/types/shared/OverlayWindow.native.d.ts.map +1 -0
  179. package/types/shared/overlayStack.d.ts +42 -0
  180. package/types/shared/overlayStack.d.ts.map +1 -0
@@ -5,9 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { Button } from "./index";
6
6
 
7
7
  /**
8
- * WP-c2: a loading Button renders the ONE Skeleton (forms, re-exported by
9
- * components) at the button's recipe height, and the bone stops pulsing for a
10
- * user who asked for reduced motion.
8
+ * FZ-7812: a loading Button keeps its label and its rest chrome; a spinner
9
+ * takes the leading icon slot. WP-c2's skeleton bone stays the `skeleton`
10
+ * placeholder (a button that has not loaded), and stops pulsing for a user
11
+ * who asked for reduced motion.
11
12
  */
12
13
 
13
14
  let reduced = false;
@@ -32,30 +33,76 @@ afterEach(() => {
32
33
  });
33
34
 
34
35
  function bone(container: HTMLElement): HTMLElement {
35
- const el = container.querySelector("[data-button-loading] [data-skeleton]");
36
- if (!el) throw new Error("no skeleton bone in the loading button");
36
+ const el = container.querySelector("[data-skeleton]");
37
+ if (!el) throw new Error("no skeleton bone in the skeleton button");
37
38
  return el as HTMLElement;
38
39
  }
39
40
 
40
- describe("Button loading (WP-c2 one Skeleton)", () => {
41
- it("renders the shared rounded Skeleton at the recipe height in the label slot", () => {
41
+ describe("Button loading (FZ-7812 label stays)", () => {
42
+ it("keeps the label and puts a spinner in the leading slot", () => {
42
43
  const { container, getByRole } = renderWithProviders(
43
44
  <Button loading size="$3">
44
45
  Save
45
46
  </Button>,
46
47
  );
48
+ const button = getByRole("button");
49
+ expect(button.getAttribute("aria-busy")).toBe("true");
50
+ expect(button.textContent).toContain("Save");
51
+ const slot = container.querySelector("[data-button-loading]");
52
+ expect(slot).toBeTruthy();
53
+ expect(slot?.getAttribute("aria-hidden")).toBe("true");
54
+ expect(slot?.nextElementSibling?.textContent).toBe("Save");
55
+ expect(container.querySelector("[data-skeleton]")).toBeNull();
56
+ });
57
+
58
+ it("takes an icon's slot instead of adding a second glyph", () => {
59
+ const Icon = () => <svg data-testid="icon" />;
60
+ const { container, queryByTestId } = renderWithProviders(
61
+ <Button loading icon={Icon}>
62
+ Save
63
+ </Button>,
64
+ );
65
+ expect(queryByTestId("icon")).toBeNull();
66
+ expect(container.querySelectorAll("[data-button-loading]")).toHaveLength(1);
67
+ });
68
+
69
+ it("keeps the rest chrome: loading is not the disabled wash", () => {
70
+ const rest = renderWithProviders(<Button chromeless>Log in</Button>);
71
+ const restStyle = getComputedStyle(rest.getByRole("button"));
72
+ const restBg = restStyle.backgroundColor;
73
+ const restInk = getComputedStyle(rest.getByText("Log in")).color;
74
+ rest.unmount();
75
+ const busy = renderWithProviders(
76
+ <Button chromeless loading>
77
+ Log in
78
+ </Button>,
79
+ );
80
+ expect(getComputedStyle(busy.getByRole("button")).backgroundColor).toBe(restBg);
81
+ expect(getComputedStyle(busy.getByText("Log in")).color).toBe(restInk);
82
+ });
83
+
84
+ it("swallows presses while busy", () => {
85
+ const onPress = vi.fn();
86
+ const { getByRole } = renderWithProviders(
87
+ <Button loading onPress={onPress}>
88
+ Save
89
+ </Button>,
90
+ );
91
+ getByRole("button").click();
92
+ expect(onPress).not.toHaveBeenCalled();
93
+ });
94
+ });
95
+
96
+ describe("Button skeleton (WP-c2 one Skeleton)", () => {
97
+ it("renders the shared rounded Skeleton at the recipe height", () => {
98
+ const { container } = renderWithProviders(<Button skeleton size="$3" />);
47
99
  const el = bone(container);
48
100
  expect(el.getAttribute("data-skeleton-variant")).toBe("rounded");
49
101
  expect(el.getAttribute("data-height")).toBe(String(sizeRecipeForToken("$3").height));
50
- expect(el.getAttribute("aria-hidden")).toBe("true");
51
- const button = getByRole("button");
52
- expect(button.getAttribute("aria-busy")).toBe("true");
53
- // The label is swapped out for the bone (busy, not narrated).
54
- expect(button.textContent).not.toContain("Save");
55
102
  });
56
103
 
57
104
  it("pulses with the animation knob on", () => {
58
- const { container } = renderWithProviders(<Button loading>Save</Button>);
105
+ const { container } = renderWithProviders(<Button skeleton />);
59
106
  const el = bone(container);
60
107
  expect(el.getAttribute("data-animate")).toBe("true");
61
108
  expect(el.className).toMatch(/mp-skeleton-pulse/);
@@ -64,7 +111,7 @@ describe("Button loading (WP-c2 one Skeleton)", () => {
64
111
 
65
112
  it("respects prefers-reduced-motion: the bone goes static", () => {
66
113
  reduced = true;
67
- const { container } = renderWithProviders(<Button loading>Save</Button>);
114
+ const { container } = renderWithProviders(<Button skeleton />);
68
115
  const el = bone(container);
69
116
  expect(el.getAttribute("data-animate")).toBe("false");
70
117
  expect(el.className).toMatch(/mp-skeleton-static/);
@@ -74,8 +121,8 @@ describe("Button loading (WP-c2 one Skeleton)", () => {
74
121
  it("mounts one keyframe sheet that also stops motion under reduced motion", () => {
75
122
  renderWithProviders(
76
123
  <>
77
- <Button loading>One</Button>
78
- <Button loading>Two</Button>
124
+ <Button skeleton />
125
+ <Button skeleton />
79
126
  </>,
80
127
  );
81
128
  const sheets = document.querySelectorAll("#__mp-skeleton-pulse");
@@ -52,6 +52,7 @@ import {
52
52
  useTheme,
53
53
  } from "tamagui";
54
54
  import { Skeleton } from "../Skeleton";
55
+ import { Spinner } from "../Spinner";
55
56
  import type { ThemeName } from "@tamagui/web";
56
57
  import type { GestureResponderEvent } from "react-native";
57
58
  import type { AnyFormApi } from "../types";
@@ -501,7 +502,10 @@ export function Button({
501
502
  );
502
503
  }
503
504
 
504
- const isDisabled = disabled || loading || (action === "submit" && resolvedForm && !isValid);
505
+ // Busy is not unavailable: a loading button swallows presses but keeps its
506
+ // rest chrome and label. Washed, it read as stuck pressed (FZ-7812).
507
+ const unavailable = Boolean(disabled || (action === "submit" && resolvedForm && !isValid));
508
+ const isDisabled = unavailable || Boolean(loading);
505
509
 
506
510
  const handlePress = (e: GestureResponderEvent) => {
507
511
  // Explained disable keeps the frame press-able (aria-disabled, focusable)
@@ -542,7 +546,7 @@ export function Button({
542
546
  // where `$color11` misses 4.5:1 (light warning 4.48, success 4.27) and the
543
547
  // hue's `$color12` text step clears it.
544
548
  const washOnHost = Boolean(
545
- isDisabled && activeThemeName && solidPaletteThemes.has(activeThemeName),
549
+ unavailable && activeThemeName && solidPaletteThemes.has(activeThemeName),
546
550
  );
547
551
  const disabledSurface = washOnHost
548
552
  ? hostScoped(disabledState.surfaceKnobProps)
@@ -607,7 +611,7 @@ export function Button({
607
611
  ];
608
612
  const isSelected =
609
613
  selected ?? (ariaPressedProp === true || ariaPressedProp === "true" ? true : undefined);
610
- const selectedFill = Boolean(isSelected) && !isDisabled;
614
+ const selectedFill = Boolean(isSelected) && !unavailable;
611
615
  const iconSize = nested
612
616
  ? Math.min(recipe.iconSize, Math.max(12, Math.round(knobProps.nestedControl.px * 0.45)))
613
617
  : recipe.iconSize;
@@ -647,7 +651,7 @@ export function Button({
647
651
  : undefined)}
648
652
  // LC-40: on the washed disabled chrome the label leaves the on-fill
649
653
  // foreground for the wash's AA ink (keepLabel keeps >=4.5:1).
650
- {...(isDisabled ? disabledText : undefined)}
654
+ {...(unavailable ? disabledText : undefined)}
651
655
  // Boards measure ink.weight on the TEXT NODE, never the frame. The
652
656
  // prop is restated after the spreads because SizableText's size
653
657
  // variant otherwise leaks 500/600 over it (MPO-125: a literal 400
@@ -669,29 +673,21 @@ export function Button({
669
673
  : flattenedChildren?.some(isBareTextChild)
670
674
  ? wrapBareTextChildren(flattenedChildren, renderLabelText)
671
675
  : resolvedChildren;
672
- // Loading shows state, not a narration: the shared Skeleton bone takes the
673
- // label's slot at the recipe height (the label is swapped out; hosts such as
674
- // Toolbar name the busy button through `aria-label`). The frame stays, so
675
- // focus never leaves the button.
676
- const textContent = loading ? (
677
- <View data-button-loading="">
678
- <Skeleton variant="rounded" height={boneHeight} width={boneHeight * 2} aria-hidden />
679
- </View>
680
- ) : (
681
- labelContent
682
- );
683
-
684
- const hasIcon = icon || iconAfter;
676
+ const hasIcon = icon || iconAfter || loading;
685
677
  const nativeIconColor =
686
678
  props.color ??
687
- (isDisabled ? disabledText.color : undefined) ??
679
+ (unavailable ? disabledText.color : undefined) ??
688
680
  (textColor
689
681
  ? outlinedOnHost
690
682
  ? hostScoped({ color: textColor }).color
691
683
  : textColor
692
684
  : outlinedInk) ??
693
685
  "$color";
694
- const iconElement = icon ? (
686
+ const iconElement = loading ? (
687
+ <View flexShrink={0} data-button-loading="" aria-hidden>
688
+ <Spinner px={iconSize} color={nativeIconColor as string} aria-hidden />
689
+ </View>
690
+ ) : icon ? (
695
691
  <View flexShrink={0}>
696
692
  {isWeb ? (
697
693
  normalizeButtonIcon(icon, iconSize)
@@ -710,7 +706,7 @@ export function Button({
710
706
  </View>
711
707
  ) : null;
712
708
 
713
- const explainedDisable = Boolean(disabledReason?.trim()) && Boolean(isDisabled);
709
+ const explainedDisable = Boolean(disabledReason?.trim()) && unavailable;
714
710
  const generatedId = useId();
715
711
  const reasonId = explainedDisable
716
712
  ? `${(props as { id?: string }).id ?? generatedId}-disabled-reason`
@@ -837,7 +833,7 @@ export function Button({
837
833
  // (`color` inherits into currentColor icons); dimWhole dims the
838
834
  // button as its own assembly. Replaces the old hardcoded 0.5
839
835
  // whole-dim that dropped label contrast under AA.
840
- {...(isDisabled
836
+ {...(unavailable
841
837
  ? {
842
838
  ...disabledSurface,
843
839
  ...disabledText,
@@ -880,7 +876,7 @@ export function Button({
880
876
  onPress={handlePress}
881
877
  {...(isWeb ? { onKeyDown: handleKeyDown as unknown as () => void } : undefined)}
882
878
  {...(tabIndex != null ? { tabIndex } : undefined)}
883
- gap={hasIcon && (resolvedChildren || textContent) ? innerGap : undefined}
879
+ gap={hasIcon && (resolvedChildren || labelContent) ? innerGap : undefined}
884
880
  // Form actions hug their label (Clay: no stretch): a Button inside a
885
881
  // Form never takes the column's full width. A consumer width wins.
886
882
  {...(formFromHook && !grouped ? { alignSelf: "flex-start" as const } : undefined)}
@@ -897,7 +893,7 @@ export function Button({
897
893
  {...passthroughSizeProps}
898
894
  {...sizeRecipeEscapeOverride}
899
895
  {...(nested ? nestedFloor : isCircular ? circularPressFloor : pageSlop)}
900
- {...(selectedFill && !isDisabled
896
+ {...(selectedFill && !unavailable
901
897
  ? {
902
898
  backgroundColor: restFill,
903
899
  hoverStyle: {
@@ -955,7 +951,7 @@ export function Button({
955
951
  {...(selectedFill ? { "aria-pressed": true, "data-mp-selected": "true" } : undefined)}
956
952
  >
957
953
  {iconElement}
958
- {textContent}
954
+ {labelContent}
959
955
  {iconAfterElement}
960
956
  </ButtonFrame>
961
957
  );
@@ -10,6 +10,7 @@ import { Dialog, Paragraph, XStack } from "tamagui";
10
10
  import { Button } from "../Button";
11
11
  import { formCommonColors } from "../shared/colorRamps";
12
12
  import { useReturnFocusOnClose } from "../shared/focusManagement";
13
+ import { withOverlayStack } from "../shared/overlayStack";
13
14
  import { t } from "../shared/t";
14
15
 
15
16
  export interface UnsavedChangesDialogProps {
@@ -19,6 +20,8 @@ export interface UnsavedChangesDialogProps {
19
20
  onDiscard: () => void;
20
21
  }
21
22
 
23
+ const DialogPortal = withOverlayStack(Dialog.Portal, "dropdown", "DialogPortal");
24
+
22
25
  /**
23
26
  * Opens on Keep editing, the least destructive action, as tamagui's
24
27
  * AlertDialog.Cancel does. Without it Chromium and WebKit's `<dialog>.show()`
@@ -67,7 +70,7 @@ export function UnsavedChangesDialog({
67
70
 
68
71
  return (
69
72
  <Dialog modal open={open} onOpenChange={handleOpenChange}>
70
- <Dialog.Portal>
73
+ <DialogPortal>
71
74
  <DialogOverlay key="overlay" />
72
75
  <DialogContent
73
76
  key="content"
@@ -111,7 +114,7 @@ export function UnsavedChangesDialog({
111
114
  </Button>
112
115
  </XStack>
113
116
  </DialogContent>
114
- </Dialog.Portal>
117
+ </DialogPortal>
115
118
  </Dialog>
116
119
  );
117
120
  }
@@ -83,6 +83,8 @@ import {
83
83
  motionDurationMs,
84
84
  } from "@multiplatform.one/theme";
85
85
  import { View, YStack } from "tamagui";
86
+ import { useOverlayZIndex } from "../shared/overlayStack";
87
+ import { OverlayWindow } from "../shared/OverlayWindow";
86
88
  import { t } from "../shared/t";
87
89
  import { useKeyboardInset } from "../shared/useKeyboardInset";
88
90
  import type { FloatingPanelProps } from "./index";
@@ -143,6 +145,7 @@ export function FloatingPanel({
143
145
  }: FloatingPanelProps) {
144
146
  // The panel floats over the page: it never inherits a painted ancestor.
145
147
  const { knobProps } = useResolvedKnobs({ painted: false });
148
+ const windowZIndex = useOverlayZIndex("sheet");
146
149
  const children = <Surface painted={false}>{panelChildren}</Surface>;
147
150
  const slideTransition = knobProps.motion.slide.transition;
148
151
  const enterDuration = motionDurationMs(slideTransition);
@@ -522,118 +525,120 @@ export function FloatingPanel({
522
525
  onNativeShow?.();
523
526
  }}
524
527
  >
525
- {atPoint && pressPoint ? (
526
- <PointPanel
527
- key={`${pressPoint.x},${pressPoint.y}`}
528
- point={pressPoint}
529
- bounds={{
530
- top: topInset + panelViewportPadding,
531
- left: (safeArea?.left ?? 0) + panelViewportPadding,
532
- right: windowWidth - (safeArea?.right ?? 0) - panelViewportPadding,
533
- bottom:
534
- windowHeight -
535
- Math.max(safeArea?.bottom ?? 0, keyboardInset) -
536
- panelViewportPadding,
537
- }}
538
- framePadding={framePadding}
539
- frameGap={frameGap}
540
- onBackdropPress={handleBackdropPress}
541
- onEscape={close}
542
- >
543
- {header}
544
- {children}
545
- </PointPanel>
546
- ) : (
547
- /*
528
+ <OverlayWindow zIndex={windowZIndex}>
529
+ {atPoint && pressPoint ? (
530
+ <PointPanel
531
+ key={`${pressPoint.x},${pressPoint.y}`}
532
+ point={pressPoint}
533
+ bounds={{
534
+ top: topInset + panelViewportPadding,
535
+ left: (safeArea?.left ?? 0) + panelViewportPadding,
536
+ right: windowWidth - (safeArea?.right ?? 0) - panelViewportPadding,
537
+ bottom:
538
+ windowHeight -
539
+ Math.max(safeArea?.bottom ?? 0, keyboardInset) -
540
+ panelViewportPadding,
541
+ }}
542
+ framePadding={framePadding}
543
+ frameGap={frameGap}
544
+ onBackdropPress={handleBackdropPress}
545
+ onEscape={close}
546
+ >
547
+ {header}
548
+ {children}
549
+ </PointPanel>
550
+ ) : (
551
+ /*
548
552
  A plain padded column, not KeyboardAvoidingView: inside a Modal the
549
553
  latter pads the container while the sheet keeps its own fixed height,
550
554
  so the sheet overflows off the TOP instead of shrinking (MPO-64).
551
555
  */
552
- <RNView style={{ flex: 1, justifyContent: "flex-end", paddingBottom: keyboardInset }}>
553
- <Pressable
554
- style={StyleSheet.absoluteFill}
555
- onPress={handleBackdropPress}
556
- accessibilityRole="button"
557
- accessibilityLabel={t("Close")}
558
- >
559
- <View flex={1} backgroundColor="$shadow6" />
560
- </Pressable>
561
- {/*
556
+ <RNView style={{ flex: 1, justifyContent: "flex-end", paddingBottom: keyboardInset }}>
557
+ <Pressable
558
+ style={StyleSheet.absoluteFill}
559
+ onPress={handleBackdropPress}
560
+ accessibilityRole="button"
561
+ accessibilityLabel={t("Close")}
562
+ >
563
+ <View flex={1} backgroundColor="$shadow6" />
564
+ </Pressable>
565
+ {/*
562
566
  Sheet sits above the absoluteFill backdrop in z-order (later sibling).
563
567
  Do NOT claim the responder on a wrapper here — that steals presses
564
568
  from option rows / calendar days and blocks commit.
565
569
  */}
566
- <Animated.View
567
- testID="floating-panel-sheet"
568
- onLayout={(e) => {
569
- // Travel distance for a dismiss, and the basis for the detents.
570
- // A content-sized picker is shorter than maxHeight, and sliding
571
- // it by maxHeight would overshoot into a visible lag.
572
- if (restHeightRef.current !== null) return;
573
- const h = Math.round(e.nativeEvent.layout.height);
574
- if (h <= 0) return;
575
- naturalRef.current = h;
576
- filledRef.current = h >= maxHeightRef.current - 1;
577
- if (h !== measuredHeight) setMeasuredHeight(h);
578
- }}
579
- style={{
580
- // MPO-64: a flex child defaults to flexShrink 0, so without this
581
- // the sheet cannot give way to the keyboard at all.
582
- flexShrink: 1,
583
- minHeight: 0,
584
- maxHeight: frameCap,
585
- transform: [{ translateY }],
586
- }}
587
- >
588
- <YStack
589
- {...knobProps.elevatedSurface}
590
- borderBottomLeftRadius={0}
591
- borderBottomRightRadius={0}
592
- maxHeight={maxHeight}
593
- flexShrink={1}
594
- minHeight={0}
595
- // sheetFill opens at full sheet height (flexShrink keeps the
596
- // keyboard from pushing the top of the sheet off-screen).
597
- {...(sheetFill ? { height: maxHeight } : undefined)}
598
- paddingTop="$2"
599
- paddingBottom={sheetBottomPadding}
600
- // VoiceOver escape gesture (two-finger Z) dismisses the sheet.
601
- onAccessibilityEscape={close}
570
+ <Animated.View
571
+ testID="floating-panel-sheet"
572
+ onLayout={(e) => {
573
+ // Travel distance for a dismiss, and the basis for the detents.
574
+ // A content-sized picker is shorter than maxHeight, and sliding
575
+ // it by maxHeight would overshoot into a visible lag.
576
+ if (restHeightRef.current !== null) return;
577
+ const h = Math.round(e.nativeEvent.layout.height);
578
+ if (h <= 0) return;
579
+ naturalRef.current = h;
580
+ filledRef.current = h >= maxHeightRef.current - 1;
581
+ if (h !== measuredHeight) setMeasuredHeight(h);
582
+ }}
583
+ style={{
584
+ // MPO-64: a flex child defaults to flexShrink 0, so without this
585
+ // the sheet cannot give way to the keyboard at all.
586
+ flexShrink: 1,
587
+ minHeight: 0,
588
+ maxHeight: frameCap,
589
+ transform: [{ translateY }],
590
+ }}
602
591
  >
603
- {/*
592
+ <YStack
593
+ {...knobProps.elevatedSurface}
594
+ borderBottomLeftRadius={0}
595
+ borderBottomRightRadius={0}
596
+ maxHeight={maxHeight}
597
+ flexShrink={1}
598
+ minHeight={0}
599
+ // sheetFill opens at full sheet height (flexShrink keeps the
600
+ // keyboard from pushing the top of the sheet off-screen).
601
+ {...(sheetFill ? { height: maxHeight } : undefined)}
602
+ paddingTop="$2"
603
+ paddingBottom={sheetBottomPadding}
604
+ // VoiceOver escape gesture (two-finger Z) dismisses the sheet.
605
+ onAccessibilityEscape={close}
606
+ >
607
+ {/*
604
608
  The grab ZONE, not just the 4pt pill: a 4pt tall target is
605
609
  unhittable. The gesture lives here rather than on the whole
606
610
  sheet so it never fights the ScrollView inside it, which is the
607
611
  same division iOS uses.
608
612
  */}
609
- <RNView
610
- {...panResponder.panHandlers}
611
- accessibilityRole="adjustable"
612
- accessibilityLabel={t("Drag to resize")}
613
- style={{
614
- alignSelf: "stretch",
615
- alignItems: "center",
616
- paddingTop: 4,
617
- paddingBottom: 12,
618
- }}
619
- >
620
- <View width={40} height={4} borderRadius={2} backgroundColor="$color8" />
621
- </RNView>
622
- <YStack
623
- padding={framePadding}
624
- gap={frameGap}
625
- flexShrink={1}
626
- {...(sheetFill ? { flex: 1, minHeight: 0 } : undefined)}
627
- >
628
- <Surface size="$true">
629
- {header}
630
- {content}
631
- </Surface>
613
+ <RNView
614
+ {...panResponder.panHandlers}
615
+ accessibilityRole="adjustable"
616
+ accessibilityLabel={t("Drag to resize")}
617
+ style={{
618
+ alignSelf: "stretch",
619
+ alignItems: "center",
620
+ paddingTop: 4,
621
+ paddingBottom: 12,
622
+ }}
623
+ >
624
+ <View width={40} height={4} borderRadius={2} backgroundColor="$color8" />
625
+ </RNView>
626
+ <YStack
627
+ padding={framePadding}
628
+ gap={frameGap}
629
+ flexShrink={1}
630
+ {...(sheetFill ? { flex: 1, minHeight: 0 } : undefined)}
631
+ >
632
+ <Surface size="$true">
633
+ {header}
634
+ {content}
635
+ </Surface>
636
+ </YStack>
632
637
  </YStack>
633
- </YStack>
634
- </Animated.View>
635
- </RNView>
636
- )}
638
+ </Animated.View>
639
+ </RNView>
640
+ )}
641
+ </OverlayWindow>
637
642
  </Modal>
638
643
  </>
639
644
  );
@@ -28,7 +28,7 @@ import { useReturnFocusOnClose, useSheetModalFocus } from "../shared/focusManage
28
28
  import { useSheetEscapeDismiss } from "../shared/useSheetEscapeDismiss";
29
29
  import { useFloatingPanel, panelViewportPadding } from "./useFloatingPanel";
30
30
  import type { PanelWidthMode } from "./useFloatingPanel";
31
- import { zIndex } from "../shared/zIndex";
31
+ import { OverlayStackProvider, useOverlayZIndex } from "../shared/overlayStack";
32
32
  import { isNestedPanelAction, type PanelActionEvent } from "./nestedAction";
33
33
  import { PanelPortal } from "./PanelPortal";
34
34
  import {
@@ -490,6 +490,7 @@ function FloatingPanelSheet({
490
490
  escapeTarget = "window",
491
491
  }: FloatingPanelProps & { escapeTarget?: "window" | "document" }) {
492
492
  const { knobProps } = useResolvedKnobs({ painted: false });
493
+ const sheetZIndex = useOverlayZIndex("sheet");
493
494
  // LC-24 / A-ENTEREXIT: honor animation=none (undefined) — do not hardcode "medium".
494
495
  const slideMotion = {
495
496
  ...knobProps.motion.slide,
@@ -585,7 +586,8 @@ function FloatingPanelSheet({
585
586
  dismissOnSnapToBottom
586
587
  position={position}
587
588
  onPositionChange={setPosition}
588
- zIndex={zIndex.sheet}
589
+ zIndex={sheetZIndex}
590
+ portalProps={{ zIndex: sheetZIndex }}
589
591
  {...transitionProps(slideMotion)}
590
592
  >
591
593
  <Sheet.Overlay
@@ -608,14 +610,16 @@ function FloatingPanelSheet({
608
610
  padding={framePadding}
609
611
  gap={frameGap}
610
612
  >
611
- {header}
612
- {scrollable ? (
613
- <Sheet.ScrollView flex={header ? 1 : undefined} {...scrollViewProps}>
614
- {children}
615
- </Sheet.ScrollView>
616
- ) : (
617
- children
618
- )}
613
+ <OverlayStackProvider zIndex={sheetZIndex}>
614
+ {header}
615
+ {scrollable ? (
616
+ <Sheet.ScrollView flex={header ? 1 : undefined} {...scrollViewProps}>
617
+ {children}
618
+ </Sheet.ScrollView>
619
+ ) : (
620
+ children
621
+ )}
622
+ </OverlayStackProvider>
619
623
  </Sheet.Frame>
620
624
  </Sheet>
621
625
  </>
@@ -642,6 +646,7 @@ function FloatingPanelWeb({
642
646
  anchorRect,
643
647
  ...sheetProps
644
648
  }: FloatingPanelProps) {
649
+ const panelZIndex = useOverlayZIndex("dropdown");
645
650
  const [collisionSheet, setCollisionSheet] = useState(false);
646
651
  const isFill = sizing === "fill";
647
652
  const isContextMenu = openOn === "contextmenu";
@@ -1193,7 +1198,7 @@ function FloatingPanelWeb({
1193
1198
  top: overlayStyleRef.current?.top ?? panel.y ?? 0,
1194
1199
  left: overlayStyleRef.current?.left ?? panel.x ?? 0,
1195
1200
  scrollbarWidth: "none" as any,
1196
- zIndex: zIndex.dropdown,
1201
+ zIndex: panelZIndex,
1197
1202
  overflow: "auto",
1198
1203
  ...(cover && !isContextMenu ? { overflowX: "hidden" as const } : {}),
1199
1204
  ...panel.floatingStyleRef.current,
@@ -1261,7 +1266,7 @@ function FloatingPanelWeb({
1261
1266
  {...(contentPadding !== "none" ? panel.knobProps.panelPadding : undefined)}
1262
1267
  {...(contentPadding !== "none" ? panel.knobProps.gapLg : undefined)}
1263
1268
  >
1264
- {children}
1269
+ <OverlayStackProvider zIndex={panelZIndex}>{children}</OverlayStackProvider>
1265
1270
  </View>
1266
1271
  <ScrollArrow
1267
1272
  direction="down"
@@ -11,10 +11,10 @@ export type { SpinnerProps, SpinnerSize } from "./spinnerSize";
11
11
  const SPIN_MS = 700;
12
12
 
13
13
  /** Native Spinner. Size follows the shared size-token scale; spin gates on transition. */
14
- export function Spinner({ size, color, ...props }: SpinnerProps) {
14
+ export function Spinner({ size, px: pxProp, color, ...props }: SpinnerProps) {
15
15
  const { knobProps } = useResolvedKnobs();
16
16
  const token = spinnerSizeToken(size, knobProps.sizeToken);
17
- const px = spinnerSizePx(token);
17
+ const px = pxProp ?? spinnerSizePx(token);
18
18
  const stroke = useSpinnerStroke(color);
19
19
  const [reduceMotion, setReduceMotion] = useState(false);
20
20
  useEffect(() => {
@@ -26,10 +26,10 @@ function ensureMotionStyles() {
26
26
  }
27
27
 
28
28
  /** Form-styled Spinner. Size follows the shared size-token scale; spin gates on transition. */
29
- export function Spinner({ size, color, ...props }: SpinnerProps) {
29
+ export function Spinner({ size, px: pxProp, color, ...props }: SpinnerProps) {
30
30
  const { knobProps } = useResolvedKnobs();
31
31
  const token = spinnerSizeToken(size, knobProps.sizeToken);
32
- const px = spinnerSizePx(token);
32
+ const px = pxProp ?? spinnerSizePx(token);
33
33
  const stroke = useSpinnerStroke(color);
34
34
  const animationsOff = !knobProps.motion.control.transition;
35
35
  ensureMotionStyles();
@@ -6,6 +6,8 @@ export type SpinnerSize = "small" | "large";
6
6
 
7
7
  export interface SpinnerProps {
8
8
  size?: SpinnerSize;
9
+ /** Glyph box in px, over `size`: a host slot that already has a size (Button's icon slot). */
10
+ px?: number;
9
11
  color?: string;
10
12
  [key: string]: unknown;
11
13
  }
@@ -67,7 +67,7 @@ import {
67
67
  useViewportGtSm,
68
68
  } from "../../FloatingPanel";
69
69
  import { PanelPortal } from "../../FloatingPanel/PanelPortal";
70
- import { zIndex } from "../../shared/zIndex";
70
+ import { useOverlayZIndex } from "../../shared/overlayStack";
71
71
  import { useDragScroll } from "../../shared/useDragScroll";
72
72
  import { useIsInTableCell } from "../../shared/tableCellContext";
73
73
  import { orderOptionsBySelected, type SelectedOrder } from "../selectedOrder";
@@ -518,6 +518,7 @@ function FloatingComboboxCore({
518
518
  open: openProp,
519
519
  onOpenChange,
520
520
  }: FloatingComboboxCoreProps) {
521
+ const overlayZIndex = useOverlayZIndex("dropdown");
521
522
  const { style: elevatedSurfaceStyle, ...elevatedSurfaceProps } = knobProps.elevatedSurface;
522
523
  const hydrationTouch = useTouchSurface();
523
524
  if (isWeb) ensureKeyboardModalityTracking();
@@ -1498,7 +1499,7 @@ function FloatingComboboxCore({
1498
1499
  position: "fixed",
1499
1500
  top: 0,
1500
1501
  left: 0,
1501
- zIndex: zIndex.dropdown,
1502
+ zIndex: overlayZIndex,
1502
1503
  overflow: "hidden",
1503
1504
  display: "flex",
1504
1505
  flexDirection: "column",