@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
@@ -4,7 +4,8 @@
4
4
  * so only inside another overlay, so a sheet never stacks on a sheet.
5
5
  */
6
6
  import { renderWithProviders } from "@multiplatform.one/test-utils";
7
- import { act } from "@testing-library/react";
7
+ import { act, render } from "@testing-library/react";
8
+ import { PlusIcon, XIcon } from "@phosphor-icons/react";
8
9
  import { useState, type ReactNode } from "react";
9
10
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
11
  import { Text, XStack, YStack, getTokens, getVariableValue } from "tamagui";
@@ -454,3 +455,88 @@ describe("FloatingPanel native menu face panelWidth (LC-112, FZ-8438 r3)", () =>
454
455
  expect(open(900).style.width).toBe(`${room}px`);
455
456
  });
456
457
  });
458
+
459
+ function EchoMenu({ kind }: { kind: "close" | "menu" }) {
460
+ const [open, setOpen] = useState(false);
461
+ return (
462
+ <FloatingPanel
463
+ open={open}
464
+ onOpenChange={setOpen}
465
+ compactFace="menu"
466
+ anchorMode="cover-trigger"
467
+ triggerEcho={false}
468
+ iconEcho={kind}
469
+ coverAlign="auto"
470
+ triggerSizing="content"
471
+ contentPadding="none"
472
+ trigger={<Button icon={PlusIcon} aria-label="Add node" />}
473
+ triggerA11y={{ label: "Add node" }}
474
+ >
475
+ <Text>Add a node</Text>
476
+ </FloatingPanel>
477
+ );
478
+ }
479
+
480
+ const iconEcho = () =>
481
+ document.querySelector<HTMLElement>('[data-testid="floating-panel-icon-echo"]');
482
+ const iconBand = () =>
483
+ document.querySelector<HTMLElement>('[data-testid="floating-panel-icon-echo-band"]');
484
+ const glyphPath = (node: Element | null) => node?.querySelector("svg path")?.getAttribute("d");
485
+
486
+ describe("LC-112 icon trigger echo on native (FZ-8480, rv_2607879d3bdaf9bf)", () => {
487
+ it("draws the + again as an X in the button's own box, at the menu's top trailing corner", () => {
488
+ trigger = { x: 330, y: 60, width: 44, height: 44 };
489
+ const result = renderWithProviders(<EchoMenu kind="close" />);
490
+ tap(result.getAllByRole("button", { name: "Add node" })[0]!);
491
+ const panel = menu() as HTMLElement;
492
+ layout(panel, MENU_MIN_WIDTH, 180);
493
+ expect(panel.style.top).toBe(`${trigger.y}px`);
494
+ expect(panel.style.left).toBe(`${trigger.x + trigger.width - MENU_MIN_WIDTH}px`);
495
+ const band = iconBand() as HTMLElement;
496
+ expect(band.style.height).toBe("44px");
497
+ const box = band.firstElementChild as HTMLElement;
498
+ expect(box.style.width).toBe("44px");
499
+ expect(box.style.height).toBe("44px");
500
+ // The box sits where the trigger is: its start offset is the trigger's x
501
+ // less the menu's left edge.
502
+ expect(box.style.marginInlineStart || box.style.marginLeft).toBe(
503
+ `${trigger.x - (trigger.x + trigger.width - MENU_MIN_WIDTH)}px`,
504
+ );
505
+ const echoButton = iconEcho() as HTMLElement;
506
+ expect(echoButton.getAttribute("aria-label")).toBe("Close");
507
+ const x = render(<XIcon weight="bold" />);
508
+ expect(glyphPath(echoButton)).toBe(glyphPath(x.container));
509
+ x.unmount();
510
+ expect(echo()).toBeNull();
511
+ });
512
+
513
+ it("closes the menu when the echo is pressed", () => {
514
+ trigger = { x: 330, y: 60, width: 44, height: 44 };
515
+ const result = renderWithProviders(<EchoMenu kind="close" />);
516
+ tap(result.getAllByRole("button", { name: "Add node" })[0]!);
517
+ layout(menu() as HTMLElement, MENU_MIN_WIDTH, 180);
518
+ act(() => {
519
+ vi.advanceTimersByTime(1000);
520
+ });
521
+ tap(iconEcho() as HTMLElement);
522
+ expect(menu()).toBeNull();
523
+ });
524
+
525
+ it("turns a menu trigger into an X too, and opens up from the button's bottom edge near the bottom", () => {
526
+ trigger = { x: 20, y: 760, width: 44, height: 44 };
527
+ const result = renderWithProviders(<EchoMenu kind="menu" />);
528
+ const live = result.getAllByRole("button", { name: "Add node" })[0]!;
529
+ tap(live);
530
+ const panel = menu() as HTMLElement;
531
+ layout(panel, MENU_MIN_WIDTH, 300);
532
+ expect(parseFloat(panel.style.top) + 300).toBe(trigger.y + trigger.height);
533
+ const surface = frame() as HTMLElement;
534
+ const band = iconBand() as HTMLElement;
535
+ const clip = surface.querySelector('[data-testid="floating-panel-menu-clip"]') as HTMLElement;
536
+ expect(clip.lastElementChild).toBe(band);
537
+ const x = render(<XIcon weight="bold" />);
538
+ expect(glyphPath(iconEcho())).toBe(glyphPath(x.container));
539
+ x.unmount();
540
+ expect(glyphPath(iconEcho())).not.toBe(glyphPath(live));
541
+ });
542
+ });
@@ -0,0 +1,26 @@
1
+ import { XStack, type XStackProps } from "tamagui";
2
+ import { usePanelIconEchoBox } from "./echoContext";
3
+
4
+ /**
5
+ * A floating panel's header row (LC-112, FZ-8480 Round 37). Over an
6
+ * icon-trigger panel whose X shares the header's row, it takes the X's
7
+ * height and centres its content on the X's line, keeping clear of the X;
8
+ * anywhere else it is the panel's padded top row.
9
+ */
10
+ export function PanelHeader({ children, ...rest }: XStackProps) {
11
+ const echo = usePanelIconEchoBox();
12
+ return (
13
+ <XStack
14
+ alignItems="center"
15
+ gap="$2"
16
+ paddingLeft={echo?.left || "$4"}
17
+ paddingRight={echo?.right || "$4"}
18
+ paddingTop={echo ? 0 : "$4"}
19
+ {...(echo ? { minHeight: echo.height } : undefined)}
20
+ data-fp-panel-header=""
21
+ {...rest}
22
+ >
23
+ {children}
24
+ </XStack>
25
+ );
26
+ }
@@ -20,3 +20,48 @@ export const PanelTriggerRadiusContext = createContext<((radius: number) => void
20
20
  export function usePanelTriggerRadiusReporter(): ((radius: number) => void) | null {
21
21
  return useContext(PanelTriggerRadiusContext);
22
22
  }
23
+
24
+ /**
25
+ * The X a panel lays over its content's header row (LC-112, FZ-8480 Round
26
+ * 37): its height, and how far the header keeps clear of it on each side.
27
+ * A panel header sits on the X's line and never runs under it.
28
+ */
29
+ export interface PanelIconEchoBox {
30
+ height: number;
31
+ left: number;
32
+ right: number;
33
+ }
34
+
35
+ export const PanelIconEchoBoxContext = createContext<PanelIconEchoBox | null>(null);
36
+
37
+ export function usePanelIconEchoBox(): PanelIconEchoBox | null {
38
+ return useContext(PanelIconEchoBoxContext);
39
+ }
40
+
41
+ /**
42
+ * The header's clearance around an X `offset` in from the panel's start edge:
43
+ * the X's side keeps clear of it, the other side keeps none of its own.
44
+ */
45
+ export function echoHeaderBox({
46
+ offset,
47
+ width,
48
+ height,
49
+ panelWidth,
50
+ rtl = false,
51
+ }: {
52
+ offset: number;
53
+ width: number;
54
+ height: number;
55
+ panelWidth: number;
56
+ rtl?: boolean;
57
+ }): PanelIconEchoBox {
58
+ const atStart = offset + width / 2 < panelWidth / 2;
59
+ const start = atStart ? Math.max(0, offset + width) : 0;
60
+ const end = atStart ? 0 : Math.max(0, panelWidth - offset);
61
+ return { height, left: rtl ? end : start, right: rtl ? start : end };
62
+ }
63
+
64
+ export function sameEchoBox(a: PanelIconEchoBox | null, b: PanelIconEchoBox | null): boolean {
65
+ if (a == null || b == null) return a === b;
66
+ return a.height === b.height && a.left === b.left && a.right === b.right;
67
+ }
@@ -0,0 +1,118 @@
1
+ import { XIcon } from "@phosphor-icons/react";
2
+ import { cloneElement, isValidElement, useState, type ReactElement, type ReactNode } from "react";
3
+ import { SurfaceContext, type SurfaceIntent } from "@multiplatform.one/theme";
4
+ import { useTheme, View } from "tamagui";
5
+ import { ICON_BUTTON_CIRCLE_GLYPH_RATIO } from "../Button/circleGlyph";
6
+ import { t } from "../shared/t";
7
+
8
+ /**
9
+ * How a panel covering an icon-only trigger shows the pressed button (LC-112,
10
+ * FZ-8480 Round 37): in its exact spot, turned into an X that closes the
11
+ * panel, the way iOS morphs a + into an X. `"close"` lays the X over the
12
+ * content's header row (a panel's title shares the X's line); `"menu"` gives
13
+ * the X its own band above the rows, which run the panel's full width.
14
+ */
15
+ export type IconEcho = "close" | "menu";
16
+
17
+ export const ICON_ECHO_TEST_ID = "floating-panel-icon-echo";
18
+
19
+ const DROPPED_PROPS = [
20
+ "id",
21
+ "onLongPress",
22
+ "onKeyDown",
23
+ "onContextMenu",
24
+ "aria-haspopup",
25
+ "aria-expanded",
26
+ "aria-controls",
27
+ "aria-describedby",
28
+ "tooltip",
29
+ ] as const;
30
+
31
+ /**
32
+ * An X sits in the panel's corner over the content's header row, the way a
33
+ * sheet's close button shares the row with its title; a menu's X takes its
34
+ * own band above the rows. A panel that opened upward always gives the echo
35
+ * its own band.
36
+ */
37
+ export function iconEchoOverlays(kind: IconEcho, up: boolean): boolean {
38
+ return kind === "close" && !up;
39
+ }
40
+
41
+ /**
42
+ * The pressed trigger drawn again over its panel as an X: the same element
43
+ * with the same size and plate and an X for its glyph, a fresh render that
44
+ * shares none of the consumer's refs. It renders on the trigger's own
45
+ * surface, so a bar button keeps its bar treatment (on iOS its own Liquid
46
+ * Glass circle) inside the panel. Pressing it closes the panel, and it reads
47
+ * "Close". A button (anything with an `icon`) is the control itself; any
48
+ * other trigger (an avatar) becomes an X in a circle of its size.
49
+ */
50
+ export function iconEchoElement(
51
+ trigger: ReactNode,
52
+ kind: IconEcho,
53
+ onClose: () => void,
54
+ surface?: SurfaceIntent,
55
+ ): ReactNode {
56
+ const echo = echoOf(trigger, kind, onClose);
57
+ if (echo == null || !surface) return echo;
58
+ return <SurfaceContext.Provider {...surface}>{echo}</SurfaceContext.Provider>;
59
+ }
60
+
61
+ function echoOf(trigger: ReactNode, _kind: IconEcho, onClose: () => void): ReactNode {
62
+ if (!isValidElement(trigger)) return null;
63
+ const props = trigger.props as { icon?: unknown };
64
+ const label = t("Close");
65
+ const dropped = Object.fromEntries(DROPPED_PROPS.map((name) => [name, undefined]));
66
+ if (props.icon != null) {
67
+ return cloneElement(trigger as ReactElement<Record<string, unknown>>, {
68
+ ...dropped,
69
+ ref: null,
70
+ icon: XIcon,
71
+ "aria-label": label,
72
+ onPress: onClose,
73
+ testID: ICON_ECHO_TEST_ID,
74
+ "data-testid": ICON_ECHO_TEST_ID,
75
+ });
76
+ }
77
+ return <CircleClose label={label} onClose={onClose} />;
78
+ }
79
+
80
+ /** The plate an avatar draws by default, so its X keeps the avatar's circle. */
81
+ const avatarPlate = "$selectedBackground";
82
+
83
+ /** The X's glyph before the circle has measured itself. */
84
+ const circleGlyphFallbackPx = 18;
85
+
86
+ /** An X in a circle the size of the trigger it replaces (an avatar). */
87
+ function CircleClose({ label, onClose }: { label: string; onClose: () => void }) {
88
+ const theme = useTheme();
89
+ const [side, setSide] = useState(0);
90
+ return (
91
+ <View
92
+ role="button"
93
+ aria-label={label}
94
+ tabIndex={0}
95
+ cursor="pointer"
96
+ width="100%"
97
+ height="100%"
98
+ borderRadius={100000}
99
+ backgroundColor={avatarPlate}
100
+ alignItems="center"
101
+ justifyContent="center"
102
+ pressStyle={{ opacity: 0.6 }}
103
+ onPress={onClose}
104
+ onLayout={(event) =>
105
+ setSide(Math.min(event.nativeEvent.layout.width, event.nativeEvent.layout.height))
106
+ }
107
+ testID={ICON_ECHO_TEST_ID}
108
+ data-testid={ICON_ECHO_TEST_ID}
109
+ >
110
+ <XIcon
111
+ size={side > 0 ? Math.round(side * ICON_BUTTON_CIRCLE_GLYPH_RATIO) : circleGlyphFallbackPx}
112
+ weight="bold"
113
+ color={theme.selectedColor?.val ?? theme.color?.val}
114
+ aria-hidden
115
+ />
116
+ </View>
117
+ );
118
+ }
@@ -90,7 +90,12 @@ import {
90
90
  type GestureResponderEvent,
91
91
  } from "react-native";
92
92
  import { SafeAreaInsetsContext, initialWindowMetrics } from "react-native-safe-area-context";
93
- import { Surface, useResolvedKnobs, motionDurationMs } from "@multiplatform.one/theme";
93
+ import {
94
+ Surface,
95
+ SurfaceContext,
96
+ useResolvedKnobs,
97
+ motionDurationMs,
98
+ } from "@multiplatform.one/theme";
94
99
  import { View, YStack } from "tamagui";
95
100
  import { useInsideOverlay, useOverlayZIndex } from "../shared/overlayStack";
96
101
  import { OverlayWindow } from "../shared/OverlayWindow";
@@ -100,7 +105,12 @@ import { useKeyboardInset } from "../shared/useKeyboardInset";
100
105
  import type { FloatingPanelProps } from "./index";
101
106
  import { MENU_MIN_WIDTH, resolveCompactFace } from "./compactFace";
102
107
  import { echoOffset, type PanelWidth, panelWidthBox, screenBounds } from "./panelWidth";
103
- import { PanelEchoContext, PanelTriggerRadiusContext } from "./echoContext";
108
+ import {
109
+ echoHeaderBox,
110
+ PanelEchoContext,
111
+ PanelIconEchoBoxContext,
112
+ PanelTriggerRadiusContext,
113
+ } from "./echoContext";
104
114
  import {
105
115
  placeAtPoint,
106
116
  placeBesideTrigger,
@@ -112,6 +122,7 @@ import {
112
122
  import { RegularPanel } from "./RegularPanel";
113
123
  import { panelViewportPadding, resolvePanelWidthMode } from "./useFloatingPanel";
114
124
  import { TrailingActionOverlay, TriggerWithTrailingAction } from "./trailingAction";
125
+ import { iconEchoElement, iconEchoOverlays, type IconEcho } from "./iconEcho";
115
126
 
116
127
  // Re-export the hook + constants so Select / Combobox / external consumers
117
128
  // resolve identically on native (their floating cores are unused on device
@@ -167,6 +178,7 @@ export function FloatingPanel({
167
178
  triggerRow,
168
179
  triggerEcho = true,
169
180
  coverAlign = "start",
181
+ iconEcho,
170
182
  anchorMode,
171
183
  panelWidth = "content",
172
184
  sheet,
@@ -175,6 +187,7 @@ export function FloatingPanel({
175
187
  }: FloatingPanelProps) {
176
188
  // The panel floats over the page: it never inherits a painted ancestor.
177
189
  const { knobProps } = useResolvedKnobs({ painted: false });
190
+ const triggerSurface = SurfaceContext.useStyledContext();
178
191
  const windowZIndex = useOverlayZIndex("sheet");
179
192
  const children = (
180
193
  <Surface painted={false} elevated>
@@ -634,8 +647,14 @@ export function FloatingPanel({
634
647
  Math.max(safeArea?.bottom ?? 0, keyboardInset) -
635
648
  panelViewportPadding,
636
649
  }}
637
- echo={triggerEcho ? (triggerRow ?? trigger) : null}
650
+ echo={triggerEcho && !iconEcho ? (triggerRow ?? trigger) : null}
638
651
  echoLabel={triggerA11y?.label}
652
+ iconEcho={
653
+ iconEcho && anchorMode !== "attach"
654
+ ? iconEchoElement(trigger, iconEcho, close, triggerSurface)
655
+ : null
656
+ }
657
+ iconEchoKind={iconEcho}
639
658
  coverAlign={anchorMode === "attach" ? "start" : coverAlign}
640
659
  panelWidth={panelWidth}
641
660
  triggerRadius={anchorMode === "attach" ? null : triggerRadius}
@@ -803,6 +822,8 @@ function TriggerMenuPanel({
803
822
  bounds,
804
823
  echo,
805
824
  echoLabel,
825
+ iconEcho = null,
826
+ iconEchoKind = "menu",
806
827
  coverAlign,
807
828
  panelWidth,
808
829
  triggerRadius,
@@ -817,6 +838,9 @@ function TriggerMenuPanel({
817
838
  bounds: PanelBounds;
818
839
  echo: ReactNode;
819
840
  echoLabel?: string;
841
+ /** LC-112 (FZ-8480): the icon trigger drawn again in its own spot. */
842
+ iconEcho?: ReactNode;
843
+ iconEchoKind?: IconEcho;
820
844
  coverAlign: "start" | "auto";
821
845
  panelWidth: PanelWidth;
822
846
  triggerRadius: number | null;
@@ -858,6 +882,7 @@ function TriggerMenuPanel({
858
882
  bounds: room,
859
883
  rtl: I18nManager.isRTL,
860
884
  align: coverAlign,
885
+ pinned: iconEcho != null,
861
886
  })
862
887
  : null;
863
888
  // LC-112 seamless cover: the menu is its trigger grown open, so it draws the
@@ -871,6 +896,7 @@ function TriggerMenuPanel({
871
896
  ? half
872
897
  : undefined;
873
898
  const borderWidth = Number(knobProps.elevatedSurface.borderWidth) || 0;
899
+ const echoOverlays = iconEchoOverlays(iconEchoKind, placement?.up ?? false);
874
900
  // A panel slid along the screen (a `"screen"` or fixed width, or one pushed
875
901
  // back inside the margins) keeps its echo on the trigger.
876
902
  const echoShift =
@@ -954,14 +980,43 @@ function TriggerMenuPanel({
954
980
  </RNView>
955
981
  </Pressable>
956
982
  ) : null}
983
+ {iconEcho != null && anchor && !placement?.up && !echoOverlays ? (
984
+ <IconEchoBand anchor={anchor} shift={echoShift} borderWidth={borderWidth} edge="top">
985
+ {iconEcho}
986
+ </IconEchoBand>
987
+ ) : null}
957
988
  <RNScrollView
958
989
  style={{ flexGrow: 0, flexShrink: 1 }}
959
990
  keyboardShouldPersistTaps="handled"
960
991
  >
961
992
  <YStack padding={framePadding} gap={frameGap}>
962
- {children}
993
+ <PanelIconEchoBoxContext.Provider
994
+ value={
995
+ iconEcho != null && anchor && placement && echoOverlays
996
+ ? echoHeaderBox({
997
+ offset: echoShift,
998
+ width: anchor.width,
999
+ height: anchor.height,
1000
+ panelWidth: placement.width,
1001
+ rtl: I18nManager.isRTL,
1002
+ })
1003
+ : null
1004
+ }
1005
+ >
1006
+ {children}
1007
+ </PanelIconEchoBoxContext.Provider>
963
1008
  </YStack>
964
1009
  </RNScrollView>
1010
+ {iconEcho != null && anchor && (placement?.up || echoOverlays) ? (
1011
+ <IconEchoBand
1012
+ anchor={anchor}
1013
+ shift={echoShift}
1014
+ borderWidth={borderWidth}
1015
+ edge={echoOverlays ? "overlay" : "bottom"}
1016
+ >
1017
+ {iconEcho}
1018
+ </IconEchoBand>
1019
+ ) : null}
965
1020
  </YStack>
966
1021
  </YStack>
967
1022
  </RNView>
@@ -969,6 +1024,62 @@ function TriggerMenuPanel({
969
1024
  );
970
1025
  }
971
1026
 
1027
+ /**
1028
+ * The band an icon trigger's echo rides (LC-112, FZ-8480): the trigger's
1029
+ * height on the edge the menu shares with it, outside the scroll view so the
1030
+ * rows scroll under it, and the button in the trigger's own box, pulled back
1031
+ * over the frame's border so it lands exactly where the trigger is.
1032
+ */
1033
+ function IconEchoBand({
1034
+ anchor,
1035
+ shift,
1036
+ borderWidth,
1037
+ edge,
1038
+ children,
1039
+ }: {
1040
+ anchor: TriggerRect;
1041
+ shift: number;
1042
+ borderWidth: number;
1043
+ /** `"overlay"`: an X laid over the content's top corner, taking no height. */
1044
+ edge: "top" | "bottom" | "overlay";
1045
+ children: ReactNode;
1046
+ }) {
1047
+ return (
1048
+ <RNView
1049
+ testID="floating-panel-icon-echo-band"
1050
+ pointerEvents="box-none"
1051
+ style={
1052
+ edge === "overlay"
1053
+ ? {
1054
+ position: "absolute",
1055
+ top: -borderWidth,
1056
+ start: -borderWidth,
1057
+ end: -borderWidth,
1058
+ height: anchor.height,
1059
+ zIndex: 1,
1060
+ }
1061
+ : {
1062
+ height: anchor.height,
1063
+ marginHorizontal: -borderWidth,
1064
+ ...(edge === "top" ? { marginTop: -borderWidth } : { marginBottom: -borderWidth }),
1065
+ }
1066
+ }
1067
+ >
1068
+ <RNView
1069
+ style={{
1070
+ width: anchor.width,
1071
+ height: anchor.height,
1072
+ marginStart: shift,
1073
+ alignItems: "flex-start",
1074
+ justifyContent: "flex-start",
1075
+ }}
1076
+ >
1077
+ {children}
1078
+ </RNView>
1079
+ </RNView>
1080
+ );
1081
+ }
1082
+
972
1083
  /**
973
1084
  * The menu at the long-press point: measured once per open at its natural
974
1085
  * size, then placed by the same rule as the web pointer menu. Later layouts