@multiplatform.one/forms 7.72.0 → 7.74.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 (211) hide show
  1. package/dist/cjs/Button/IconButtonGroup.cjs +32 -0
  2. package/dist/cjs/Button/IconButtonGroup.native.js +32 -0
  3. package/dist/cjs/Button/IconButtonGroup.native.js.map +1 -1
  4. package/dist/cjs/Button/circleGlyph.cjs +28 -0
  5. package/dist/cjs/Button/circleGlyph.native.js +31 -0
  6. package/dist/cjs/Button/circleGlyph.native.js.map +1 -0
  7. package/dist/cjs/Button/glass.cjs +4 -1
  8. package/dist/cjs/Button/iconButtonGroupContext.native.js.map +1 -1
  9. package/dist/cjs/Button/index.cjs +44 -15
  10. package/dist/cjs/Button/index.native.js +52 -17
  11. package/dist/cjs/Button/index.native.js.map +1 -1
  12. package/dist/cjs/Button/webGlass.cjs +62 -0
  13. package/dist/cjs/Button/webGlass.native.js +68 -0
  14. package/dist/cjs/Button/webGlass.native.js.map +1 -0
  15. package/dist/cjs/FloatingPanel/PanelHeader.cjs +49 -0
  16. package/dist/cjs/FloatingPanel/PanelHeader.native.js +53 -0
  17. package/dist/cjs/FloatingPanel/PanelHeader.native.js.map +1 -0
  18. package/dist/cjs/FloatingPanel/echoContext.cjs +28 -0
  19. package/dist/cjs/FloatingPanel/echoContext.native.js +29 -0
  20. package/dist/cjs/FloatingPanel/echoContext.native.js.map +1 -1
  21. package/dist/cjs/FloatingPanel/iconEcho.cjs +104 -0
  22. package/dist/cjs/FloatingPanel/iconEcho.native.js +114 -0
  23. package/dist/cjs/FloatingPanel/iconEcho.native.js.map +1 -0
  24. package/dist/cjs/FloatingPanel/index.cjs +168 -15
  25. package/dist/cjs/FloatingPanel/index.native.js +77 -8
  26. package/dist/cjs/FloatingPanel/index.native.js.map +1 -1
  27. package/dist/cjs/FloatingPanel/paintedBox.cjs +80 -0
  28. package/dist/cjs/FloatingPanel/paintedBox.native.js +85 -0
  29. package/dist/cjs/FloatingPanel/paintedBox.native.js.map +1 -0
  30. package/dist/cjs/FloatingPanel/pointAnchor.cjs +22 -3
  31. package/dist/cjs/FloatingPanel/pointAnchor.native.js +26 -7
  32. package/dist/cjs/FloatingPanel/pointAnchor.native.js.map +1 -1
  33. package/dist/cjs/FloatingPanel/useFloatingPanel.cjs +2 -1
  34. package/dist/cjs/FloatingPanel/useFloatingPanel.native.js +3 -1
  35. package/dist/cjs/FloatingPanel/useFloatingPanel.native.js.map +1 -1
  36. package/dist/cjs/fields/FileUpload/index.cjs +8 -3
  37. package/dist/cjs/fields/FileUpload/index.native.js +8 -3
  38. package/dist/cjs/fields/FileUpload/index.native.js.map +1 -1
  39. package/dist/cjs/index.cjs +2 -0
  40. package/dist/cjs/index.native.js +2 -0
  41. package/dist/cjs/index.native.js.map +1 -1
  42. package/dist/esm/Button/IconButtonGroup.mjs +32 -0
  43. package/dist/esm/Button/IconButtonGroup.mjs.map +1 -1
  44. package/dist/esm/Button/IconButtonGroup.native.js +32 -0
  45. package/dist/esm/Button/IconButtonGroup.native.js.map +1 -1
  46. package/dist/esm/Button/circleGlyph.mjs +3 -0
  47. package/dist/esm/Button/circleGlyph.mjs.map +1 -0
  48. package/dist/esm/Button/circleGlyph.native.js +3 -0
  49. package/dist/esm/Button/circleGlyph.native.js.map +1 -0
  50. package/dist/esm/Button/glass.mjs +4 -2
  51. package/dist/esm/Button/glass.mjs.map +1 -1
  52. package/dist/esm/Button/iconButtonGroupContext.mjs.map +1 -1
  53. package/dist/esm/Button/iconButtonGroupContext.native.js.map +1 -1
  54. package/dist/esm/Button/index.mjs +44 -16
  55. package/dist/esm/Button/index.mjs.map +1 -1
  56. package/dist/esm/Button/index.native.js +52 -18
  57. package/dist/esm/Button/index.native.js.map +1 -1
  58. package/dist/esm/Button/webGlass.mjs +36 -0
  59. package/dist/esm/Button/webGlass.mjs.map +1 -0
  60. package/dist/esm/Button/webGlass.native.js +39 -0
  61. package/dist/esm/Button/webGlass.native.js.map +1 -0
  62. package/dist/esm/FloatingPanel/PanelHeader.mjs +24 -0
  63. package/dist/esm/FloatingPanel/PanelHeader.mjs.map +1 -0
  64. package/dist/esm/FloatingPanel/PanelHeader.native.js +25 -0
  65. package/dist/esm/FloatingPanel/PanelHeader.native.js.map +1 -0
  66. package/dist/esm/FloatingPanel/echoContext.mjs +25 -1
  67. package/dist/esm/FloatingPanel/echoContext.mjs.map +1 -1
  68. package/dist/esm/FloatingPanel/echoContext.native.js +26 -1
  69. package/dist/esm/FloatingPanel/echoContext.native.js.map +1 -1
  70. package/dist/esm/FloatingPanel/iconEcho.mjs +77 -0
  71. package/dist/esm/FloatingPanel/iconEcho.mjs.map +1 -0
  72. package/dist/esm/FloatingPanel/iconEcho.native.js +84 -0
  73. package/dist/esm/FloatingPanel/iconEcho.native.js.map +1 -0
  74. package/dist/esm/FloatingPanel/index.mjs +169 -16
  75. package/dist/esm/FloatingPanel/index.mjs.map +1 -1
  76. package/dist/esm/FloatingPanel/index.native.js +79 -10
  77. package/dist/esm/FloatingPanel/index.native.js.map +1 -1
  78. package/dist/esm/FloatingPanel/paintedBox.mjs +52 -0
  79. package/dist/esm/FloatingPanel/paintedBox.mjs.map +1 -0
  80. package/dist/esm/FloatingPanel/paintedBox.native.js +54 -0
  81. package/dist/esm/FloatingPanel/paintedBox.native.js.map +1 -0
  82. package/dist/esm/FloatingPanel/pointAnchor.mjs +22 -3
  83. package/dist/esm/FloatingPanel/pointAnchor.mjs.map +1 -1
  84. package/dist/esm/FloatingPanel/pointAnchor.native.js +26 -7
  85. package/dist/esm/FloatingPanel/pointAnchor.native.js.map +1 -1
  86. package/dist/esm/FloatingPanel/useFloatingPanel.mjs +2 -1
  87. package/dist/esm/FloatingPanel/useFloatingPanel.mjs.map +1 -1
  88. package/dist/esm/FloatingPanel/useFloatingPanel.native.js +3 -1
  89. package/dist/esm/FloatingPanel/useFloatingPanel.native.js.map +1 -1
  90. package/dist/esm/fields/FileUpload/index.mjs +8 -3
  91. package/dist/esm/fields/FileUpload/index.mjs.map +1 -1
  92. package/dist/esm/fields/FileUpload/index.native.js +8 -3
  93. package/dist/esm/fields/FileUpload/index.native.js.map +1 -1
  94. package/dist/esm/index.mjs +2 -1
  95. package/dist/esm/index.mjs.map +1 -1
  96. package/dist/esm/index.native.js +2 -1
  97. package/dist/esm/index.native.js.map +1 -1
  98. package/dist/jsx/Button/IconButtonGroup.mjs +32 -0
  99. package/dist/jsx/Button/IconButtonGroup.mjs.map +1 -1
  100. package/dist/jsx/Button/IconButtonGroup.native.js +32 -0
  101. package/dist/jsx/Button/IconButtonGroup.native.js.map +1 -1
  102. package/dist/jsx/Button/circleGlyph.mjs +3 -0
  103. package/dist/jsx/Button/circleGlyph.mjs.map +1 -0
  104. package/dist/jsx/Button/circleGlyph.native.js +31 -0
  105. package/dist/jsx/Button/circleGlyph.native.js.map +1 -0
  106. package/dist/jsx/Button/glass.mjs +4 -2
  107. package/dist/jsx/Button/glass.mjs.map +1 -1
  108. package/dist/jsx/Button/iconButtonGroupContext.mjs.map +1 -1
  109. package/dist/jsx/Button/iconButtonGroupContext.native.js.map +1 -1
  110. package/dist/jsx/Button/index.mjs +44 -16
  111. package/dist/jsx/Button/index.mjs.map +1 -1
  112. package/dist/jsx/Button/index.native.js +52 -17
  113. package/dist/jsx/Button/index.native.js.map +1 -1
  114. package/dist/jsx/Button/webGlass.mjs +36 -0
  115. package/dist/jsx/Button/webGlass.mjs.map +1 -0
  116. package/dist/jsx/Button/webGlass.native.js +68 -0
  117. package/dist/jsx/Button/webGlass.native.js.map +1 -0
  118. package/dist/jsx/FloatingPanel/PanelHeader.mjs +24 -0
  119. package/dist/jsx/FloatingPanel/PanelHeader.mjs.map +1 -0
  120. package/dist/jsx/FloatingPanel/PanelHeader.native.js +53 -0
  121. package/dist/jsx/FloatingPanel/PanelHeader.native.js.map +1 -0
  122. package/dist/jsx/FloatingPanel/echoContext.mjs +25 -1
  123. package/dist/jsx/FloatingPanel/echoContext.mjs.map +1 -1
  124. package/dist/jsx/FloatingPanel/echoContext.native.js +29 -0
  125. package/dist/jsx/FloatingPanel/echoContext.native.js.map +1 -1
  126. package/dist/jsx/FloatingPanel/iconEcho.mjs +77 -0
  127. package/dist/jsx/FloatingPanel/iconEcho.mjs.map +1 -0
  128. package/dist/jsx/FloatingPanel/iconEcho.native.js +114 -0
  129. package/dist/jsx/FloatingPanel/iconEcho.native.js.map +1 -0
  130. package/dist/jsx/FloatingPanel/index.mjs +169 -16
  131. package/dist/jsx/FloatingPanel/index.mjs.map +1 -1
  132. package/dist/jsx/FloatingPanel/index.native.js +77 -8
  133. package/dist/jsx/FloatingPanel/index.native.js.map +1 -1
  134. package/dist/jsx/FloatingPanel/paintedBox.mjs +52 -0
  135. package/dist/jsx/FloatingPanel/paintedBox.mjs.map +1 -0
  136. package/dist/jsx/FloatingPanel/paintedBox.native.js +85 -0
  137. package/dist/jsx/FloatingPanel/paintedBox.native.js.map +1 -0
  138. package/dist/jsx/FloatingPanel/pointAnchor.mjs +22 -3
  139. package/dist/jsx/FloatingPanel/pointAnchor.mjs.map +1 -1
  140. package/dist/jsx/FloatingPanel/pointAnchor.native.js +26 -7
  141. package/dist/jsx/FloatingPanel/pointAnchor.native.js.map +1 -1
  142. package/dist/jsx/FloatingPanel/useFloatingPanel.mjs +2 -1
  143. package/dist/jsx/FloatingPanel/useFloatingPanel.mjs.map +1 -1
  144. package/dist/jsx/FloatingPanel/useFloatingPanel.native.js +3 -1
  145. package/dist/jsx/FloatingPanel/useFloatingPanel.native.js.map +1 -1
  146. package/dist/jsx/fields/FileUpload/index.mjs +8 -3
  147. package/dist/jsx/fields/FileUpload/index.mjs.map +1 -1
  148. package/dist/jsx/fields/FileUpload/index.native.js +8 -3
  149. package/dist/jsx/fields/FileUpload/index.native.js.map +1 -1
  150. package/dist/jsx/index.js +2 -1
  151. package/dist/jsx/index.js.map +1 -1
  152. package/dist/jsx/index.mjs +2 -1
  153. package/dist/jsx/index.mjs.map +1 -1
  154. package/dist/jsx/index.native.js +2 -0
  155. package/dist/jsx/index.native.js.map +1 -1
  156. package/package.json +8 -8
  157. package/src/Button/Button.accentInk.spec.tsx +39 -8
  158. package/src/Button/Button.iconButton.spec.tsx +143 -3
  159. package/src/Button/Button.stories.tsx +55 -38
  160. package/src/Button/IconButtonGroup.spec.tsx +26 -0
  161. package/src/Button/IconButtonGroup.tsx +39 -0
  162. package/src/Button/circleGlyph.ts +6 -0
  163. package/src/Button/glass.spec.ts +73 -0
  164. package/src/Button/glass.ts +8 -3
  165. package/src/Button/iconButtonGroupContext.ts +2 -1
  166. package/src/Button/index.tsx +68 -17
  167. package/src/Button/webGlass.ts +42 -0
  168. package/src/FloatingPanel/FloatingPanel.iconEcho.spec.tsx +297 -0
  169. package/src/FloatingPanel/FloatingPanel.menuFace.native.spec.tsx +87 -1
  170. package/src/FloatingPanel/PanelHeader.tsx +26 -0
  171. package/src/FloatingPanel/echoContext.ts +45 -0
  172. package/src/FloatingPanel/iconEcho.tsx +118 -0
  173. package/src/FloatingPanel/index.native.tsx +115 -4
  174. package/src/FloatingPanel/index.tsx +238 -16
  175. package/src/FloatingPanel/paintedBox.spec.ts +95 -0
  176. package/src/FloatingPanel/paintedBox.ts +75 -0
  177. package/src/FloatingPanel/pointAnchor.spec.ts +47 -0
  178. package/src/FloatingPanel/pointAnchor.ts +28 -0
  179. package/src/FloatingPanel/useFloatingPanel.ts +6 -1
  180. package/src/fields/FileUpload/FileUpload.spec.tsx +8 -0
  181. package/src/fields/FileUpload/index.tsx +12 -4
  182. package/src/index.ts +2 -0
  183. package/types/Button/IconButtonGroup.d.ts.map +1 -1
  184. package/types/Button/circleGlyph.d.ts +7 -0
  185. package/types/Button/circleGlyph.d.ts.map +1 -0
  186. package/types/Button/glass.d.ts +5 -2
  187. package/types/Button/glass.d.ts.map +1 -1
  188. package/types/Button/iconButtonGroupContext.d.ts +2 -1
  189. package/types/Button/iconButtonGroupContext.d.ts.map +1 -1
  190. package/types/Button/index.d.ts +7 -4
  191. package/types/Button/index.d.ts.map +1 -1
  192. package/types/Button/webGlass.d.ts +32 -0
  193. package/types/Button/webGlass.d.ts.map +1 -0
  194. package/types/FloatingPanel/PanelHeader.d.ts +9 -0
  195. package/types/FloatingPanel/PanelHeader.d.ts.map +1 -0
  196. package/types/FloatingPanel/echoContext.d.ts +24 -0
  197. package/types/FloatingPanel/echoContext.d.ts.map +1 -1
  198. package/types/FloatingPanel/iconEcho.d.ts +29 -0
  199. package/types/FloatingPanel/iconEcho.d.ts.map +1 -0
  200. package/types/FloatingPanel/index.d.ts +10 -0
  201. package/types/FloatingPanel/index.d.ts.map +1 -1
  202. package/types/FloatingPanel/index.native.d.ts +1 -1
  203. package/types/FloatingPanel/index.native.d.ts.map +1 -1
  204. package/types/FloatingPanel/paintedBox.d.ts +29 -0
  205. package/types/FloatingPanel/paintedBox.d.ts.map +1 -0
  206. package/types/FloatingPanel/pointAnchor.d.ts +10 -1
  207. package/types/FloatingPanel/pointAnchor.d.ts.map +1 -1
  208. package/types/FloatingPanel/useFloatingPanel.d.ts.map +1 -1
  209. package/types/fields/FileUpload/index.d.ts.map +1 -1
  210. package/types/index.d.ts +2 -0
  211. package/types/index.d.ts.map +1 -1
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ *
4
+ * LC-176 / LC-266: on the web only iOS Safari takes the glass face (Clay on
5
+ * rv_3bb3f1f65d6b981f).
6
+ */
7
+
8
+ import { createDefaultThemeConfig } from "@multiplatform.one/theme";
9
+ import { cleanup, render } from "@testing-library/react";
10
+ import { createElement } from "react";
11
+ import { TamaguiProvider } from "tamagui";
12
+ import { afterEach, describe, expect, it, vi } from "vitest";
13
+ import { isAppleTouchWeb, loadGlassView } from "./glass";
14
+
15
+ const IPHONE =
16
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1";
17
+ const MAC_SAFARI =
18
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15";
19
+ const houseConfig = createDefaultThemeConfig();
20
+ const PIXEL =
21
+ "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36";
22
+
23
+ describe("isAppleTouchWeb", () => {
24
+ it("an iPhone's Safari takes the glass face", () => {
25
+ expect(isAppleTouchWeb({ userAgent: IPHONE, platform: "iPhone", maxTouchPoints: 5 })).toBe(
26
+ true,
27
+ );
28
+ });
29
+
30
+ it("iPadOS reports a Mac with touch, and is iOS", () => {
31
+ expect(
32
+ isAppleTouchWeb({ userAgent: MAC_SAFARI, platform: "MacIntel", maxTouchPoints: 5 }),
33
+ ).toBe(true);
34
+ });
35
+
36
+ it("a Mac, an Android phone and no navigator draw the bare glyph", () => {
37
+ expect(
38
+ isAppleTouchWeb({ userAgent: MAC_SAFARI, platform: "MacIntel", maxTouchPoints: 0 }),
39
+ ).toBe(false);
40
+ expect(isAppleTouchWeb({ userAgent: PIXEL, platform: "Linux armv8l", maxTouchPoints: 5 })).toBe(
41
+ false,
42
+ );
43
+ expect(isAppleTouchWeb(undefined)).toBe(false);
44
+ });
45
+ });
46
+
47
+ describe("loadGlassView on the web", () => {
48
+ afterEach(() => {
49
+ cleanup();
50
+ vi.unstubAllGlobals();
51
+ });
52
+
53
+ it("iOS Safari's glass circle sits under the glyph it backs", () => {
54
+ vi.stubGlobal("navigator", { userAgent: IPHONE, platform: "iPhone", maxTouchPoints: 5 });
55
+ const Glass = loadGlassView();
56
+ expect(Glass).not.toBeNull();
57
+ const { container } = render(
58
+ createElement(
59
+ TamaguiProvider,
60
+ { config: houseConfig.tamagui, defaultTheme: "light", disableInjectCSS: true },
61
+ createElement(Glass!, { style: { position: "absolute" } }),
62
+ ),
63
+ );
64
+ const face = container.querySelector('[data-mp-glass="web"]') as HTMLElement | null;
65
+ expect(face).not.toBeNull();
66
+ expect(face!.style.zIndex).toBe("-1");
67
+ });
68
+
69
+ it("every other browser draws the bare glyph", () => {
70
+ vi.stubGlobal("navigator", { userAgent: PIXEL, platform: "Linux armv8l", maxTouchPoints: 5 });
71
+ expect(loadGlassView()).toBeNull();
72
+ });
73
+ });
@@ -1,4 +1,5 @@
1
1
  import type { ComponentType, ReactNode } from "react";
