@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
@@ -0,0 +1,186 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ *
4
+ * LC-176 (FZ-8711, Round 48): an icon button in a field's leading or trailing
5
+ * slot is part of the field. Clay, on the list search's filter and sort glass
6
+ * circles overhanging the field: "Liquid glass icon should not be inside of
7
+ * inputs though right you should do the no borders no background when you put
8
+ * them inside of here right". On iOS Safari and on native iOS 26 alike it is
9
+ * the bare tinted glyph, even when the field sits in a bar; glass stays on the
10
+ * bar's own buttons.
11
+ */
12
+
13
+ import { createDefaultThemeConfig, Surface, useFieldSurface } from "@multiplatform.one/theme";
14
+ import { cleanup, render, screen } from "@testing-library/react";
15
+ import type { ReactElement } from "react";
16
+ import { TamaguiProvider } from "tamagui";
17
+ import { afterEach, describe, expect, it, vi } from "vitest";
18
+ import { SearchInput } from "../fields/SearchInput";
19
+ import { Input as InputParts } from "../InputParts";
20
+ import { Button, IconButton, IconButtonGroup } from "./index";
21
+
22
+ const glass = vi.hoisted(() => ({ system: false }));
23
+ vi.mock("./glass", async (importOriginal) => {
24
+ const real = await importOriginal<typeof import("./glass")>();
25
+ function FakeSystemGlass({
26
+ glassEffectStyle: _style,
27
+ isInteractive: _interactive,
28
+ colorScheme: _scheme,
29
+ children,
30
+ ...props
31
+ }: Record<string, unknown>) {
32
+ return (
33
+ <div data-glass="system" {...props}>
34
+ {children as never}
35
+ </div>
36
+ );
37
+ }
38
+ return {
39
+ ...real,
40
+ // Native iOS 26 with expo-glass-effect linked, else the real web loader
41
+ // (iOS Safari's drawn face, nothing elsewhere).
42
+ loadGlassView: () => (glass.system ? FakeSystemGlass : real.loadGlassView()),
43
+ loadGlassContainer: () => (glass.system ? FakeSystemGlass : null),
44
+ };
45
+ });
46
+
47
+ afterEach(() => {
48
+ cleanup();
49
+ vi.unstubAllGlobals();
50
+ glass.system = false;
51
+ });
52
+
53
+ const IPHONE =
54
+ "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";
55
+
56
+ const houseConfig = createDefaultThemeConfig();
57
+
58
+ function Glyph({ size, weight }: { size?: number; weight?: string }) {
59
+ return <svg data-glyph="" data-weight={weight} width={size} height={size} />;
60
+ }
61
+
62
+ function houseRender(ui: ReactElement, scheme: "light" | "dark") {
63
+ return render(
64
+ <TamaguiProvider config={houseConfig.tamagui} defaultTheme={scheme} disableInjectCSS>
65
+ {ui}
66
+ </TamaguiProvider>,
67
+ );
68
+ }
69
+
70
+ /** A bar holding a search field with filter and sort in its trailing slot, and a bar button. */
71
+ function SearchBar() {
72
+ return (
73
+ <Surface bar>
74
+ <SearchInput
75
+ value=""
76
+ onChange={() => {}}
77
+ aria-label="Search"
78
+ trailing={
79
+ <>
80
+ <Button chromeless accentInk icon={Glyph} aria-label="Filter" />
81
+ <IconButton icon={Glyph} aria-label="Sort" />
82
+ </>
83
+ }
84
+ />
85
+ <IconButton icon={Glyph} aria-label="Add" />
86
+ </Surface>
87
+ );
88
+ }
89
+
90
+ function hasGlass(label: string): boolean {
91
+ return screen.getByLabelText(label).querySelector("[data-glass], [data-mp-glass]") != null;
92
+ }
93
+
94
+ const PLATFORMS = [
95
+ [
96
+ "iOS Safari",
97
+ () => vi.stubGlobal("navigator", { userAgent: IPHONE, platform: "iPhone", maxTouchPoints: 5 }),
98
+ ],
99
+ ["native iOS 26", () => (glass.system = true)],
100
+ ] as const;
101
+
102
+ describe("LC-176: an icon button in a field's slot is the field's bare glyph (FZ-8711)", () => {
103
+ describe.each(PLATFORMS)("%s", (_platform, onIPhone) => {
104
+ it.each(["light", "dark"] as const)(
105
+ "the search's filter and sort draw no glass while the bar's own button keeps it (%s)",
106
+ (scheme) => {
107
+ onIPhone();
108
+ houseRender(<SearchBar />, scheme);
109
+ for (const label of ["Filter", "Sort"]) {
110
+ const button = screen.getByLabelText(label);
111
+ expect(hasGlass(label)).toBe(false);
112
+ expect(button.getAttribute("data-mp-icon-button")).toBe("glyph");
113
+ // Bold glyph, tinted, on its 44pt square.
114
+ expect(button.querySelector("[data-glyph]")!.getAttribute("data-weight")).toBe("bold");
115
+ }
116
+ expect(hasGlass("Add")).toBe(true);
117
+ expect(screen.getByLabelText("Add").getAttribute("data-mp-icon-button")).toBe("glass");
118
+ },
119
+ );
120
+
121
+ it("a framed, plated or toggled icon button in a slot still draws only its glyph", () => {
122
+ onIPhone();
123
+ houseRender(
124
+ <Surface bar>
125
+ <InputParts>
126
+ <InputParts.Box>
127
+ <InputParts.Area aria-label="Value" />
128
+ <Button outlined icon={Glyph} aria-label="Outlined" />
129
+ <Button circular icon={Glyph} aria-label="Circle" />
130
+ <Button plate="media" icon={Glyph} aria-label="Media" />
131
+ <Button selected icon={Glyph} aria-label="Toggle" />
132
+ </InputParts.Box>
133
+ </InputParts>
134
+ </Surface>,
135
+ "light",
136
+ );
137
+ for (const label of ["Outlined", "Circle", "Media", "Toggle"]) {
138
+ const button = screen.getByLabelText(label);
139
+ expect(hasGlass(label)).toBe(false);
140
+ expect(button.getAttribute("data-mp-icon-button")).toBe("glyph");
141
+ expect(button.getAttribute("data-mp-selected")).toBeNull();
142
+ }
143
+ });
144
+
145
+ it("a group in a slot paints no capsule", () => {
146
+ onIPhone();
147
+ houseRender(
148
+ <Surface bar>
149
+ <InputParts>
150
+ <InputParts.Box>
151
+ <InputParts.Area aria-label="Value" />
152
+ <IconButtonGroup data-testid="slot-group">
153
+ <IconButton icon={Glyph} aria-label="Filter" />
154
+ <IconButton icon={Glyph} aria-label="Sort" />
155
+ </IconButtonGroup>
156
+ </InputParts.Box>
157
+ </InputParts>
158
+ </Surface>,
159
+ "light",
160
+ );
161
+ const group = screen.getByTestId("slot-group");
162
+ expect(group.querySelector("[data-glass], [data-mp-glass]")).toBeNull();
163
+ expect(group.getAttribute("data-mp-icon-button-group")).not.toBe("glass");
164
+ });
165
+ });
166
+
167
+ it("an overlay opened from a slot is content again, not part of the field", () => {
168
+ let insideField: boolean | undefined;
169
+ let insideOverlay: boolean | undefined;
170
+ function Probe({ onRead }: { onRead: (field: boolean) => void }) {
171
+ onRead(useFieldSurface());
172
+ return null;
173
+ }
174
+ houseRender(
175
+ <Surface field>
176
+ <Probe onRead={(field) => (insideField = field)} />
177
+ <Surface painted={false} elevated>
178
+ <Probe onRead={(field) => (insideOverlay = field)} />
179
+ </Surface>
180
+ </Surface>,
181
+ "light",
182
+ );
183
+ expect(insideField).toBe(true);
184
+ expect(insideOverlay).toBe(false);
185
+ });
186
+ });
@@ -16,7 +16,6 @@ import {
16
16
  ArchiveIcon,
17
17
  ArrowCircleUpIcon,
18
18
  ArrowsClockwiseIcon,
19
- CaretLeftIcon,
20
19
  CopyIcon,
21
20
  DotsThreeIcon,
22
21
  DownloadSimpleIcon,
@@ -26,6 +25,13 @@ import {
26
25
  TrashIcon,
27
26
  XIcon,
28
27
  } from "@phosphor-icons/react";
28
+ import {
29
+ CodeBlock,
30
+ PageBackButton,
31
+ Popover,
32
+ PopoverContent,
33
+ ScreenToolbar,
34
+ } from "@multiplatform.one/components";
29
35
  import { Button, IconButton, IconButtonGroup, type ButtonProps } from "./index";
30
36
  import { useState } from "react";
31
37
 
@@ -447,89 +453,93 @@ export const IconButtons: Story = {
447
453
  render: () => {
448
454
  const [on, setOn] = useState(false);
449
455
  return (
450
- <YStack gap="$4" padding="$4" backgroundColor="$background">
451
- <Surface bar>
452
- <XStack alignItems="center" data-testid="icon-button-bar">
453
- <IconButton icon={CaretLeftIcon} aria-label="Back" onPress={pressed("back")} />
454
- <Paragraph flex={1} fontWeight="600">
455
- Inbox
456
- </Paragraph>
457
- <IconButtonGroup data-testid="icon-button-toolbar">
458
- <IconButton icon={ArchiveIcon} aria-label="Archive" onPress={pressed("archive")} />
459
- <IconButton icon={TrashIcon} aria-label="Delete" onPress={pressed("delete")} />
460
- <IconButton
461
- icon={EnvelopeSimpleIcon}
462
- aria-label="Mark unread"
463
- selected={on}
464
- onPress={() => setOn((v) => !v)}
465
- />
466
- <IconButton icon={DotsThreeIcon} aria-label="More" onPress={pressed("more")} />
467
- </IconButtonGroup>
468
- </XStack>
469
- </Surface>
470
- <Surface bar>
471
- <XStack alignItems="center" data-testid="icon-button-bar-disabled">
472
- <Paragraph flex={1}>Downloads</Paragraph>
456
+ <YStack backgroundColor="$background">
457
+ <ScreenToolbar
458
+ data-testid="icon-button-bar"
459
+ leading={<PageBackButton onPress={pressed("back")} />}
460
+ title="Inbox"
461
+ trailing={<IconButton icon={DotsThreeIcon} aria-label="More" onPress={pressed("more")} />}
462
+ />
463
+ <YStack gap="$4" padding="$4">
464
+ <Surface bar>
465
+ <XStack justifyContent="center" data-testid="icon-button-toolbar">
466
+ <IconButtonGroup>
467
+ <IconButton icon={ArchiveIcon} aria-label="Archive" onPress={pressed("archive")} />
468
+ <IconButton icon={TrashIcon} aria-label="Delete" onPress={pressed("delete")} />
469
+ <IconButton
470
+ icon={EnvelopeSimpleIcon}
471
+ aria-label="Mark unread"
472
+ selected={on}
473
+ onPress={() => setOn((v) => !v)}
474
+ />
475
+ </IconButtonGroup>
476
+ </XStack>
477
+ </Surface>
478
+ <Surface bar>
479
+ <XStack alignItems="center" data-testid="icon-button-bar-disabled">
480
+ <Paragraph flex={1}>Downloads</Paragraph>
481
+ <IconButtonGroup>
482
+ <IconButton
483
+ icon={DownloadSimpleIcon}
484
+ aria-label="Download"
485
+ onPress={pressed("download")}
486
+ />
487
+ <IconButton
488
+ icon={DownloadSimpleIcon}
489
+ aria-label="Download (nothing to download)"
490
+ disabled
491
+ />
492
+ </IconButtonGroup>
493
+ </XStack>
494
+ </Surface>
495
+ <XStack alignItems="center" data-testid="icon-button-content">
496
+ <Paragraph flex={1}>A row in content</Paragraph>
473
497
  <IconButtonGroup>
474
498
  <IconButton
475
499
  icon={DownloadSimpleIcon}
476
500
  aria-label="Download"
477
501
  onPress={pressed("download")}
478
502
  />
479
- <IconButton
480
- icon={DownloadSimpleIcon}
481
- aria-label="Download (nothing to download)"
482
- disabled
483
- />
503
+ <IconButton icon={TrashIcon} error aria-label="Delete" onPress={pressed("delete")} />
484
504
  </IconButtonGroup>
485
505
  </XStack>
486
- </Surface>
487
- <XStack alignItems="center" data-testid="icon-button-content">
488
- <Paragraph flex={1}>A row in content</Paragraph>
489
- <IconButtonGroup>
490
- <IconButton
491
- icon={DownloadSimpleIcon}
492
- aria-label="Download"
493
- onPress={pressed("download")}
494
- />
495
- <IconButton icon={TrashIcon} error aria-label="Delete" onPress={pressed("delete")} />
496
- </IconButtonGroup>
497
- </XStack>
498
- <XStack alignItems="center" data-testid="icon-button-inline">
499
- <Paragraph>shc node join</Paragraph>
500
- <IconButton inline icon={CopyIcon} aria-label="Copy" onPress={pressed("copy")} />
501
- </XStack>
502
- <YStack
503
- position="relative"
504
- width="$20"
505
- height="$12"
506
- backgroundColor="$blue9"
507
- borderRadius="$4"
508
- data-testid="icon-button-media"
509
- >
510
- <YStack position="absolute" top="$2" left="$2">
511
- <IconButton
512
- plate="media"
513
- icon={HeartIcon}
514
- aria-label="Favourite"
515
- onPress={pressed("favourite")}
516
- />
517
- </YStack>
518
- <YStack position="absolute" top="$2" right="$2">
519
- <IconButtonGroup plate="media">
520
- <IconButton
521
- plate="media"
522
- icon={ArrowsClockwiseIcon}
523
- aria-label="Replace photo"
524
- onPress={pressed("replace")}
525
- />
506
+ <XStack alignItems="center" data-testid="icon-button-inline">
507
+ <Paragraph>10.64.96.1:4003</Paragraph>
508
+ <IconButton inline icon={CopyIcon} aria-label="Copy" onPress={pressed("copy")} />
509
+ </XStack>
510
+ <CodeBlock language="text">shc node join</CodeBlock>
511
+ <YStack
512
+ position="relative"
513
+ width="$20"
514
+ height="$12"
515
+ backgroundColor="$blue9"
516
+ borderRadius="$4"
517
+ data-testid="icon-button-media"
518
+ >
519
+ <YStack position="absolute" top="$2" left="$2">
526
520
  <IconButton
527
521
  plate="media"
528
- icon={XIcon}
529
- aria-label="Remove photo"
530
- onPress={pressed("remove")}
522
+ icon={HeartIcon}
523
+ aria-label="Favourite"
524
+ onPress={pressed("favourite")}
531
525
  />
532
- </IconButtonGroup>
526
+ </YStack>
527
+ <YStack position="absolute" top="$2" right="$2">
528
+ <IconButtonGroup plate="media">
529
+ <IconButton
530
+ plate="media"
531
+ icon={ArrowsClockwiseIcon}
532
+ aria-label="Replace photo"
533
+ onPress={pressed("replace")}
534
+ />
535
+ <IconButton
536
+ plate="media"
537
+ icon={XIcon}
538
+ aria-label="Remove photo"
539
+ onPress={pressed("remove")}
540
+ />
541
+ </IconButtonGroup>
542
+ </YStack>
533
543
  </YStack>
534
544
  </YStack>
535
545
  </YStack>
@@ -560,15 +570,20 @@ export const ShcNodesScreen: TintStory = {
560
570
  <Paragraph flex={1} size="$9" fontWeight="700">
561
571
  Nodes
562
572
  </Paragraph>
563
- <Button size="$3" {...shcRoundGlyph} icon={PlusIcon} aria-label="Add node" />
564
- </XStack>
565
- <XStack alignItems="center" gap="$2">
566
- <Paragraph fontFamily="$mono">shc node join</Paragraph>
567
- <Button size="$2" {...shcRoundGlyph} icon={CopyIcon} aria-label="Copy" />
568
- </XStack>
569
- <XStack alignItems="center">
570
- <Paragraph fontFamily="$mono">shc node join</Paragraph>
571
- <IconButton inline icon={CopyIcon} aria-label="Copy (inline)" />
573
+ <Popover anchorMode="cover-trigger">
574
+ <Popover.Trigger asChild>
575
+ <Button size="$3" {...shcRoundGlyph} icon={PlusIcon} aria-label="Add node" />
576
+ </Popover.Trigger>
577
+ <PopoverContent>
578
+ <YStack padding="$4" gap="$3" width={320} maxWidth="100%">
579
+ <Paragraph size="$5" fontWeight="600">
580
+ Add a node
581
+ </Paragraph>
582
+ <Paragraph color="$color11">Run this on the new machine.</Paragraph>
583
+ <CodeBlock language="text">shc node join</CodeBlock>
584
+ </YStack>
585
+ </PopoverContent>
586
+ </Popover>
572
587
  </XStack>
573
588
  </YStack>
574
589
  </GlyphTintFallbackProvider>
@@ -11,7 +11,7 @@ import { createDefaultThemeConfig, Surface } from "@multiplatform.one/theme";
11
11
  import type { ReactElement } from "react";
12
12
  import { TamaguiProvider } from "tamagui";
13
13
  import { afterEach, describe, expect, it, vi } from "vitest";
14
- import { IconButton, IconButtonGroup } from "./index";
14
+ import { IconButton, IconButtonGroup, useGlyphOnPlate } from "./index";
15
15
 
16
16
  const glass = vi.hoisted(() => ({
17
17
  available: false,
@@ -99,6 +99,53 @@ describe("LC-176: IconButtonGroup", () => {
99
99
  expect(screen.getByLabelText("Back").querySelector("[data-glass]")).not.toBeNull();
100
100
  });
101
101
 
102
+ it("a group of one is no capsule: its button keeps its own glass circle (FZ-8442)", () => {
103
+ vi.stubGlobal("navigator", { userAgent: IPHONE, platform: "iPhone", maxTouchPoints: 5 });
104
+ glass.available = true;
105
+ houseRender(
106
+ <Surface bar>
107
+ <IconButtonGroup data-testid="group">
108
+ <>
109
+ <IconButton icon={Glyph} aria-label="Add node" edge="end" />
110
+ </>
111
+ </IconButtonGroup>
112
+ </Surface>,
113
+ );
114
+ const group = screen.getByTestId("group");
115
+ expect(group.querySelector("[data-mp-glass-capsule]")).toBeNull();
116
+ expect(screen.getByLabelText("Add node").querySelector("[data-glass]")).not.toBeNull();
117
+ });
118
+
119
+ it("a glyph on glass is told it is plated, so it centres instead of pulling (FZ-8442)", () => {
120
+ vi.stubGlobal("navigator", { userAgent: IPHONE, platform: "iPhone", maxTouchPoints: 5 });
121
+ glass.available = true;
122
+ function Probe({ size }: { size?: number }) {
123
+ return <svg data-plated={String(useGlyphOnPlate())} width={size} height={size} />;
124
+ }
125
+ houseRender(
126
+ <>
127
+ <Surface bar>
128
+ <IconButton icon={Probe} aria-label="Back" edge="start" />
129
+ <IconButtonGroup>
130
+ <IconButton icon={Probe} aria-label="Archive" />
131
+ <IconButton icon={Probe} aria-label="Delete" edge="end" />
132
+ </IconButtonGroup>
133
+ </Surface>
134
+ <IconButton icon={Probe} aria-label="Copy" />
135
+ </>,
136
+ );
137
+ for (const name of ["Back", "Archive", "Delete"]) {
138
+ const button = screen.getByLabelText(name);
139
+ expect(button.querySelector("svg")?.getAttribute("data-plated")).toBe("true");
140
+ const style = getComputedStyle(button);
141
+ for (const side of ["marginLeft", "marginRight", "marginInlineStart", "marginInlineEnd"])
142
+ expect(parseFloat(style[side as "marginLeft"] || "0") || 0).toBeGreaterThanOrEqual(0);
143
+ }
144
+ expect(screen.getByLabelText("Copy").querySelector("svg")?.getAttribute("data-plated")).toBe(
145
+ "false",
146
+ );
147
+ });
148
+
102
149
  it("a lone glass circle fills the button's border box, as wide as the ✓ beside it (FZ-8439)", () => {
103
150
  glass.available = true;
104
151
  houseRender(
@@ -1,4 +1,9 @@
1
- import { isDarkThemeName, radiusClassProps, useBarSurface } from "@multiplatform.one/theme";
1
+ import {
2
+ isDarkThemeName,
3
+ radiusClassProps,
4
+ useBarSurface,
5
+ useFieldSurface,
6
+ } from "@multiplatform.one/theme";
2
7
  import { Children, useCallback, useMemo, useRef, useState, type ReactNode } from "react";
3
8
  import { isWeb, Theme, useThemeName, View, XStack, type XStackProps } from "tamagui";
4
9
  import { loadGlassContainer, loadGlassView } from "./glass";
@@ -35,7 +40,10 @@ export interface IconButtonGroupProps extends XStackProps {
35
40
  * bar group with no icon button in it paints no capsule (FZ-8494).
36
41
  */
37
42
  export function IconButtonGroup({ plate, children, ...rest }: IconButtonGroupProps) {
38
- const bar = useBarSurface();
43
+ // LC-176 (FZ-8711): a group in a field's slot is part of the field, so it
44
+ // paints no capsule of its own: neither bar glass nor the media plate.
45
+ const field = useFieldSurface();
46
+ const bar = useBarSurface() && !field;
39
47
  const dark = isDarkThemeName(useThemeName());
40
48
  const [capsuleHeight, setCapsuleHeight] = useState(0);
41
49
  const [members, setMembers] = useState(0);
@@ -59,7 +67,7 @@ export function IconButtonGroup({ plate, children, ...rest }: IconButtonGroupPro
59
67
  }),
60
68
  [],
61
69
  );
62
- if (plate === "media") {
70
+ if (plate === "media" && !field) {
63
71
  return (
64
72
  <IconButtonGroupContext.Provider value="media">
65
73
  <Theme name="dark">
@@ -80,26 +88,34 @@ export function IconButtonGroup({ plate, children, ...rest }: IconButtonGroupPro
80
88
  );
81
89
  }
82
90
  const GlassView = bar ? loadGlassView() : null;
91
+ // A group of one button is a lone button, and keeps its circle (FZ-8442).
92
+ const lone = members === 1 && Children.toArray(children).length === 1;
83
93
  if (GlassView && isWeb && isAppleTouchWeb()) {
84
94
  // The caller's layout props stay on the outer row; the capsule hugs the
85
95
  // buttons inside it, so a stretched slot never draws a wide empty pill.
96
+ // A lone button keeps the same tree, so it never remounts as it registers.
97
+ const capsuled = members > 0 && !lone;
86
98
  return (
87
99
  <IconButtonGroupRegistry.Provider value={register}>
88
- <IconButtonGroupContext.Provider value="glass">
100
+ <IconButtonGroupContext.Provider value={lone ? null : "glass"}>
89
101
  <XStack
90
102
  alignItems="center"
91
- data-mp-icon-button-group={members > 0 ? "glass" : "bar"}
103
+ data-mp-icon-button-group={capsuled ? "glass" : "bar"}
92
104
  {...rest}
93
105
  >
94
106
  <XStack
95
107
  alignItems="center"
96
108
  position="relative"
97
109
  zIndex={0}
98
- paddingHorizontal="$2"
99
- gap="$1"
100
- data-mp-glass-capsule=""
110
+ {...(lone
111
+ ? null
112
+ : {
113
+ paddingHorizontal: "$2" as const,
114
+ gap: "$1" as const,
115
+ "data-mp-glass-capsule": "",
116
+ })}
101
117
  >
102
- {members > 0 ? (
118
+ {capsuled ? (
103
119
  <GlassView
104
120
  style={{
105
121
  position: "absolute",
@@ -120,9 +136,7 @@ export function IconButtonGroup({ plate, children, ...rest }: IconButtonGroupPro
120
136
  }
121
137
  const capsule = GlassView != null && members > 0;
122
138
  // The capsule hugs its buttons with the web twin's $2 end inset and $1 gap,
123
- // so its first and last items never sit on its curve (FZ-8550). A group of
124
- // one button is a lone button, and keeps its circle.
125
- const lone = members === 1 && Children.toArray(children).length === 1;
139
+ // so its first and last items never sit on its curve (FZ-8550).
126
140
  const row = (
127
141
  <XStack
128
142
  alignItems="center"
@@ -15,6 +15,18 @@ export function useIconButtonGroupGround(): IconButtonGroupGround {
15
15
  return useContext(IconButtonGroupContext);
16
16
  }
17
17
 
18
+ /**
19
+ * LC-176: true while a glyph is drawn centred on a visible ground (a glass
20
+ * circle or capsule, the media plate, a framed circle). An icon that pulls
21
+ * itself toward a container edge for optical alignment, such as the back
22
+ * caret, keeps its ink centred instead.
23
+ */
24
+ export const GlyphPlateContext = createContext(false);
25
+
26
+ export function useGlyphOnPlate(): boolean {
27
+ return useContext(GlyphPlateContext);
28
+ }
29
+
18
30
  /**
19
31
  * An icon button counts itself into the group around it, so a bar group of
20
32
  * only non-icon children (a status badge in a page head) paints no capsule