@multiplatform.one/forms 7.81.2 → 7.82.1

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 (98) hide show
  1. package/dist/cjs/Button/IconButtonGroup.cjs +13 -9
  2. package/dist/cjs/Button/IconButtonGroup.native.js +13 -9
  3. package/dist/cjs/Button/IconButtonGroup.native.js.map +1 -1
  4. package/dist/cjs/Button/iconButtonGroupContext.cjs +6 -0
  5. package/dist/cjs/Button/iconButtonGroupContext.native.js +6 -0
  6. package/dist/cjs/Button/iconButtonGroupContext.native.js.map +1 -1
  7. package/dist/cjs/Button/index.cjs +38 -26
  8. package/dist/cjs/Button/index.native.js +38 -26
  9. package/dist/cjs/Button/index.native.js.map +1 -1
  10. package/dist/cjs/FloatingPanel/RegularPanel.cjs +2 -2
  11. package/dist/cjs/FloatingPanel/RegularPanel.native.js +2 -2
  12. package/dist/cjs/FloatingPanel/RegularPanel.native.js.map +1 -1
  13. package/dist/cjs/FloatingPanel/index.native.js +11 -10
  14. package/dist/cjs/FloatingPanel/index.native.js.map +1 -1
  15. package/dist/cjs/InputParts/index.cjs +12 -6
  16. package/dist/cjs/InputParts/index.native.js +12 -6
  17. package/dist/cjs/InputParts/index.native.js.map +1 -1
  18. package/dist/cjs/index.cjs +1 -0
  19. package/dist/cjs/index.native.js +1 -0
  20. package/dist/cjs/index.native.js.map +1 -1
  21. package/dist/esm/Button/IconButtonGroup.mjs +14 -10
  22. package/dist/esm/Button/IconButtonGroup.mjs.map +1 -1
  23. package/dist/esm/Button/IconButtonGroup.native.js +14 -10
  24. package/dist/esm/Button/IconButtonGroup.native.js.map +1 -1
  25. package/dist/esm/Button/iconButtonGroupContext.mjs +5 -1
  26. package/dist/esm/Button/iconButtonGroupContext.mjs.map +1 -1
  27. package/dist/esm/Button/iconButtonGroupContext.native.js +5 -1
  28. package/dist/esm/Button/iconButtonGroupContext.native.js.map +1 -1
  29. package/dist/esm/Button/index.mjs +39 -28
  30. package/dist/esm/Button/index.mjs.map +1 -1
  31. package/dist/esm/Button/index.native.js +39 -28
  32. package/dist/esm/Button/index.native.js.map +1 -1
  33. package/dist/esm/FloatingPanel/RegularPanel.mjs +2 -2
  34. package/dist/esm/FloatingPanel/RegularPanel.mjs.map +1 -1
  35. package/dist/esm/FloatingPanel/RegularPanel.native.js +2 -2
  36. package/dist/esm/FloatingPanel/RegularPanel.native.js.map +1 -1
  37. package/dist/esm/FloatingPanel/index.native.js +11 -10
  38. package/dist/esm/FloatingPanel/index.native.js.map +1 -1
  39. package/dist/esm/InputParts/index.mjs +13 -7
  40. package/dist/esm/InputParts/index.mjs.map +1 -1
  41. package/dist/esm/InputParts/index.native.js +13 -7
  42. package/dist/esm/InputParts/index.native.js.map +1 -1
  43. package/dist/esm/index.mjs +2 -2
  44. package/dist/esm/index.mjs.map +1 -1
  45. package/dist/esm/index.native.js +2 -2
  46. package/dist/esm/index.native.js.map +1 -1
  47. package/dist/jsx/Button/IconButtonGroup.mjs +14 -10
  48. package/dist/jsx/Button/IconButtonGroup.mjs.map +1 -1
  49. package/dist/jsx/Button/IconButtonGroup.native.js +13 -9
  50. package/dist/jsx/Button/IconButtonGroup.native.js.map +1 -1
  51. package/dist/jsx/Button/iconButtonGroupContext.mjs +5 -1
  52. package/dist/jsx/Button/iconButtonGroupContext.mjs.map +1 -1
  53. package/dist/jsx/Button/iconButtonGroupContext.native.js +6 -0
  54. package/dist/jsx/Button/iconButtonGroupContext.native.js.map +1 -1
  55. package/dist/jsx/Button/index.mjs +39 -28
  56. package/dist/jsx/Button/index.mjs.map +1 -1
  57. package/dist/jsx/Button/index.native.js +38 -26
  58. package/dist/jsx/Button/index.native.js.map +1 -1
  59. package/dist/jsx/FloatingPanel/RegularPanel.mjs +2 -2
  60. package/dist/jsx/FloatingPanel/RegularPanel.mjs.map +1 -1
  61. package/dist/jsx/FloatingPanel/RegularPanel.native.js +2 -2
  62. package/dist/jsx/FloatingPanel/RegularPanel.native.js.map +1 -1
  63. package/dist/jsx/FloatingPanel/index.native.js +11 -10
  64. package/dist/jsx/FloatingPanel/index.native.js.map +1 -1
  65. package/dist/jsx/InputParts/index.mjs +13 -7
  66. package/dist/jsx/InputParts/index.mjs.map +1 -1
  67. package/dist/jsx/InputParts/index.native.js +12 -6
  68. package/dist/jsx/InputParts/index.native.js.map +1 -1
  69. package/dist/jsx/index.js +2 -2
  70. package/dist/jsx/index.js.map +1 -1
  71. package/dist/jsx/index.mjs +2 -2
  72. package/dist/jsx/index.mjs.map +1 -1
  73. package/dist/jsx/index.native.js +1 -0
  74. package/dist/jsx/index.native.js.map +1 -1
  75. package/package.json +8 -8
  76. package/src/Button/Button.fieldGlyph.spec.tsx +186 -0
  77. package/src/Button/Button.stories.tsx +97 -82
  78. package/src/Button/IconButtonGroup.spec.tsx +48 -1
  79. package/src/Button/IconButtonGroup.tsx +26 -12
  80. package/src/Button/iconButtonGroupContext.ts +12 -0
  81. package/src/Button/index.tsx +73 -35
  82. package/src/FloatingPanel/FloatingPanel.menuFace.native.spec.tsx +2 -2
  83. package/src/FloatingPanel/FloatingPanel.openOnMount.native.spec.tsx +198 -0
  84. package/src/FloatingPanel/RegularPanel.tsx +8 -6
  85. package/src/FloatingPanel/index.native.tsx +29 -18
  86. package/src/InputParts/index.tsx +40 -27
  87. package/src/index.ts +1 -0
  88. package/types/Button/IconButtonGroup.d.ts.map +1 -1
  89. package/types/Button/iconButtonGroupContext.d.ts +8 -0
  90. package/types/Button/iconButtonGroupContext.d.ts.map +1 -1
  91. package/types/Button/index.d.ts +1 -0
  92. package/types/Button/index.d.ts.map +1 -1
  93. package/types/FloatingPanel/RegularPanel.d.ts.map +1 -1
  94. package/types/FloatingPanel/index.native.d.ts +5 -0
  95. package/types/FloatingPanel/index.native.d.ts.map +1 -1
  96. package/types/InputParts/index.d.ts.map +1 -1
  97. package/types/index.d.ts +1 -1
  98. package/types/index.d.ts.map +1 -1