2
+ import { isAppleTouchWeb, WebGlassFace } from "./webGlass";
2
3
 
3
4
  /** The slice of expo-glass-effect's `GlassView` an icon button draws. */
4
5
  export type GlassViewComponent = ComponentType<{
@@ -15,13 +16,17 @@ export type GlassContainerComponent = ComponentType<{
15
16
  children?: ReactNode;
16
17
  }>;
17
18
 
19
+ export { isAppleTouchWeb } from "./webGlass";
20
+
18
21
  /**
19
22
  * LC-176: the iOS 26 Liquid Glass material an icon button floats on in a bar.
20
- * Only iOS has it (`glass.native.ts`; the native module is Apple-only, so Android probes null); web, Android and GNOME draw the bare
21
- * tinted glyph.
23
+ * Native iOS draws it from expo-glass-effect (`glass.native.ts`; Android
24
+ * probes null). On the web only iOS Safari takes the glass face (Clay on
25
+ * rv_3bb3f1f65d6b981f: "Is there a way to have the buttons at the bottom be
26
+ * liquid glass if it's an iPhone"); every other browser draws the bare glyph.
22
27
  */
23
28
  export function loadGlassView(): GlassViewComponent | null {
24
- return null;
29
+ return isAppleTouchWeb() ? (WebGlassFace as GlassViewComponent) : null;
25
30
  }
26
31
 
27
32
  /**
@@ -4,7 +4,8 @@ const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : us
4
4
 
5
5
  /**
6
6
  * LC-176: the ground an `IconButtonGroup` paints under its buttons. `media`
7
- * is one shared material capsule and `glass` one shared Liquid Glass capsule,
7
+ * is one shared material capsule and `glass` one shared glass capsule (Liquid
8
+ * Glass on iOS 26, its web twin in iOS Safari),
8
9
  * so a button inside draws its bare glyph instead of its own circle.
9
10
  */
10
11
  export type IconButtonGroupGround = "media" | "glass" | null;
@@ -34,7 +34,7 @@ import {
34
34
  ensureKeyboardModalityTracking,
35
35
  useBarSurface,
36
36
  useElevatedSurface,
37
- useIdentityInkOnSurface,
37
+ useGlyphTintOnSurface,
38
38
  useResolvedKnobs,
39
39
  useTouchSurface,
40
40
  type ControlTier,
@@ -76,8 +76,11 @@ import { useControlGrouped } from "../shared/groupContext";
76
76
  import { useInPanelEcho, usePanelTriggerRadiusReporter } from "../FloatingPanel/echoContext";
77
77
  import { loadGlassView } from "./glass";
78
78
  import { MEDIA_PLATE_WEB_STYLE } from "./mediaPlate";
79
+ import { ICON_BUTTON_CIRCLE_GLYPH_RATIO } from "./circleGlyph";
79
80
  import { useIconButtonGroupGround, useIconButtonGroupMember } from "./iconButtonGroupContext";
80
81
 
82
+ export { ICON_BUTTON_CIRCLE_GLYPH_RATIO };
83
+
81
84
  /**
82
85
  * Coordinated size recipe (Polaris slim/medium/large, Primer s/m/l, Linear
83
86
  * 28/32/40, Spectrum S/M, M3 XS/S). Height, pad, type, icon, gap and radius
@@ -437,32 +440,48 @@ export function iconButtonGlyphPx(rowLinePx: number | undefined): number {
437
440
  return Math.max(ICON_BUTTON_GLYPH_PX, Math.round(rowLinePx / PHOSPHOR_INK_RATIO));
438
441
  }
439
442
 
443
+ /** LC-176: an inline glyph is the text's size; Phosphor's 256 grid pads ~12%. */
444
+ const INLINE_GLYPH_RATIO = 1.2;
445
+
440
446
  /**
441
- * LC-176: a glyph on a circle the caller asked for (`circular`, `outlined`)
442
- * fills this share of the circle, the iOS Settings and Contacts leading-icon
443
- * ratio, never a small glyph lost in a big plate.
447
+ * LC-176: Phosphor bold's stem is 0.12 of its ink, an SF Symbols bold plus's
448
+ * 0.18 (measured at 17 and 24pt). An outline stroke this wide, in the 256-unit
449
+ * grid, brings a bold glyph to SF semibold-bold (0.154); a wider one closes
450
+ * Phosphor bold's 16-unit counters (the trash lid, copy's overlap).
444
451
  */
445
- export const ICON_BUTTON_CIRCLE_GLYPH_RATIO = 0.58;
452
+ export const GLYPH_BOLD_STROKE_UNITS = 8;
446
453
 
447
- /** LC-176: an inline glyph is the text's size; Phosphor's 256 grid pads ~12%. */
448
- const INLINE_GLYPH_RATIO = 1.2;
454
+ function boldStroke(weight: "regular" | "bold", color = "currentColor") {
455
+ return weight === "bold"
456
+ ? {
457
+ stroke: color,
458
+ strokeWidth: GLYPH_BOLD_STROKE_UNITS,
459
+ strokeLinejoin: "round" as const,
460
+ }
461
+ : undefined;
462
+ }
449
463
 
450
464
  function withGlyphWeight(
451
465
  icon: IconLike | undefined,
452
466
  size: number,
453
467
  weight: "regular" | "bold",
468
+ floor = false,
454
469
  ): IconLike | undefined {
455
470
  if (icon == null || icon === false) return icon;
456
- if (isValidElement<{ size?: number; weight?: string }>(icon)) {
471
+ if (isValidElement<{ size?: number; weight?: string; color?: string }>(icon)) {
472
+ const resolved = (icon.props.weight ?? weight) as "regular" | "bold";
473
+ const own = icon.props.size;
457
474
  return cloneElement(icon, {
458
- ...(icon.props.size == null ? { size } : undefined),
475
+ ...(own == null || (floor && typeof own === "number" && own < size) ? { size } : undefined),
459
476
  ...(icon.props.weight == null ? { weight } : undefined),
477
+ ...boldStroke(resolved, icon.props.color),
460
478
  });
461
479
  }
462
480
  if (typeof icon === "function" || (typeof icon === "object" && "$$typeof" in icon)) {
463
481
  return createElement(icon as ComponentType<{ size?: number; weight?: string }>, {
464
482
  size,
465
483
  weight,
484
+ ...boldStroke(weight),
466
485
  });
467
486
  }
468
487
  return icon;
@@ -572,7 +591,7 @@ export function Button({
572
591
  const parentScopeTheme = useTheme();
573
592
  const pageOutlineColor = parentScopeTheme.outlineColor?.val as string | undefined;
574
593
  const hostAccentInk = useAccentOnSurface();
575
- const hostIdentityInk = useIdentityInkOnSurface();
594
+ const glyphTint = useGlyphTintOnSurface();
576
595
  const grouped = useControlGrouped();
577
596
  // LC-176 ICON-BUTTON: with no label, the button is its glyph. It paints no
578
597
  // plate at rest and takes the bar tint, unless the caller asked for a frame
@@ -697,7 +716,7 @@ export function Button({
697
716
  // Accent and intent buttons, chromeless, and grouped segments keep their
698
717
  // own contracts.
699
718
  const tier: ControlTier | undefined =
700
- activeThemeName || chromeless || grouped
719
+ activeThemeName || chromeless || grouped || mediaPlate
701
720
  ? undefined
702
721
  : barSurface
703
722
  ? "plain"
@@ -810,7 +829,13 @@ export function Button({
810
829
  // The media plate is its own state ground; a toggle over media shows its
811
830
  // state in the glyph.
812
831
  const selectedFill = Boolean(isSelected) && !unavailable && !mediaPlate;
813
- const barInk = accentInk && chromeless && !selectedFill && !unavailable ? hostIdentityInk : null;
832
+ // A glyph alone holds 3:1; beside a label both take the label's AA tint.
833
+ const barInk =
834
+ accentInk && chromeless && !selectedFill && !unavailable
835
+ ? labelled
836
+ ? glyphTint.label
837
+ : glyphTint.glyph
838
+ : null;
814
839
  const inlineGlyph = iconButton && inline === true && !nested;
815
840
  // LC-176: back's caret beside its label is a bar glyph too, and so is the
816
841
  // glyph on a plate (media, or the overlay's confirm).
@@ -828,6 +853,17 @@ export function Button({
828
853
  ? loadGlassView()
829
854
  : null;
830
855
  const framedCircle = iconOnly && Boolean(circular) && !nested && !mediaPlate && !confirmPlate;
856
+ // LC-176: a glyph on a plate takes the plate's on-colour at full strength,
857
+ // never the frame's quieter default: white on the media material, the text
858
+ // colour on a neutral circle.
859
+ const plateInk =
860
+ unavailable || barInk || outlinedInk
861
+ ? undefined
862
+ : mediaPlate || (iconButton && groupGround === "media")
863
+ ? "$color12"
864
+ : framedCircle && !activeThemeName
865
+ ? hostScoped({ color: "$color12" }).color
866
+ : undefined;
831
867
  const framedCirclePx =
832
868
  touch && typeof sizeProp !== "number"
833
869
  ? Math.max(sizeTokenPx(resolvedSize), MIN_PRESS_TARGET)
@@ -841,13 +877,22 @@ export function Button({
841
877
  : barGlyph
842
878
  ? touch
843
879
  ? Math.max(glyphFloor, resolveButtonSizeRecipe("$6", recipeFamily, touch).iconSize)
844
- : // A pointer toolbar keeps its token box (LC-54); the glyph fills it
845
- // up to the same floor.
846
- Math.max(recipe.iconSize, Math.min(glyphFloor, recipe.height - 8))
880
+ : iconButton
881
+ ? // A bare glyph has no plate to inset from, so it takes its box
882
+ // less a 2pt optical margin, up to the row's glyph: the SHC
883
+ // "+" at $3 reaches the bar's glyph, a $2 copy beside mono
884
+ // text stays in proportion.
885
+ Math.max(recipe.iconSize, Math.min(glyphFloor, recipe.height - 4))
886
+ : // A pointer toolbar keeps its token box (LC-54); the glyph fills
887
+ // it up to the same floor.
888
+ Math.max(recipe.iconSize, Math.min(glyphFloor, recipe.height - 8))
847
889
  : recipe.iconSize;
890
+ // LC-176: a bar glyph is never smaller than its row, whatever px the caller
891
+ // passed (the DataTable toolbar's 14pt token icons).
892
+ const barGlyphFloor = barGlyph && !framedCircle && !inlineGlyph && !nested;
848
893
  const glyph =
849
894
  barGlyph || inlineGlyph || framedCircle
850
- ? withGlyphWeight(icon, iconSize, resolvedGlyphWeight)
895
+ ? withGlyphWeight(icon, iconSize, resolvedGlyphWeight, barGlyphFloor)
851
896
  : icon;
852
897
 
853
898
  // LC-158: the frame keeps its recipe height under Dynamic Type, so the
@@ -912,6 +957,7 @@ export function Button({
912
957
  props.color ??
913
958
  (unavailable ? disabledText.color : undefined) ??
914
959
  barInk ??
960
+ plateInk ??
915
961
  (textColor
916
962
  ? outlinedOnHost
917
963
  ? hostScoped({ color: textColor }).color
@@ -933,7 +979,7 @@ export function Button({
933
979
  ) : null;
934
980
  const glyphAfter =
935
981
  iconAfter && (barGlyph || inlineGlyph || framedCircle)
936
- ? withGlyphWeight(iconAfter, iconSize, resolvedGlyphWeight)
982
+ ? withGlyphWeight(iconAfter, iconSize, resolvedGlyphWeight, barGlyphFloor)
937
983
  : iconAfter;
938
984
  const iconAfterElement = glyphAfter ? (
939
985
  <View flexShrink={0}>
@@ -1098,6 +1144,10 @@ export function Button({
1098
1144
  {...(inlineGlyph ? { marginInlineStart: "$2" as const } : undefined)}
1099
1145
  // The glass rounds itself, unclipped.
1100
1146
  {...(GlassView ? { borderRadius: 100000 } : undefined)}
1147
+ // On the web the absolute glass circle needs its button as the
1148
+ // containing block and stacking context, or it fills the page and
1149
+ // paints over the glyph.
1150
+ {...(GlassView && isWeb ? { position: "relative" as const, zIndex: 0 } : undefined)}
1101
1151
  {...(confirmPlate ? { borderRadius: 100000 } : undefined)}
1102
1152
  {...(mediaPlate
1103
1153
  ? {
@@ -1117,6 +1167,7 @@ export function Button({
1117
1167
  // text/icon color (currentColor) to the hue's readable text tier.
1118
1168
  {...(outlinedInk ? { color: outlinedInk } : undefined)}
1119
1169
  {...(barInk ? { color: barInk } : undefined)}
1170
+ {...(plateInk ? { color: plateInk } : undefined)}
1120
1171
  // LC-40 DISABLED-VISIBLE: keepLabel washes the chrome (muted fill/
1121
1172
  // border) while label/glyphs re-anchor to the lowest AA text tier
1122
1173
  // (`color` inherits into currentColor icons); dimWhole dims the
@@ -0,0 +1,42 @@
1
+ import { isDarkThemeName } from "@multiplatform.one/theme";
2
+ import { createElement } from "react";
3
+ import { useThemeName, YStack } from "tamagui";
4
+ import { MEDIA_PLATE_WEB_STYLE } from "./mediaPlate";
5
+
6
+ type GlassNavigator = { userAgent?: string; platform?: string; maxTouchPoints?: number };
7
+
8
+ /**
9
+ * LC-266: iOS Safari, iPadOS included (it reports itself as a Mac with a
10
+ * touch screen). The one place web asks which platform it is on, so an iPhone
11
+ * user gets the iPhone's bar buttons in the browser too.
12
+ */
13
+ export function isAppleTouchWeb(
14
+ nav: GlassNavigator | undefined = typeof navigator === "undefined" ? undefined : navigator,
15
+ ): boolean {
16
+ if (!nav) return false;
17
+ if (/iPhone|iPad|iPod/.test(nav.userAgent ?? "")) return true;
18
+ return /Mac/.test(nav.platform ?? nav.userAgent ?? "") && (nav.maxTouchPoints ?? 0) > 1;
19
+ }
20
+
21
+ const webGlassMarker = { "data-mp-glass": "web" };
22
+
23
+ /**
24
+ * LC-176: the Liquid Glass face iOS Safari draws under a bar's icon buttons,
25
+ * one circle for a lone button or one capsule for an `IconButtonGroup`. It is
26
+ * a layer under the glyphs (absolute, z -1 in the button's stacking context):
27
+ * a near-white material in light and a raised grey in dark, edged by a soft
28
+ * drop shadow alone, with the media plate's blur.
29
+ */
30
+ export function WebGlassFace({ style }: { style?: unknown }) {
31
+ const dark = isDarkThemeName(useThemeName());
32
+ return createElement(YStack, {
33
+ pointerEvents: "none",
34
+ backgroundColor: dark ? "$color4" : "$color1",
35
+ opacity: 0.92,
36
+ shadowColor: "$shadowColor",
37
+ shadowRadius: 10,
38
+ shadowOffset: { width: 0, height: 3 },
39
+ ...webGlassMarker,
40
+ style: { ...(style as object), zIndex: -1, ...MEDIA_PLATE_WEB_STYLE },
41
+ });
42
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * LC-112 icon trigger echo (FZ-8480, Clay's rv_2607879d3bdaf9bf): a panel
3
+ * that covers the icon button it opened from draws that button again in its
4
+ * exact spot, same size and plate, as an X (Round 37: every icon trigger,
5
+ * the + and a menu's button alike). It closes the panel and reads "Close".
6
+ */
7
+ import { PlusIcon, XIcon } from "@phosphor-icons/react";
8
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
9
+ import { fireEvent, render, waitFor, within } from "@testing-library/react";
10
+ import { useState } from "react";
11
+ import { Surface, useBarSurface } from "@multiplatform.one/theme";
12
+ import { afterEach, describe, expect, it, vi } from "vitest";
13
+ import { Text } from "tamagui";
14
+ import { Button } from "../Button";
15
+ import { FloatingPanel } from "./index";
16
+ import { usePanelIconEchoBox } from "./echoContext";
17
+ import { PanelHeader } from "./PanelHeader";
18
+ import type { IconEcho } from "./iconEcho";
19
+
20
+ type Rect = { x: number; y: number; width: number; height: number };
21
+
22
+ function measure(trigger: Rect, panelWidth = 240, panelHeight = 160, surface?: Rect) {
23
+ const original = HTMLElement.prototype.getBoundingClientRect;
24
+ const rect = {
25
+ ...trigger,
26
+ top: trigger.y,
27
+ left: trigger.x,
28
+ right: trigger.x + trigger.width,
29
+ bottom: trigger.y + trigger.height,
30
+ toJSON: () => ({}),
31
+ };
32
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
33
+ function (this: HTMLElement) {
34
+ if (this.hasAttribute("data-fp-reference")) return rect;
35
+ if (surface && this.hasAttribute("data-fp-cover-surface")) {
36
+ return {
37
+ ...surface,
38
+ top: surface.y,
39
+ left: surface.x,
40
+ right: surface.x + surface.width,
41
+ bottom: surface.y + surface.height,
42
+ toJSON: () => ({}),
43
+ };
44
+ }
45
+ return original.call(this);
46
+ },
47
+ );
48
+ vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(
49
+ function (this: HTMLElement) {
50
+ return this.hasAttribute("data-fp-panel") ? panelWidth : 0;
51
+ },
52
+ );
53
+ vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(
54
+ function (this: HTMLElement) {
55
+ return this.hasAttribute("data-fp-panel") ? panelHeight : 0;
56
+ },
57
+ );
58
+ }
59
+
60
+ function EchoBoxProbe() {
61
+ const box = usePanelIconEchoBox();
62
+ return box ? (
63
+ <span
64
+ data-testid="echo-box"
65
+ data-height={box.height}
66
+ data-left={box.left}
67
+ data-right={box.right}
68
+ />
69
+ ) : null;
70
+ }
71
+
72
+ function AddPanel({ kind = "close", header = false }: { kind?: IconEcho; header?: boolean }) {
73
+ const [open, setOpen] = useState(false);
74
+ return (
75
+ <FloatingPanel
76
+ open={open}
77
+ onOpenChange={setOpen}
78
+ anchorMode="cover-trigger"
79
+ compactFace="menu"
80
+ triggerEcho={false}
81
+ iconEcho={kind}
82
+ coverAlign="auto"
83
+ widthMode="fit-content"
84
+ triggerSizing="content"
85
+ contentPadding="none"
86
+ trigger={<Button icon={PlusIcon} aria-label="Add node" />}
87
+ >
88
+ {header ? (
89
+ <PanelHeader>
90
+ <Text>Add a node</Text>
91
+ <EchoBoxProbe />
92
+ </PanelHeader>
93
+ ) : (
94
+ <Text>Add a node</Text>
95
+ )}
96
+ <Button>Copy</Button>
97
+ </FloatingPanel>
98
+ );
99
+ }
100
+
101
+ const glyphPath = (node: Element | null | undefined) =>
102
+ node?.querySelector("svg path")?.getAttribute("d");
103
+
104
+ async function open(result: ReturnType<typeof renderWithProviders>) {
105
+ fireEvent.click(result.getByRole("button", { name: "Add node" }), { detail: 0 });
106
+ const panel = (await result.findByTestId("floating-panel-viewport")) as HTMLElement;
107
+ await waitFor(() => expect(panel.style.visibility).toBe("visible"));
108
+ return panel;
109
+ }
110
+
111
+ afterEach(() => {
112
+ vi.restoreAllMocks();
113
+ });
114
+
115
+ describe("LC-112 icon trigger echo on the web (FZ-8480)", () => {
116
+ const viewportWidth = () => document.documentElement.clientWidth || window.innerWidth;
117
+
118
+ it("draws the + again as an X on the button's exact box at the panel's top trailing corner", async () => {
119
+ const button = { x: viewportWidth() - 16 - 44, y: 12, width: 44, height: 44 };
120
+ measure(button, 240);
121
+ const result = renderWithProviders(<AddPanel />);
122
+ const panel = await open(result);
123
+ const panelLeft = button.x + button.width - 240;
124
+ expect(panel.style.top).toBe(`${button.y}px`);
125
+ expect(panel.style.left).toBe(`${panelLeft}px`);
126
+ const row = panel.querySelector<HTMLElement>("[data-fp-icon-echo]") as HTMLElement;
127
+ expect(row.style.order).toBe("-1");
128
+ expect(row.style.height).toBe(`${button.height}px`);
129
+ // The X shares the content's first row, like a sheet's close button: it
130
+ // takes no height of its own.
131
+ expect(row.getAttribute("data-fp-icon-echo")).toBe("overlay");
132
+ const borderTop = parseFloat(getComputedStyle(panel).borderTopWidth) || 0;
133
+ expect(row.style.marginBottom).toBe(`${-(button.height - borderTop)}px`);
134
+ const box = row.querySelector<HTMLElement>("[data-fp-icon-echo-box]") as HTMLElement;
135
+ expect(box.style.width).toBe(`${button.width}px`);
136
+ expect(box.style.height).toBe(`${button.height}px`);
137
+ expect(box.style.marginLeft).toBe(`${button.x - panelLeft}px`);
138
+ const echo = within(panel).getByRole("button", { name: "Close" });
139
+ const x = render(<XIcon weight="bold" />);
140
+ expect(glyphPath(echo)).toBe(glyphPath(x.container));
141
+ x.unmount();
142
+ expect(glyphPath(echo)).not.toBe(glyphPath(result.getByRole("button", { name: "Add node" })));
143
+ expect(panel.querySelector("[data-fp-trigger-row]")).toBeNull();
144
+ });
145
+
146
+ it("renders the echo on the trigger's bar surface, so it keeps the bar button's plate", async () => {
147
+ measure({ x: 16, y: 12, width: 44, height: 44 });
148
+ function BarProbe({ icon: _icon, ...rest }: { icon?: unknown; [key: string]: unknown }) {
149
+ const bar = useBarSurface();
150
+ const press = rest.onPress as (() => void) | undefined;
151
+ return (
152
+ <button
153
+ type="button"
154
+ aria-label={rest["aria-label"] as string}
155
+ data-bar={String(bar)}
156
+ onClick={() => press?.()}
157
+ />
158
+ );
159
+ }
160
+ const result = renderWithProviders(
161
+ <Surface bar>
162
+ <FloatingPanel
163
+ open
164
+ onOpenChange={() => {}}
165
+ anchorMode="cover-trigger"
166
+ compactFace="menu"
167
+ triggerEcho={false}
168
+ iconEcho="close"
169
+ coverAlign="auto"
170
+ widthMode="fit-content"
171
+ triggerSizing="content"
172
+ trigger={<BarProbe icon={PlusIcon} aria-label="Add node" />}
173
+ >
174
+ <Text>Add a node</Text>
175
+ </FloatingPanel>
176
+ </Surface>,
177
+ );
178
+ const panel = (await result.findByTestId("floating-panel-viewport")) as HTMLElement;
179
+ expect(within(panel).getByRole("button", { name: "Close" }).getAttribute("data-bar")).toBe(
180
+ "true",
181
+ );
182
+ });
183
+
184
+ it("covers a grouped trigger's whole plate, only the pressed trigger showing as its X", async () => {
185
+ const button = { x: viewportWidth() - 16 - 44, y: 12, width: 44, height: 44 };
186
+ const plate = { x: button.x - 100, y: 12, width: 144, height: 44 };
187
+ measure(button, 120, 160, plate);
188
+ const result = renderWithProviders(
189
+ <div data-fp-cover-surface="">
190
+ <Button icon={PlusIcon} aria-label="Search" />
191
+ <AddPanel kind="menu" />
192
+ </div>,
193
+ );
194
+ const panel = await open(result);
195
+ // The plate is wider than the menu, so the menu takes the plate's width
196
+ // and both edges.
197
+ expect(panel.style.left).toBe(`${plate.x}px`);
198
+ expect(panel.style.top).toBe(`${plate.y}px`);
199
+ const row = panel.querySelector<HTMLElement>("[data-fp-icon-echo]") as HTMLElement;
200
+ // The band covers the whole plate; the group's other buttons are covered,
201
+ // not echoed.
202
+ expect(row.getAttribute("data-fp-icon-echo")).toBe("band");
203
+ expect(row.style.height).toBe(`${plate.height}px`);
204
+ expect(row.querySelector("[data-fp-group-echo]")).toBeNull();
205
+ expect(within(panel).queryByRole("button", { name: "Search" })).toBeNull();
206
+ const box = row.querySelector<HTMLElement>("[data-fp-icon-echo-box]") as HTMLElement;
207
+ expect(box.style.marginLeft).toBe(`${button.x - plate.x}px`);
208
+ expect(within(panel).getByRole("button", { name: "Close" })).toBeTruthy();
209
+ });
210
+
211
+ it("closes the panel when the echo is pressed", async () => {
212
+ measure({ x: 16, y: 12, width: 44, height: 44 });
213
+ const result = renderWithProviders(<AddPanel />);
214
+ const panel = await open(result);
215
+ fireEvent.click(within(panel).getByRole("button", { name: "Close" }));
216
+ await waitFor(() => expect(result.queryByTestId("floating-panel-viewport")).toBeNull());
217
+ });
218
+
219
+ it("turns a menu trigger into an X too, in its own band", async () => {
220
+ measure({ x: 16, y: 12, width: 44, height: 44 });
221
+ const result = renderWithProviders(<AddPanel kind="menu" />);
222
+ const panel = await open(result);
223
+ const echo = within(panel).getByRole("button", { name: "Close" });
224
+ const x = render(<XIcon weight="bold" />);
225
+ expect(glyphPath(echo)).toBe(glyphPath(x.container));
226
+ x.unmount();
227
+ // A menu's rows run the full width, so its X takes its own band.
228
+ const row = panel.querySelector<HTMLElement>("[data-fp-icon-echo]") as HTMLElement;
229
+ expect(row.getAttribute("data-fp-icon-echo")).toBe("band");
230
+ expect(row.style.marginBottom).toBe("");
231
+ });
232
+
233
+ it("puts a panel header on the X's line, clear of the X", async () => {
234
+ const button = { x: viewportWidth() - 16 - 44, y: 12, width: 44, height: 44 };
235
+ measure(button, 240);
236
+ const result = renderWithProviders(<AddPanel header />);
237
+ const panel = await open(result);
238
+ expect(panel.querySelector("[data-fp-panel-header]")).toBeTruthy();
239
+ const box = await within(panel).findByTestId("echo-box");
240
+ // The header takes the X's height, and the X sits at the end edge, so it
241
+ // keeps the X's whole box clear there: from the X's start to the panel's end.
242
+ const panelLeft = button.x + button.width - 240;
243
+ expect(box.dataset.height).toBe(`${button.height}`);
244
+ expect(box.dataset.left).toBe("0");
245
+ expect(box.dataset.right).toBe(`${240 - (button.x - panelLeft)}`);
246
+ });
247
+
248
+ it("keeps a content-sized trigger on a centring row's line", async () => {
249
+ measure({ x: 16, y: 12, width: 44, height: 44 });
250
+ const centred = renderWithProviders(
251
+ <div style={{ display: "flex", flexDirection: "row", alignItems: "center", height: 60 }}>
252
+ <AddPanel />
253
+ </div>,
254
+ );
255
+ const inRow = centred.container.querySelector<HTMLElement>(
256
+ "[data-fp-reference]",
257
+ ) as HTMLElement;
258
+ await waitFor(() => expect(getComputedStyle(inRow).alignSelf).not.toBe("flex-start"));
259
+ centred.unmount();
260
+ const column = renderWithProviders(
261
+ <div style={{ display: "flex", flexDirection: "column", alignItems: "stretch" }}>
262
+ <AddPanel />
263
+ </div>,
264
+ );
265
+ const inColumn = column.container.querySelector<HTMLElement>(
266
+ "[data-fp-reference]",
267
+ ) as HTMLElement;
268
+ expect(getComputedStyle(inColumn).alignSelf).toBe("flex-start");
269
+ });
270
+
271
+ it("comes last in the document, so a keyboard open lands on the content first", async () => {
272
+ measure({ x: 16, y: 12, width: 44, height: 44 });
273
+ const result = renderWithProviders(<AddPanel />);
274
+ const panel = await open(result);
275
+ const buttons = within(panel).getAllByRole("button");
276
+ expect(buttons.map((b) => b.textContent || b.getAttribute("aria-label"))).toEqual([
277
+ "Copy",
278
+ "Close",
279
+ ]);
280
+ });
281
+
282
+ it("opens up from the button's bottom edge near the bottom, the echo on the bottom row", async () => {
283
+ const button = { x: 16, y: window.innerHeight - 60, width: 44, height: 44 };
284
+ measure(button, 240, 300);
285
+ const result = renderWithProviders(<AddPanel />);
286
+ const panel = await open(result);
287
+ const frame = getComputedStyle(panel);
288
+ const borders =
289
+ (parseFloat(frame.borderTopWidth) || 0) + (parseFloat(frame.borderBottomWidth) || 0);
290
+ expect(parseFloat(panel.style.top) + 300 + borders).toBe(button.y + button.height);
291
+ expect(panel.style.left).toBe(`${button.x}px`);
292
+ const row = panel.querySelector<HTMLElement>("[data-fp-icon-echo]") as HTMLElement;
293
+ expect(row.style.order).toBe("1");
294
+ expect(panel.style.transformOrigin).toBe("bottom left");
295
+ expect(result.queryByTestId("floating-panel-sheet")).toBeNull();
296
+ });
297
+ });