@@ -37,6 +37,7 @@ import {
37
37
  normalizeToHex,
38
38
  relativeLuminance,
39
39
  useBarSurface,
40
+ useFieldSurface,
40
41
  useElevatedSurface,
41
42
  useGlyphTintOnSurface,
42
43
  useResolvedKnobs,
@@ -80,7 +81,11 @@ import { useControlGrouped } from "../shared/groupContext";
80
81
  import { useInPanelEcho, usePanelTriggerRadiusReporter } from "../FloatingPanel/echoContext";
81
82
  import { loadGlassView } from "./glass";
82
83
  import { boldStroke, GLYPH_BOLD_STROKE_UNITS, ICON_BUTTON_CIRCLE_GLYPH_RATIO } from "./circleGlyph";
83
- import { useIconButtonGroupGround, useIconButtonGroupMember } from "./iconButtonGroupContext";
84
+ import {
85
+ GlyphPlateContext,
86
+ useIconButtonGroupGround,
87
+ useIconButtonGroupMember,
88
+ } from "./iconButtonGroupContext";
84
89
 
85
90
  export { GLYPH_BOLD_STROKE_UNITS, ICON_BUTTON_CIRCLE_GLYPH_RATIO };
86
91
 
@@ -614,14 +619,24 @@ export function Button({
614
619
  // (`outlined`, `circular`, `chromeless={false}`) or a segment owns it.
615
620
  const labelled = verb != null || (children != null && children !== false && children !== "");
616
621
  const iconOnly = !labelled && Boolean(icon || iconAfter);
622
+ // LC-176 (FZ-8711): an icon button in a field's leading or trailing slot is
623
+ // part of the field: the bare tinted glyph on every platform, with no glass,
624
+ // plate, frame or fill of its own, whatever the caller or the bar asked for.
625
+ const fieldSurface = useFieldSurface();
626
+ const fieldGlyph = iconOnly && fieldSurface;
627
+ if (fieldGlyph) {
628
+ outlined = false;
629
+ if (chromeless === false) chromeless = undefined;
630
+ }
617
631
  // LC-176: `circular chromeless` paints no plate, so an icon-only one is an
618
632
  // icon button like any other (the SHC console's add and copy buttons).
619
- const circular = circularProp && !(iconOnly && chromeless === true && plate == null);
633
+ const circular =
634
+ circularProp && !fieldGlyph && !(iconOnly && chromeless === true && plate == null);
620
635
  // LC-176: inside a group's shared capsule a button draws its glyph alone.
621
636
  const groupGround = useIconButtonGroupGround();
622
637
  useIconButtonGroupMember(iconOnly);
623
- const mediaPlate = iconOnly && plate === "media" && groupGround !== "media";
624
- const confirmPlate = iconOnly && primaryPlate;
638
+ const mediaPlate = iconOnly && plate === "media" && groupGround !== "media" && !fieldGlyph;
639
+ const confirmPlate = iconOnly && primaryPlate && !fieldGlyph;
625
640
  const iconButton =
626
641
  iconOnly &&
627
642
  !mediaPlate &&
@@ -642,10 +657,15 @@ export function Button({
642
657
  // An action inline with a value (copy beside an address) is tinted like a
643
658
  // link, so it reads as the one pressable thing on the line.
644
659
  const neutralGlyph =
645
- iconButton && !(theme ?? intent) && !barSurface && accentInk == null && inline !== true;
660
+ iconButton &&
661
+ !(theme ?? intent) &&
662
+ !barSurface &&
663
+ !fieldGlyph &&
664
+ accentInk == null &&
665
+ inline !== true;
646
666
  if (iconButton) {
647
667
  chromeless = true;
648
- accentInk = accentInk ?? (!(theme ?? intent) && (barSurface || inline === true));
668
+ accentInk = accentInk ?? (!(theme ?? intent) && (barSurface || fieldGlyph || inline === true));
649
669
  }
650
670
  // LC-176: a glyph that is the whole button is bold, so it reads as one.
651
671
  const resolvedGlyphWeight = glyphWeight ?? (iconOnly ? "bold" : "regular");
@@ -845,7 +865,7 @@ export function Button({
845
865
  selected ?? (ariaPressedProp === true || ariaPressedProp === "true" ? true : undefined);
846
866
  // The media plate is its own state ground; a toggle over media shows its
847
867
  // state in the glyph.
848
- const selectedFill = Boolean(isSelected) && !unavailable && !mediaPlate;
868
+ const selectedFill = Boolean(isSelected) && !unavailable && !mediaPlate && !fieldGlyph;
849
869
  // A glyph alone holds 3:1; beside a label both take the label's AA tint.
850
870
  const barInk =
851
871
  accentInk && chromeless && !selectedFill && !unavailable && !confirmPlate
@@ -863,10 +883,18 @@ export function Button({
863
883
  const glyphFloor = iconButtonGlyphPx(labelLine);
864
884
  // LC-176: on iOS 26 a bar's icon button sits in system Liquid Glass, the
865
885
  // native bar button, and iOS Safari draws its web face. Elsewhere, and on a
866
- // binary without the module, it stays the bare tinted glyph (FZ-8666).
886
+ // binary without the module, it stays the bare tinted glyph (FZ-8666). In a
887
+ // field's slot it is always that glyph (FZ-8711).
867
888
  const glassGroup = groupGround === "glass";
868
889
  const GlassView =
869
- iconButton && !inlineGlyph && !nested && accentInk && barSurface && !selectedFill && !glassGroup
890
+ iconButton &&
891
+ !inlineGlyph &&
892
+ !nested &&
893
+ accentInk &&
894
+ barSurface &&
895
+ !fieldGlyph &&
896
+ !selectedFill &&
897
+ !glassGroup
870
898
  ? loadGlassView()
871
899
  : null;
872
900
  const framedCircle = iconOnly && Boolean(circular) && !nested && !mediaPlate && !confirmPlate;
@@ -1125,11 +1153,18 @@ export function Button({
1125
1153
  : mediaPlate
1126
1154
  ? { backgroundColor: "$shadow5" }
1127
1155
  : undefined;
1156
+ // LC-176: a glyph on a visible ground sits at that ground's centre, and
1157
+ // the ground, not the glyph, takes the gutter (FZ-8442).
1158
+ const glyphPlated = Boolean(
1159
+ GlassView || groupGround || mediaPlate || confirmPlate || framedCircle,
1160
+ );
1128
1161
  // A square box, so an `edge` pull puts the glyph itself on the gutter.
1129
- // On glass the circle, not the glyph, sits on the margin.
1130
1162
  const glassInset = typeof resolvedBorderWidth === "number" ? -resolvedBorderWidth : 0;
1131
- const glyphEdgePull =
1132
- GlassView || glassGroup ? 0 : iconButton ? -Math.max(0, (visualPx - iconSize) / 2) : edgePull;
1163
+ const glyphEdgePull = glyphPlated
1164
+ ? 0
1165
+ : iconButton
1166
+ ? -Math.max(0, (visualPx - iconSize) / 2)
1167
+ : edgePull;
1133
1168
  const buttonElement = (
1134
1169
  <ButtonFrame
1135
1170
  // DG-ST-03: an explained disable is aria-disabled (focusable, press
@@ -1355,29 +1390,31 @@ export function Button({
1355
1390
  data-size-token={knobProps.sizeToken}
1356
1391
  {...(selectedFill ? { "aria-pressed": true, "data-mp-selected": "true" } : undefined)}
1357
1392
  >
1358
- {GlassView ? (
1359
- <GlassView
1360
- glassEffectStyle="regular"
1361
- isInteractive={!unavailable}
1362
- colorScheme={darkScheme ? "dark" : "light"}
1363
- style={{
1364
- position: "absolute",
1365
- top: glassInset,
1366
- right: glassInset,
1367
- bottom: glassInset,
1368
- left: glassInset,
1369
- borderRadius: visualPx / 2,
1370
- alignItems: "center",
1371
- justifyContent: "center",
1372
- }}
1373
- >
1374
- {iconElement}
1375
- </GlassView>
1376
- ) : (
1377
- iconElement
1378
- )}
1379
- {labelContent}
1380
- {iconAfterElement}
1393
+ <GlyphPlateContext.Provider value={glyphPlated}>
1394
+ {GlassView ? (
1395
+ <GlassView
1396
+ glassEffectStyle="regular"
1397
+ isInteractive={!unavailable}
1398
+ colorScheme={darkScheme ? "dark" : "light"}
1399
+ style={{
1400
+ position: "absolute",
1401
+ top: glassInset,
1402
+ right: glassInset,
1403
+ bottom: glassInset,
1404
+ left: glassInset,
1405
+ borderRadius: visualPx / 2,
1406
+ alignItems: "center",
1407
+ justifyContent: "center",
1408
+ }}
1409
+ >
1410
+ {iconElement}
1411
+ </GlassView>
1412
+ ) : (
1413
+ iconElement
1414
+ )}
1415
+ {labelContent}
1416
+ {iconAfterElement}
1417
+ </GlyphPlateContext.Provider>
1381
1418
  </ButtonFrame>
1382
1419
  );
1383
1420
 
@@ -1445,4 +1482,5 @@ export function IconButton(props: IconButtonProps) {
1445
1482
  }
1446
1483
 
1447
1484
  export { IconButtonGroup } from "./IconButtonGroup";
1485
+ export { useGlyphOnPlate } from "./iconButtonGroupContext";
1448
1486
  export type { IconButtonGroupProps } from "./IconButtonGroup";
@@ -631,10 +631,10 @@ describe("LC-112 grouped glass capsule on native (FZ-8550, Round 37 second pass)
631
631
  </IconButtonGroupSurfaceContext.Provider>,
632
632
  );
633
633
  tap(result.getAllByRole("button", { name: "Add node" })[0]!);
634
+ expect(menu()).toBeNull();
635
+ act(() => answer?.(plate));
634
636
  const panel = menu() as HTMLElement;
635
637
  layout(panel, MENU_MIN_WIDTH, 180);
636
- expect(panel.style.opacity).toBe("0");
637
- act(() => answer?.(plate));
638
638
  expect(panel.style.opacity).toBe("1");
639
639
  expect(panel.style.left).toBe(`${plate.x + plate.width - MENU_MIN_WIDTH}px`);
640
640
  });
@@ -0,0 +1,198 @@
1
+ /**
2
+ * FZ-8725: a menu opened on mount, before its trigger is measured, presents
3
+ * its Modal only once the measure lands, and no backdrop ever stands without
4
+ * a placed panel. The Kitchen Sink Account Menu deep link froze the screen:
5
+ * the previous story's menu presented in its mount commit and was torn down
6
+ * mid-presentation, leaving an invisible modal over every tap.
7
+ */
8
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
9
+ import { act } from "@testing-library/react";
10
+ import { PlusIcon } from "@phosphor-icons/react";
11
+ import { useState, type ReactNode } from "react";
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
+ import { Text } from "tamagui";
14
+ import { Button } from "../Button";
15
+ import { MENU_MIN_WIDTH } from "./compactFace";
16
+ import { FloatingPanel } from "./index.native";
17
+
18
+ const modal = vi.hoisted(() => ({ visible: [] as boolean[] }));
19
+
20
+ vi.mock("react-native-web", async (importOriginal) => {
21
+ const actual = await importOriginal<typeof import("react-native")>();
22
+ return {
23
+ ...actual,
24
+ ActionSheetIOS: undefined,
25
+ Modal: ({ children, visible }: { children: ReactNode; visible: boolean }) => {
26
+ modal.visible.push(visible);
27
+ return visible ? children : null;
28
+ },
29
+ useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }),
30
+ };
31
+ });
32
+
33
+ const trigger = { x: 334, y: 292, width: 44, height: 44 };
34
+ beforeEach(() => {
35
+ vi.useFakeTimers();
36
+ modal.visible = [];
37
+ // RN-web's measureInWindow reads the node's client rect on a timer.
38
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(() => ({
39
+ x: trigger.x,
40
+ y: trigger.y,
41
+ left: trigger.x,
42
+ top: trigger.y,
43
+ width: trigger.width,
44
+ height: trigger.height,
45
+ right: trigger.x + trigger.width,
46
+ bottom: trigger.y + trigger.height,
47
+ toJSON: () => ({}),
48
+ }));
49
+ });
50
+ afterEach(() => {
51
+ vi.restoreAllMocks();
52
+ vi.useRealTimers();
53
+ });
54
+
55
+ function AccountMenu({ label = "Account" }: { label?: string }) {
56
+ const [open, setOpen] = useState(true);
57
+ return (
58
+ <FloatingPanel
59
+ open={open}
60
+ onOpenChange={setOpen}
61
+ compactFace="menu"
62
+ anchorMode="cover-trigger"
63
+ triggerEcho={false}
64
+ iconCover
65
+ coverAlign="auto"
66
+ triggerSizing="content"
67
+ contentPadding="none"
68
+ trigger={<Button icon={PlusIcon} aria-label={label} />}
69
+ triggerA11y={{ label }}
70
+ >
71
+ <Text>Sign out</Text>
72
+ </FloatingPanel>
73
+ );
74
+ }
75
+
76
+ const layer = () => document.querySelector('[data-testid="floating-panel-menu-layer"]');
77
+ const menu = () => document.querySelector<HTMLElement>('[data-testid="floating-panel-menu"]');
78
+ const backdrop = (result: ReturnType<typeof renderWithProviders>) =>
79
+ result.queryByRole("button", { name: "Close" });
80
+
81
+ function tap(target: Element) {
82
+ act(() => {
83
+ target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
84
+ target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 }));
85
+ target.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0 }));
86
+ });
87
+ }
88
+
89
+ type FiberLike = { memoizedProps?: Record<string, unknown>; return?: FiberLike };
90
+
91
+ /** The live prop a component took: RN-web drops the ones it has no DOM use for. */
92
+ function propOf<T>(node: Element, name: string): T | undefined {
93
+ const key = Object.keys(node).find((candidate) => candidate.startsWith("__reactFiber$"));
94
+ let fiber = (node as unknown as Record<string, FiberLike | undefined>)[key ?? ""];
95
+ while (fiber) {
96
+ const value = fiber.memoizedProps?.[name];
97
+ if (value !== undefined) return value as T;
98
+ fiber = fiber.return;
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ function measure() {
104
+ act(() => {
105
+ vi.advanceTimersByTime(1);
106
+ });
107
+ }
108
+
109
+ /** RN-web parks onLayout on the node for a ResizeObserver happy-dom never fires. */
110
+ function layout(node: Element, width: number, height: number) {
111
+ const onLayout = (node as unknown as { __reactLayoutHandler?: (event: unknown) => void })
112
+ .__reactLayoutHandler;
113
+ expect(onLayout).toBeTypeOf("function");
114
+ act(() => {
115
+ onLayout?.({ nativeEvent: { layout: { x: 0, y: 0, width, height } } });
116
+ });
117
+ }
118
+
119
+ /** Never a backdrop on screen without the panel it closes. */
120
+ function expectNoBareBackdrop(result: ReturnType<typeof renderWithProviders>) {
121
+ if (backdrop(result)) expect(menu()?.style.opacity).toBe("1");
122
+ }
123
+
124
+ describe("FloatingPanel opened on mount (FZ-8725)", () => {
125
+ it("presents nothing until the trigger is measured", () => {
126
+ const result = renderWithProviders(<AccountMenu />);
127
+ expect(modal.visible.length).toBeGreaterThan(0);
128
+ expect(modal.visible.every((visible) => !visible)).toBe(true);
129
+ expect(layer()).toBeNull();
130
+ expect(backdrop(result)).toBeNull();
131
+ expect(result.getAllByRole("button", { name: "Account" }).length).toBeGreaterThan(0);
132
+ });
133
+
134
+ it("shows the panel over its trigger once the measure lands", () => {
135
+ const result = renderWithProviders(<AccountMenu />);
136
+ measure();
137
+ expect(modal.visible.at(-1)).toBe(true);
138
+ expect(menu()).not.toBeNull();
139
+ expectNoBareBackdrop(result);
140
+ layout(menu() as HTMLElement, MENU_MIN_WIDTH, 120);
141
+ expect(menu()?.style.opacity).toBe("1");
142
+ expect(menu()?.style.top).toBe(`${trigger.y}px`);
143
+ expect(menu()?.style.left).toBe(`${trigger.x + trigger.width - MENU_MIN_WIDTH}px`);
144
+ expect(backdrop(result)).not.toBeNull();
145
+ expectNoBareBackdrop(result);
146
+ });
147
+
148
+ it("keeps the backdrop and the modal flag off while the panel is still unplaced", () => {
149
+ const result = renderWithProviders(<AccountMenu />);
150
+ measure();
151
+ expect(menu()?.style.opacity).toBe("0");
152
+ expect(backdrop(result)).toBeNull();
153
+ expect(propOf<boolean>(layer() as Element, "accessibilityViewIsModal")).toBe(false);
154
+ layout(menu() as HTMLElement, MENU_MIN_WIDTH, 120);
155
+ act(() => {
156
+ vi.advanceTimersByTime(1000);
157
+ });
158
+ expect(backdrop(result)).not.toBeNull();
159
+ expect(propOf<boolean>(layer() as Element, "accessibilityViewIsModal")).toBe(true);
160
+ });
161
+
162
+ it("closes from the backdrop and leaves the screen tappable", () => {
163
+ const result = renderWithProviders(<AccountMenu />);
164
+ measure();
165
+ layout(menu() as HTMLElement, MENU_MIN_WIDTH, 120);
166
+ act(() => {
167
+ vi.advanceTimersByTime(1000);
168
+ });
169
+ tap(backdrop(result) as HTMLElement);
170
+ expect(layer()).toBeNull();
171
+ expect(modal.visible.at(-1)).toBe(false);
172
+ tap(result.getAllByRole("button", { name: "Account" })[0]!);
173
+ measure();
174
+ expect(menu()).not.toBeNull();
175
+ });
176
+
177
+ it("never presents a menu that is swapped out before its measure lands", () => {
178
+ let swap = () => {};
179
+ function Stories() {
180
+ const [second, setSecond] = useState(false);
181
+ swap = () => setSecond(true);
182
+ return second ? (
183
+ <AccountMenu key="b" label="Account" />
184
+ ) : (
185
+ <AccountMenu key="a" label="Main" />
186
+ );
187
+ }
188
+ renderWithProviders(<Stories />);
189
+ act(() => swap());
190
+ const beforeMeasure = modal.visible.slice();
191
+ expect(beforeMeasure.every((visible) => !visible)).toBe(true);
192
+ measure();
193
+ expect(modal.visible.at(-1)).toBe(true);
194
+ layout(menu() as HTMLElement, MENU_MIN_WIDTH, 120);
195
+ expect(menu()?.style.opacity).toBe("1");
196
+ expect(document.querySelectorAll('[data-testid="floating-panel-menu-layer"]')).toHaveLength(1);
197
+ });
198
+ });
@@ -89,12 +89,14 @@ export function RegularPanel({
89
89
  ) : null;
90
90
  return (
91
91
  <RNView style={StyleSheet.absoluteFill}>
92
- <Pressable
93
- style={StyleSheet.absoluteFill}
94
- onPress={onBackdropPress}
95
- accessibilityRole="button"
96
- accessibilityLabel={t("Close")}
97
- />
92
+ {placement ? (
93
+ <Pressable
94
+ style={StyleSheet.absoluteFill}
95
+ onPress={onBackdropPress}
96
+ accessibilityRole="button"
97
+ accessibilityLabel={t("Close")}
98
+ />
99
+ ) : null}
98
100
  <RNView
99
101
  testID="floating-panel-regular"
100
102
  onLayout={(e) => {
@@ -43,6 +43,11 @@
43
43
  * and the sheet never appears. Fields should not also toggle open on native.
44
44
  * - Opening a Modal under the same finger can synthesize a backdrop press;
45
45
  * backdrop dismiss is armed a short delay after the present begins.
46
+ * - An anchored panel opened before its trigger is measured (`open` on
47
+ * mount) presents its Modal only once the measure lands (FZ-8725). A Modal
48
+ * presented in the mount commit and torn down mid-presentation (a remount,
49
+ * a story switch) left UIKit an invisible view controller over the screen:
50
+ * no panel, and every tap under it dead.
46
51
  *
47
52
  * Menu face (`compactFace`, FZ-7830): instead of the sheet, a menu opens
48
53
  * over the trigger (LC-112), its top start corner on the trigger's, the
@@ -318,6 +323,8 @@ export function FloatingPanel({
318
323
  };
319
324
  }, [anchored, open, iconCover, anchorMode, groupSurface]);
320
325
 
326
+ const shown = open && (!anchored || (anchor !== null && group !== undefined));
327
+
321
328
  const handleBackdropPress = useCallback(() => {
322
329
  if (backdropArmed) close();
323
330
  }, [backdropArmed, close]);
@@ -568,10 +575,10 @@ export function FloatingPanel({
568
575
 
569
576
  useEffect(() => {
570
577
  presentationRef.current += 1;
571
- activeRef.current = open;
578
+ activeRef.current = shown;
572
579
  restHeightRef.current = null;
573
580
  frameCap.setValue(maxHeightRef.current);
574
- if (open) {
581
+ if (shown) {
575
582
  enter();
576
583
  } else {
577
584
  setBackdropArmed(false);
@@ -590,7 +597,7 @@ export function FloatingPanel({
590
597
  }
591
598
  translateY.stopAnimation();
592
599
  };
593
- }, [open, enter, frameCap, translateY]);
600
+ }, [shown, enter, frameCap, translateY]);
594
601
 
595
602
  const content = scrollable ? (
596
603
  <RNScrollView
@@ -658,7 +665,7 @@ export function FloatingPanel({
658
665
  <TrailingActionOverlay>{triggerEnd}</TrailingActionOverlay>
659
666
  </RNView>
660
667
  <Modal
661
- visible={open}
668
+ visible={shown}
662
669
  transparent
663
670
  animationType={knobProps.motion.scrim.transition ? "fade" : "none"}
664
671
  statusBarTranslucent
@@ -1018,14 +1025,16 @@ function TriggerMenuPanel({
1018
1025
  <RNView
1019
1026
  style={StyleSheet.absoluteFill}
1020
1027
  testID="floating-panel-menu-layer"
1021
- accessibilityViewIsModal
1028
+ accessibilityViewIsModal={placement != null}
1022
1029
  >
1023
- <Pressable
1024
- style={StyleSheet.absoluteFill}
1025
- onPress={onBackdropPress}
1026
- accessibilityRole="button"
1027
- accessibilityLabel={t("Close")}
1028
- />
1030
+ {placement ? (
1031
+ <Pressable
1032
+ style={StyleSheet.absoluteFill}
1033
+ onPress={onBackdropPress}
1034
+ accessibilityRole="button"
1035
+ accessibilityLabel={t("Close")}
1036
+ />
1037
+ ) : null}
1029
1038
  <RNView
1030
1039
  testID="floating-panel-menu"
1031
1040
  onLayout={(e) => {
@@ -1223,14 +1232,16 @@ function PointPanel({
1223
1232
  <RNView
1224
1233
  style={StyleSheet.absoluteFill}
1225
1234
  testID="floating-panel-point-layer"
1226
- accessibilityViewIsModal
1235
+ accessibilityViewIsModal={placement != null}
1227
1236
  >
1228
- <Pressable
1229
- style={StyleSheet.absoluteFill}
1230
- onPress={onBackdropPress}
1231
- accessibilityRole="button"
1232
- accessibilityLabel={t("Close")}
1233
- />
1237
+ {placement ? (
1238
+ <Pressable
1239
+ style={StyleSheet.absoluteFill}
1240
+ onPress={onBackdropPress}
1241
+ accessibilityRole="button"
1242
+ accessibilityLabel={t("Close")}
1243
+ />
1244
+ ) : null}
1234
1245
  <RNView
1235
1246
  testID="floating-panel-point"
1236
1247
  onLayout={(e) => {
@@ -47,6 +47,7 @@ import {
47
47
  resolveControlRadius,
48
48
  resolveGlyphPaint,
49
49
  SIZE_RECIPE_RADIUS_TOKEN_TO_STOP,
50
+ Surface,
50
51
  sizeRecipeBoxVariants,
51
52
  sizeRecipeForToken,
52
53
  sizeRecipeIconVariants,
@@ -530,7 +531,7 @@ const InputGroupImpl = InputGroupFrame.styleable<{
530
531
 
531
532
  // Chromeless rendering inside table cells
532
533
  if (inTableCell) {
533
- return (
534
+ return inFieldFrame(
534
535
  <FocusContext.Provider focused={focused} setFocused={setFocused}>
535
536
  {wrapNativePressable(
536
537
  onPress,
@@ -569,38 +570,40 @@ const InputGroupImpl = InputGroupFrame.styleable<{
569
570
  {trailingSlot}
570
571
  </InputGroupFrame>,
571
572
  )}
572
- </FocusContext.Provider>
573
+ </FocusContext.Provider>,
573
574
  );
574
575
  }
575
576
 
576
577
  if (grouped) {
577
- return wrapNativePressable(
578
- onPress,
579
- disabled,
580
- <InputGroupFrame
581
- touch={hydrationTouch}
582
- ref={forwardedRef}
583
- borderWidth={0}
584
- borderRadius={0}
585
- backgroundColor="transparent"
586
- {...(groupedVertical
587
- ? { flexGrow: 0, flexShrink: 0, flexBasis: "auto", height: controlFragment.height }
588
- : { flex: 1 })}
589
- elevation={undefined}
590
- {...props}
591
- {...passthroughSizeProps}
592
- {...insetPadProps}
593
- {...sizeRecipeEscapeOverride}
594
- {...framePressProps}
595
- {...boxRingAttrs}
596
- overflow={boxOverflow}
597
- >
598
- {children}
599
- </InputGroupFrame>,
578
+ return inFieldFrame(
579
+ wrapNativePressable(
580
+ onPress,
581
+ disabled,
582
+ <InputGroupFrame
583
+ touch={hydrationTouch}
584
+ ref={forwardedRef}
585
+ borderWidth={0}
586
+ borderRadius={0}
587
+ backgroundColor="transparent"
588
+ {...(groupedVertical
589
+ ? { flexGrow: 0, flexShrink: 0, flexBasis: "auto", height: controlFragment.height }
590
+ : { flex: 1 })}
591
+ elevation={undefined}
592
+ {...props}
593
+ {...passthroughSizeProps}
594
+ {...insetPadProps}
595
+ {...sizeRecipeEscapeOverride}
596
+ {...framePressProps}
597
+ {...boxRingAttrs}
598
+ overflow={boxOverflow}
599
+ >
600
+ {children}
601
+ </InputGroupFrame>,
602
+ ),
600
603
  );
601
604
  }
602
605
 
603
- return (
606
+ return inFieldFrame(
604
607
  <FocusContext.Provider focused={focused} setFocused={setFocused}>
605
608
  {wrapNativePressable(
606
609
  onPress,
@@ -651,10 +654,20 @@ const InputGroupImpl = InputGroupFrame.styleable<{
651
654
  {trailingSlot}
652
655
  </InputGroupFrame>,
653
656
  )}
654
- </FocusContext.Provider>
657
+ </FocusContext.Provider>,
655
658
  );
656
659
  });
657
660
 
661
+ /**
662
+ * LC-176 (FZ-8711): everything inside a field's frame is part of the field,
663
+ * so an icon button in its leading or trailing slot draws the bare tinted
664
+ * glyph, never bar glass or a plate. Wrapped outside the XGroup frame so the
665
+ * group's own children stay its items.
666
+ */
667
+ function inFieldFrame(frame: ReactNode) {
668
+ return <Surface field>{frame}</Surface>;
669
+ }
670
+
658
671
  // ---------------------------------------------------------------------------
659
672
  // Input size variant helper
660
673
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -120,6 +120,7 @@ export {
120
120
  ICON_BUTTON_GLYPH_PX,
121
121
  ICON_BUTTON_NEUTRAL_GLYPH_PX,
122
122
  ICON_BUTTON_PRESS_OPACITY,
123
+ useGlyphOnPlate,
123
124
  } from "./Button";
124
125
  export type {
125
126
  ButtonProps,
@@ -1 +1 @@
1
- {"version":3,"file":"IconButtonGroup.d.ts","sourceRoot":"","sources":["../../src/Button/IconButtonGroup.tsx"],"names":[],"mappings":"AACA,OAAO,EAAoD,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACzF,OAAO,EAA4C,KAAK,WAAW,EAAE,MAAM,SAAS,CAAC;AAmBrF,MAAM,WAAW,oBAAqB,SAAQ,WAAW;IACvD,gEAAgE;IAChE,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,EAAE,oBAAoB,2CAyIjF"}
1
+ {"version":3,"file":"IconButtonGroup.d.ts","sourceRoot":"","sources":["../../src/Button/IconButtonGroup.tsx"],"names":[],"mappings":"AAMA,OAAO,EAAoD,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACzF,OAAO,EAA4C,KAAK,WAAW,EAAE,MAAM,SAAS,CAAC;AAmBrF,MAAM,WAAW,oBAAqB,SAAQ,WAAW;IACvD,gEAAgE;IAChE,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,EAAE,oBAAoB,2CAkJjF"}
@@ -6,6 +6,14 @@
6
6
  export type IconButtonGroupGround = "media" | "glass" | null;
7
7
  export declare const IconButtonGroupContext: import("react").Context<IconButtonGroupGround>;
8
8
  export declare function useIconButtonGroupGround(): IconButtonGroupGround;
9
+ /**
10
+ * LC-176: true while a glyph is drawn centred on a visible ground (a glass
11
+ * circle or capsule, the media plate, a framed circle). An icon that pulls
12
+ * itself toward a container edge for optical alignment, such as the back
13
+ * caret, keeps its ink centred instead.
14
+ */
15
+ export declare const GlyphPlateContext: import("react").Context<boolean>;
16
+ export declare function useGlyphOnPlate(): boolean;
9
17
  /**
10
18
  * An icon button counts itself into the group around it, so a bar group of
11
19
  * only non-icon children (a status badge in a page head) paints no capsule
@@ -1 +1 @@
1
- {"version":3,"file":"iconButtonGroupContext.d.ts","sourceRoot":"","sources":["../../src/Button/iconButtonGroupContext.ts"],"names":[],"mappings":"AAIA;;;;GAIG;AACH,MAAM,MAAM,qBAAqB,GAAG,OAAO,GAAG,OAAO,GAAG,IAAI,CAAC;AAE7D,eAAO,MAAM,sBAAsB,gDAA6C,CAAC;AAEjF,wBAAgB,wBAAwB,IAAI,qBAAqB,CAEhE;AAED;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,iCAAwB,MAAM,IAAI,SAAe,CAAC;AAEtF,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,OAAO,GAAG,IAAI,CAMhE;AAED;;;;;GAKG;AACH,MAAM,WAAW,oBAAoB;IACnC,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,sBAAsB;IACrC,mEAAmE;IACnE,OAAO,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,oBAAoB,GAAG,IAAI,KAAK,IAAI,GAAG,IAAI,CAAC;CACvE;AAED,eAAO,MAAM,6BAA6B,wDAAqD,CAAC;AAEhG,wBAAgB,yBAAyB,IAAI,sBAAsB,GAAG,IAAI,CAEzE"}
1
+ {"version":3,"file":"iconButtonGroupContext.d.ts","sourceRoot":"","sources":["../../src/Button/iconButtonGroupContext.ts"],"names":[],"mappings":"AAIA;;;;GAIG;AACH,MAAM,MAAM,qBAAqB,GAAG,OAAO,GAAG,OAAO,GAAG,IAAI,CAAC;AAE7D,eAAO,MAAM,sBAAsB,gDAA6C,CAAC;AAEjF,wBAAgB,wBAAwB,IAAI,qBAAqB,CAEhE;AAED;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,kCAAuB,CAAC;AAEtD,wBAAgB,eAAe,IAAI,OAAO,CAEzC;AAED;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,iCAAwB,MAAM,IAAI,SAAe,CAAC;AAEtF,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,OAAO,GAAG,IAAI,CAMhE;AAED;;;;;GAKG;AACH,MAAM,WAAW,oBAAoB;IACnC,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,sBAAsB;IACrC,mEAAmE;IACnE,OAAO,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,oBAAoB,GAAG,IAAI,KAAK,IAAI,GAAG,IAAI,CAAC;CACvE;AAED,eAAO,MAAM,6BAA6B,wDAAqD,CAAC;AAEhG,wBAAgB,yBAAyB,IAAI,sBAAsB,GAAG,IAAI,CAEzE"}
@@ -197,5 +197,6 @@ export interface IconButtonProps extends Omit<ButtonProps, "children" | "icon" |
197
197
  */
198
198
  export declare function IconButton(props: IconButtonProps): import("react/jsx-runtime").JSX.Element;
199
199
  export { IconButtonGroup } from "./IconButtonGroup";
200
+ export { useGlyphOnPlate } from "./iconButtonGroupContext";
200
201
  export type { IconButtonGroupProps } from "./IconButtonGroup";
201
202
  //# sourceMappingURL=index.d.ts.map