@oxy.so/bloom 4.16.0 → 4.17.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 (86) hide show
  1. package/docs/adoption-matrix.mdx +13 -13
  2. package/docs/app-shell.mdx +3 -2
  3. package/docs/button-group.mdx +2 -1
  4. package/docs/button.mdx +24 -0
  5. package/docs/icons.mdx +53 -0
  6. package/docs/page-header.mdx +7 -0
  7. package/docs/tab-bar.mdx +1 -1
  8. package/docs/tabs.mdx +6 -0
  9. package/lib/commonjs/app-shell/AppShellEngine.js +9 -5
  10. package/lib/commonjs/app-shell/AppShellEngine.js.map +1 -1
  11. package/lib/commonjs/button/Button.js +7 -4
  12. package/lib/commonjs/button/Button.js.map +1 -1
  13. package/lib/commonjs/button/Button.web.js +21 -2
  14. package/lib/commonjs/button/Button.web.js.map +1 -1
  15. package/lib/commonjs/button-group/ButtonGroup.js +7 -4
  16. package/lib/commonjs/button-group/ButtonGroup.js.map +1 -1
  17. package/lib/commonjs/icons/index.js +8 -0
  18. package/lib/commonjs/icons/index.js.map +1 -1
  19. package/lib/commonjs/icons/render-icon.js +63 -0
  20. package/lib/commonjs/icons/render-icon.js.map +1 -0
  21. package/lib/commonjs/page-header/PageHeader.js +6 -1
  22. package/lib/commonjs/page-header/PageHeader.js.map +1 -1
  23. package/lib/commonjs/tab-bar/TabBarBase.js +38 -16
  24. package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
  25. package/lib/commonjs/tabs/Tabs.js +8 -1
  26. package/lib/commonjs/tabs/Tabs.js.map +1 -1
  27. package/lib/module/app-shell/AppShellEngine.js +9 -5
  28. package/lib/module/app-shell/AppShellEngine.js.map +1 -1
  29. package/lib/module/button/Button.js +7 -4
  30. package/lib/module/button/Button.js.map +1 -1
  31. package/lib/module/button/Button.web.js +21 -2
  32. package/lib/module/button/Button.web.js.map +1 -1
  33. package/lib/module/button-group/ButtonGroup.js +7 -4
  34. package/lib/module/button-group/ButtonGroup.js.map +1 -1
  35. package/lib/module/icons/index.js +1 -0
  36. package/lib/module/icons/index.js.map +1 -1
  37. package/lib/module/icons/render-icon.js +59 -0
  38. package/lib/module/icons/render-icon.js.map +1 -0
  39. package/lib/module/page-header/PageHeader.js +6 -1
  40. package/lib/module/page-header/PageHeader.js.map +1 -1
  41. package/lib/module/tab-bar/TabBarBase.js +38 -16
  42. package/lib/module/tab-bar/TabBarBase.js.map +1 -1
  43. package/lib/module/tabs/Tabs.js +8 -1
  44. package/lib/module/tabs/Tabs.js.map +1 -1
  45. package/lib/typescript/commonjs/app-shell/AppShellEngine.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/button/Button.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/button/Button.web.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/button/types.d.ts +10 -0
  49. package/lib/typescript/commonjs/button/types.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/button-group/ButtonGroup.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/button-group/types.d.ts +9 -0
  52. package/lib/typescript/commonjs/button-group/types.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/icons/index.d.ts +2 -0
  54. package/lib/typescript/commonjs/icons/index.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/icons/render-icon.d.ts +55 -0
  56. package/lib/typescript/commonjs/icons/render-icon.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/page-header/PageHeader.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/tabs/Tabs.d.ts.map +1 -1
  60. package/lib/typescript/module/app-shell/AppShellEngine.d.ts.map +1 -1
  61. package/lib/typescript/module/button/Button.d.ts.map +1 -1
  62. package/lib/typescript/module/button/Button.web.d.ts.map +1 -1
  63. package/lib/typescript/module/button/types.d.ts +10 -0
  64. package/lib/typescript/module/button/types.d.ts.map +1 -1
  65. package/lib/typescript/module/button-group/ButtonGroup.d.ts.map +1 -1
  66. package/lib/typescript/module/button-group/types.d.ts +9 -0
  67. package/lib/typescript/module/button-group/types.d.ts.map +1 -1
  68. package/lib/typescript/module/icons/index.d.ts +2 -0
  69. package/lib/typescript/module/icons/index.d.ts.map +1 -1
  70. package/lib/typescript/module/icons/render-icon.d.ts +55 -0
  71. package/lib/typescript/module/icons/render-icon.d.ts.map +1 -0
  72. package/lib/typescript/module/page-header/PageHeader.d.ts.map +1 -1
  73. package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
  74. package/lib/typescript/module/tabs/Tabs.d.ts.map +1 -1
  75. package/package.json +1 -1
  76. package/src/app-shell/AppShellEngine.tsx +9 -5
  77. package/src/button/Button.tsx +15 -6
  78. package/src/button/Button.web.tsx +30 -3
  79. package/src/button/types.ts +10 -0
  80. package/src/button-group/ButtonGroup.tsx +15 -6
  81. package/src/button-group/types.ts +9 -0
  82. package/src/icons/index.ts +2 -0
  83. package/src/icons/render-icon.tsx +65 -0
  84. package/src/page-header/PageHeader.tsx +6 -1
  85. package/src/tab-bar/TabBarBase.tsx +39 -16
  86. package/src/tabs/Tabs.tsx +3 -0
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/icons/index.ts"],"names":[],"mappings":"AAIA,YAAY,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AACjD,YAAY,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,KAAK,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AACpD,cAAc,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/icons/index.ts"],"names":[],"mappings":"AAIA,YAAY,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AACjD,YAAY,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,YAAY,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AACvE,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EAAE,KAAK,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AACpD,cAAc,SAAS,CAAC"}
@@ -0,0 +1,55 @@
1
+ import type { ReactNode } from 'react';
2
+ /**
3
+ * What a Bloom component tells an icon slot to draw: the box, and the colour of
4
+ * the glyph in the CURRENT state (rest, hover, pressed, disabled, checked).
5
+ *
6
+ * `color` carries the same value the component would have passed as `fill`. It
7
+ * is spelled `color` because this object is handed to CALLER code, and an app's
8
+ * own icon set almost always names it that.
9
+ */
10
+ export interface BloomIconPaint {
11
+ size: number;
12
+ color: string;
13
+ }
14
+ /**
15
+ * A caller-owned glyph that still follows the control's state.
16
+ *
17
+ * Bloom's own icon slots take a {@link BloomIconComponent} and tint it by
18
+ * passing `fill`. An app whose icons paint from a `color` prop — which most app
19
+ * icon sets do — cannot be handed to one of those slots directly: the props
20
+ * have nothing in common, so it does not typecheck, and were it cast it would
21
+ * render at its default size in its default colour, silently.
22
+ *
23
+ * Passing `color` alongside `fill` is not the fix, and deliberately so:
24
+ * `frosted-icon-button/shared.ts` records why — "the prop is not ours to
25
+ * claim", because components use `color` for semantic variants (`color="danger"`).
26
+ *
27
+ * Nor can the function form share the existing prop. A component is a function
28
+ * too, so a slot accepting both could not tell an app's ADAPTER component —
29
+ * `({ width, fill }) => <Glyph size={width} color={fill} />`, which works today
30
+ * and is the documented way to do this — from a paint callback. It would break
31
+ * exactly the callers who already did the right thing. Hence its own prop.
32
+ *
33
+ * ```tsx
34
+ * <ButtonGroupItem
35
+ * iconOnly
36
+ * renderLeadingIcon={({ size, color }) => <AppSearchIcon size={size} color={color} />}
37
+ * accessibilityLabel="Search"
38
+ * />
39
+ * ```
40
+ *
41
+ * This is `GlyphButton`'s `children?: ReactNode | ((foreground: string) => ReactNode)`
42
+ * generalised to the slots that take an icon rather than a child — including
43
+ * its precedence rule: where a family offers both, the renderer wins over the
44
+ * icon component.
45
+ */
46
+ export type BloomIconRenderer = (paint: BloomIconPaint) => ReactNode;
47
+ /**
48
+ * Resolve an icon slot that offers both forms.
49
+ *
50
+ * `render` wins when present, matching `GlyphButton`, where the caller-painted
51
+ * glyph wins over `icon`. Returns `null` when neither is set, so a call site
52
+ * stays a single expression.
53
+ */
54
+ export declare function resolveIconSlot(render: BloomIconRenderer | undefined, size: number, color: string, fallback: () => ReactNode): ReactNode;
55
+ //# sourceMappingURL=render-icon.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-icon.d.ts","sourceRoot":"","sources":["../../../../src/icons/render-icon.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,KAAK,EAAE,cAAc,KAAK,SAAS,CAAC;AAErE;;;;;;GAMG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,iBAAiB,GAAG,SAAS,EACrC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,SAAS,GACxB,SAAS,CAGX"}
@@ -1 +1 @@
1
- {"version":3,"file":"PageHeader.d.ts","sourceRoot":"","sources":["../../../../src/page-header/PageHeader.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAyD,MAAM,OAAO,CAAC;AAkC9E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AA8E/C,iBAAS,mBAAmB,CAAC,EAC3B,KAAK,EACL,QAAQ,EACR,UAAoB,EACpB,YAAgB,EAChB,WAAsB,EACtB,iBAAqB,EACrB,YAAyB,EACzB,SAAoB,EACpB,MAAM,EACN,SAAkB,EAClB,OAAO,EACP,OAAO,EACP,MAAe,EACf,WAAmB,EACnB,KAAc,EACd,UAAU,EACV,OAAO,EAAE,eAAe,EACxB,eAAmC,EACnC,MAAa,EACb,QAAQ,EACR,KAAK,EACL,MAAM,GACP,EAAE,eAAe,2CAwUjB;AAuED,eAAO,MAAM,UAAU,uDAA4B,CAAC"}
1
+ {"version":3,"file":"PageHeader.d.ts","sourceRoot":"","sources":["../../../../src/page-header/PageHeader.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAyD,MAAM,OAAO,CAAC;AAkC9E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AA8E/C,iBAAS,mBAAmB,CAAC,EAC3B,KAAK,EACL,QAAQ,EACR,UAAoB,EACpB,YAAgB,EAChB,WAAsB,EACtB,iBAAqB,EACrB,YAAyB,EACzB,SAAoB,EACpB,MAAM,EACN,SAAkB,EAClB,OAAO,EACP,OAAO,EACP,MAAe,EACf,WAAmB,EACnB,KAAc,EACd,UAAU,EACV,OAAO,EAAE,eAAe,EACxB,eAAmC,EACnC,MAAa,EACb,QAAQ,EACR,KAAK,EACL,MAAM,GACP,EAAE,eAAe,2CA6UjB;AAuED,eAAO,MAAM,UAAU,uDAA4B,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"TabBarBase.d.ts","sourceRoot":"","sources":["../../../../src/tab-bar/TabBarBase.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EASL,KAAK,aAAa,EAEnB,MAAM,OAAO,CAAC;AA2Bf,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AAItE,OAAO,EAkBL,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACxB,MAAM,UAAU,CAAC;AAClB,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAe,MAAM,SAAS,CAAC;AAwqB3E;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,aAAa,CAAC,kBAAkB,CAAC,EAC1C,IAAI,EAAE,aAAa,CAAC,oBAAoB,CAAC;YAElB,WAAW;;EAGnC;AAED,+EAA+E;AAC/E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,aAAa,CAAC,gBAAgB,CAAC;YAC1C,iBAAiB;;EAG/C"}
1
+ {"version":3,"file":"TabBarBase.d.ts","sourceRoot":"","sources":["../../../../src/tab-bar/TabBarBase.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EASL,KAAK,aAAa,EAEnB,MAAM,OAAO,CAAC;AA4Bf,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AAItE,OAAO,EAkBL,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACxB,MAAM,UAAU,CAAC;AAClB,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAe,MAAM,SAAS,CAAC;AA8rB3E;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,aAAa,CAAC,kBAAkB,CAAC,EAC1C,IAAI,EAAE,aAAa,CAAC,oBAAoB,CAAC;YAElB,WAAW;;EAGnC;AAED,+EAA+E;AAC/E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,aAAa,CAAC,gBAAgB,CAAC;YAC1C,iBAAiB;;EAG/C"}
@@ -1 +1 @@
1
- {"version":3,"file":"Tabs.d.ts","sourceRoot":"","sources":["../../../../src/tabs/Tabs.tsx"],"names":[],"mappings":"AACA,OAAO,KAUN,MAAM,OAAO,CAAC;AAoBf,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAS5C,OAAO,KAAK,EACV,gBAAgB,EAEhB,SAAS,EACT,gBAAgB,EAChB,WAAW,EACZ,MAAM,SAAS,CAAC;AAuGjB,MAAM,WAAW,SAAS;IACxB,8CAA8C;IAC9C,SAAS,EAAE,MAAM,CAAC;IAClB,yBAAyB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,8BAA8B;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,mCAAmC;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,uBAAuB,EAAE,MAAM,CAAC;IAChC,uBAAuB,EAAE,MAAM,CAAC;IAChC,mBAAmB,EAAE,MAAM,CAAC;IAC5B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,6DAA6D;IAC7D,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,OAAO,SAA0B,GAAG,SAAS,CAyBjH;AAqGD;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,kBAAkB;IACjC;;;;;;;;;;;;OAYG;IACH,IAAI,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IAC1C;;;;;;;;;OASG;IACH,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAAC;CACzC;AAsrBD,eAAO,MAAM,IAAI,iFAAyB,CAAC;AAG3C,eAAO,MAAM,WAAW,8CAAqB,CAAC;AAG9C,eAAO,MAAM,WAAW,8CAA0B,CAAC"}
1
+ {"version":3,"file":"Tabs.d.ts","sourceRoot":"","sources":["../../../../src/tabs/Tabs.tsx"],"names":[],"mappings":"AACA,OAAO,KAUN,MAAM,OAAO,CAAC;AAoBf,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAS5C,OAAO,KAAK,EACV,gBAAgB,EAEhB,SAAS,EACT,gBAAgB,EAChB,WAAW,EACZ,MAAM,SAAS,CAAC;AAuGjB,MAAM,WAAW,SAAS;IACxB,8CAA8C;IAC9C,SAAS,EAAE,MAAM,CAAC;IAClB,yBAAyB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,8BAA8B;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,mCAAmC;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,uBAAuB,EAAE,MAAM,CAAC;IAChC,uBAAuB,EAAE,MAAM,CAAC;IAChC,mBAAmB,EAAE,MAAM,CAAC;IAC5B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,6DAA6D;IAC7D,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,OAAO,SAA0B,GAAG,SAAS,CAyBjH;AAqGD;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,kBAAkB;IACjC;;;;;;;;;;;;OAYG;IACH,IAAI,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IAC1C;;;;;;;;;OASG;IACH,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAAC;CACzC;AAyrBD,eAAO,MAAM,IAAI,iFAAyB,CAAC;AAG3C,eAAO,MAAM,WAAW,8CAAqB,CAAC;AAG9C,eAAO,MAAM,WAAW,8CAA0B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/bloom",
3
- "version": "4.16.0",
3
+ "version": "4.17.0",
4
4
  "packageManager": "bun@1.3.14",
5
5
  "description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
6
6
  "main": "lib/commonjs/index.js",
@@ -538,7 +538,11 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
538
538
  // Plain navigation belongs to the viewport; only the reading surfaces are inset.
539
539
  const documentPanelInset = feedRevealed ? 12 : compactPanel ? 0 : gutter;
540
540
  const externalTopHeight = showTopBar ? topBarHeight : 0;
541
- const plainDocumentFrame = doc && contentAligned && flowSidebar?.surface === 'plain' && !topBarNode;
541
+ // A property of the NAVIGATION, not of who scrolls: native resolves every
542
+ // shell to container scroll, and gating this on `document` left native
543
+ // phones with an 8px frame around the reading panel that the same layout on
544
+ // the web does not draw.
545
+ const plainFrame = contentAligned && flowSidebar?.surface === 'plain' && !topBarNode;
542
546
  const readingGap = Math.max(0, asideGap ?? columnGap);
543
547
  const readingGroupWidth = contentWidth + (asideBeside ? asideWidth + readingGap : 0);
544
548
  const centredBody = (
@@ -556,7 +560,7 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
556
560
  // grows the page, the aside is sticky). Bounded: they stretch.
557
561
  alignItems: doc ? 'flex-start' : 'stretch',
558
562
  ...(contentAligned ? { flexGrow: compactPanel ? 1 : 0, flexBasis: compactPanel ? 'auto' : readingGroupWidth, maxWidth: compactPanel ? undefined : readingGroupWidth } : {}),
559
- ...(plainDocumentFrame ? { paddingTop: compactPanel ? 0 : gutter, paddingBottom: compactPanel ? 0 : gutter } : {}),
563
+ ...(plainFrame ? { paddingTop: compactPanel ? 0 : gutter, paddingBottom: compactPanel ? 0 : gutter } : {}),
560
564
  },
561
565
  fill,
562
566
  ]}
@@ -747,7 +751,7 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
747
751
  const navRegion =
748
752
  navInFlow && flowSidebar ? (
749
753
  doc ? (
750
- <View testID={testID ? `${testID}-navigation` : undefined} style={stickyRail(dockedNav || plainDocumentFrame ? 0 : gutter)}>
754
+ <View testID={testID ? `${testID}-navigation` : undefined} style={stickyRail(dockedNav || plainFrame ? 0 : gutter)}>
751
755
  <Sidebar {...flowSidebar} />
752
756
  </View>
753
757
  ) : canvas && !dockedNav ? (
@@ -794,8 +798,8 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
794
798
  // shorthand to physical sides, and which of two declarations for one edge
795
799
  // wins is then a matter of declaration order rather than intent.
796
800
  ...(dockedNav
797
- ? { paddingTop: 0, paddingBottom: 0, paddingInlineStart: 0, paddingInlineEnd: canvas || plainDocumentFrame ? 0 : gutter }
798
- : { padding: canvas || plainDocumentFrame ? 0 : gutter }),
801
+ ? { paddingTop: 0, paddingBottom: 0, paddingInlineStart: 0, paddingInlineEnd: canvas || plainFrame ? 0 : gutter }
802
+ : { padding: canvas || plainFrame ? 0 : gutter }),
799
803
  backgroundColor: background,
800
804
  };
801
805
 
@@ -1,5 +1,6 @@
1
1
  import type { LinkButtonProps } from './types';
2
2
  import React, { useMemo, memo, type ComponentType } from 'react';
3
+ import { resolveIconSlot } from '../icons/render-icon';
3
4
  import {
4
5
  ActivityIndicator,
5
6
  Linking,
@@ -175,6 +176,8 @@ const ButtonComponent: React.FC<ButtonProps> = ({
175
176
  trailing,
176
177
  leadingIcon: LeadingIcon,
177
178
  trailingIcon: TrailingIcon,
179
+ renderLeadingIcon,
180
+ renderTrailingIcon,
178
181
  iconOnly = false,
179
182
  linkTone = 'primary',
180
183
  underline,
@@ -298,9 +301,11 @@ const ButtonComponent: React.FC<ButtonProps> = ({
298
301
 
299
302
  const content = (
300
303
  <>
301
- {LeadingIcon ? (
302
- <LeadingIcon width={iconSize} height={iconSize} fill={paint.foreground} />
303
- ) : null}
304
+ {resolveIconSlot(renderLeadingIcon, iconSize, paint.foreground, () =>
305
+ LeadingIcon ? (
306
+ <LeadingIcon width={iconSize} height={iconSize} fill={paint.foreground} />
307
+ ) : null,
308
+ )}
304
309
  {leading}
305
310
  {iconNode}
306
311
  {!isSquare && children != null && (
@@ -313,9 +318,13 @@ const ButtonComponent: React.FC<ButtonProps> = ({
313
318
  </Text>
314
319
  )}
315
320
  {trailing}
316
- {!isSquare && TrailingIcon ? (
317
- <TrailingIcon width={iconSize} height={iconSize} fill={paint.foreground} />
318
- ) : null}
321
+ {!isSquare
322
+ ? resolveIconSlot(renderTrailingIcon, iconSize, paint.foreground, () =>
323
+ TrailingIcon ? (
324
+ <TrailingIcon width={iconSize} height={iconSize} fill={paint.foreground} />
325
+ ) : null,
326
+ )
327
+ : null}
319
328
  </>
320
329
  );
321
330
 
@@ -9,6 +9,7 @@ import React, {
9
9
  type ReactElement,
10
10
  } from 'react';
11
11
 
12
+ import { resolveIconSlot } from '../icons/render-icon';
12
13
  import { useBloomAppearance } from '../appearance/context';
13
14
  import { useTheme } from '../theme/use-theme';
14
15
  import { SpinnerIcon } from '../loading/SpinnerIcon.web';
@@ -182,6 +183,8 @@ const ButtonWebComponent: React.FC<ButtonProps> = ({
182
183
  trailing,
183
184
  leadingIcon: LeadingIcon,
184
185
  trailingIcon: TrailingIcon,
186
+ renderLeadingIcon,
187
+ renderTrailingIcon,
185
188
  iconOnly = false,
186
189
  linkTone = 'primary',
187
190
  underline,
@@ -339,11 +342,29 @@ const ButtonWebComponent: React.FC<ButtonProps> = ({
339
342
  const iconNode =
340
343
  icon == null ? null : isIconComponent(icon) ? renderIcon(icon) : (icon as React.ReactNode);
341
344
 
345
+ // A caller-drawn glyph is handed `currentColor` rather than the resolved
346
+ // foreground, for the same reason the icon components above are: on web the
347
+ // state colours come from the stylesheet, so a glyph that inherits them needs
348
+ // no re-render when hover or press changes them. Native has no cascade and
349
+ // passes the resolved value.
350
+ const renderSlot = (render: typeof renderLeadingIcon, fallback: () => React.ReactNode) => (
351
+ <span
352
+ aria-hidden="true"
353
+ style={{ display: 'inline-flex', flexShrink: 0, width: iconSize, height: iconSize }}
354
+ >
355
+ {resolveIconSlot(render, iconSize, 'currentColor', fallback)}
356
+ </span>
357
+ );
358
+
342
359
  const labelPadding = isLink ? 0 : geometry.labelPaddingHorizontal;
343
360
  const hasLabel = !isSquare && children != null && children !== false;
344
361
  const content = (
345
362
  <>
346
- {LeadingIcon ? renderIcon(LeadingIcon) : null}
363
+ {renderLeadingIcon
364
+ ? renderSlot(renderLeadingIcon, () => null)
365
+ : LeadingIcon
366
+ ? renderIcon(LeadingIcon)
367
+ : null}
347
368
  {leading}
348
369
  {iconNode}
349
370
  {hasLabel &&
@@ -367,9 +388,15 @@ const ButtonWebComponent: React.FC<ButtonProps> = ({
367
388
  ) : (
368
389
  children
369
390
  ))}
370
- {isSquare && !LeadingIcon && !iconNode && children != null ? children : null}
391
+ {isSquare && !renderLeadingIcon && !LeadingIcon && !iconNode && children != null
392
+ ? children
393
+ : null}
371
394
  {trailing}
372
- {!isSquare && TrailingIcon ? renderIcon(TrailingIcon) : null}
395
+ {!isSquare && renderTrailingIcon
396
+ ? renderSlot(renderTrailingIcon, () => null)
397
+ : !isSquare && TrailingIcon
398
+ ? renderIcon(TrailingIcon)
399
+ : null}
373
400
  </>
374
401
  );
375
402
 
@@ -1,4 +1,5 @@
1
1
  import type { BloomAppearance, BloomTone, BloomSize } from '../appearance/types';
2
+ import type { BloomIconRenderer } from '../icons/render-icon';
2
3
  import type { ComponentType, ReactNode } from 'react';
3
4
  import type { GestureResponderEvent, StyleProp, ViewStyle, TextStyle } from 'react-native';
4
5
  import type { WebAriaProps } from '../styles/styled-primitives';
@@ -173,6 +174,15 @@ export interface ButtonProps {
173
174
  leadingIcon?: ButtonIconComponent;
174
175
  /** Same as {@link ButtonProps.leadingIcon}, after the label. Ignored when `iconOnly`. */
175
176
  trailingIcon?: ButtonIconComponent;
177
+ /**
178
+ * Draw the leading glyph yourself, handed the button's icon size and the
179
+ * foreground for the CURRENT state — for an app's own icon set, whose props
180
+ * Bloom's icon slot cannot name. Wins over `leadingIcon`, and is used for the
181
+ * glyph when `iconOnly`. See `icons/render-icon.tsx`.
182
+ */
183
+ renderLeadingIcon?: BloomIconRenderer;
184
+ /** Same as {@link ButtonProps.renderLeadingIcon}, after the label. Ignored when `iconOnly`. */
185
+ renderTrailingIcon?: BloomIconRenderer;
176
186
  /**
177
187
  * Render a square icon-only button (24 / 32 / 36 / 44) from `leadingIcon` or
178
188
  * `icon`. Name it with `accessibilityLabel` — there is no text to read.
@@ -3,6 +3,7 @@ import React, { Children, createContext, Fragment, isValidElement, memo, useCont
3
3
  import { Platform, Pressable, View, type TextStyle } from 'react-native';
4
4
 
5
5
  import { useInheritedControl } from '../control-surface';
6
+ import { resolveIconSlot } from '../icons/render-icon';
6
7
  import { GlassIsland } from '../glass';
7
8
  import { useTheme } from '../theme/use-theme';
8
9
  import { Text } from '../typography/Typography';
@@ -271,6 +272,8 @@ const ButtonGroupItemComponent: React.FC<ButtonGroupItemProps> = ({
271
272
  iconOnly = false,
272
273
  leadingIcon: LeadingIcon,
273
274
  trailingIcon: TrailingIcon,
275
+ renderLeadingIcon,
276
+ renderTrailingIcon,
274
277
  accessibilityLabel,
275
278
  accessibilityRole = 'button',
276
279
  hitSlop,
@@ -355,9 +358,11 @@ const ButtonGroupItemComponent: React.FC<ButtonGroupItemProps> = ({
355
358
  aria-haspopup={ariaHasPopup}
356
359
  testID={testID}
357
360
  >
358
- {LeadingIcon ? (
359
- <LeadingIcon width={geometry.iconSize} height={geometry.iconSize} fill={foreground} />
360
- ) : null}
361
+ {resolveIconSlot(renderLeadingIcon, geometry.iconSize, foreground, () =>
362
+ LeadingIcon ? (
363
+ <LeadingIcon width={geometry.iconSize} height={geometry.iconSize} fill={foreground} />
364
+ ) : null,
365
+ )}
361
366
  {!iconOnly && children != null ? (
362
367
  typeof children === 'string' || typeof children === 'number' ? (
363
368
  <Text variant="body-medium" numberOfLines={1} style={labelStyle}>
@@ -367,9 +372,13 @@ const ButtonGroupItemComponent: React.FC<ButtonGroupItemProps> = ({
367
372
  children
368
373
  )
369
374
  ) : null}
370
- {!iconOnly && TrailingIcon ? (
371
- <TrailingIcon width={geometry.iconSize} height={geometry.iconSize} fill={foreground} />
372
- ) : null}
375
+ {!iconOnly
376
+ ? resolveIconSlot(renderTrailingIcon, geometry.iconSize, foreground, () =>
377
+ TrailingIcon ? (
378
+ <TrailingIcon width={geometry.iconSize} height={geometry.iconSize} fill={foreground} />
379
+ ) : null,
380
+ )
381
+ : null}
373
382
  </Pressable>
374
383
  );
375
384
  };
@@ -2,6 +2,7 @@ import type { GestureResponderEvent, StyleProp, ViewStyle } from 'react-native';
2
2
 
3
3
  import type { ControlMaterial } from '../control-surface/types';
4
4
  import type { ButtonIconComponent } from '../button/types';
5
+ import type { BloomIconRenderer } from '../icons/render-icon';
5
6
  import type { WebAriaProps } from '../styles/styled-primitives';
6
7
 
7
8
  /** The two group sizes: 34px (`md`) and 30px (`sm`) items. */
@@ -67,6 +68,14 @@ export interface ButtonGroupItemProps {
67
68
  leadingIcon?: ButtonIconComponent;
68
69
  /** Icon component after the label. */
69
70
  trailingIcon?: ButtonIconComponent;
71
+ /**
72
+ * Draw the leading glyph yourself, handed the item's size and the foreground
73
+ * for the CURRENT state — for an app's own icon set, whose props Bloom's icon
74
+ * slot cannot name. Wins over `leadingIcon`. See `icons/render-icon.tsx`.
75
+ */
76
+ renderLeadingIcon?: BloomIconRenderer;
77
+ /** Same as {@link ButtonGroupItemProps.renderLeadingIcon}, after the label. */
78
+ renderTrailingIcon?: BloomIconRenderer;
70
79
  accessibilityLabel?: string;
71
80
 
72
81
  // ── The trigger contract ────────────────────────────────────────────────
@@ -4,5 +4,7 @@
4
4
  // rewrites an app's imports.
5
5
  export type { IconStyle, Props } from './shared';
6
6
  export type { BloomIconComponent } from './icon-component';
7
+ export type { BloomIconPaint, BloomIconRenderer } from './render-icon';
8
+ export { resolveIconSlot } from './render-icon';
7
9
  export { sizes, useCommonSVGProps } from './shared';
8
10
  export * from './remix';
@@ -0,0 +1,65 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ /**
4
+ * What a Bloom component tells an icon slot to draw: the box, and the colour of
5
+ * the glyph in the CURRENT state (rest, hover, pressed, disabled, checked).
6
+ *
7
+ * `color` carries the same value the component would have passed as `fill`. It
8
+ * is spelled `color` because this object is handed to CALLER code, and an app's
9
+ * own icon set almost always names it that.
10
+ */
11
+ export interface BloomIconPaint {
12
+ size: number;
13
+ color: string;
14
+ }
15
+
16
+ /**
17
+ * A caller-owned glyph that still follows the control's state.
18
+ *
19
+ * Bloom's own icon slots take a {@link BloomIconComponent} and tint it by
20
+ * passing `fill`. An app whose icons paint from a `color` prop — which most app
21
+ * icon sets do — cannot be handed to one of those slots directly: the props
22
+ * have nothing in common, so it does not typecheck, and were it cast it would
23
+ * render at its default size in its default colour, silently.
24
+ *
25
+ * Passing `color` alongside `fill` is not the fix, and deliberately so:
26
+ * `frosted-icon-button/shared.ts` records why — "the prop is not ours to
27
+ * claim", because components use `color` for semantic variants (`color="danger"`).
28
+ *
29
+ * Nor can the function form share the existing prop. A component is a function
30
+ * too, so a slot accepting both could not tell an app's ADAPTER component —
31
+ * `({ width, fill }) => <Glyph size={width} color={fill} />`, which works today
32
+ * and is the documented way to do this — from a paint callback. It would break
33
+ * exactly the callers who already did the right thing. Hence its own prop.
34
+ *
35
+ * ```tsx
36
+ * <ButtonGroupItem
37
+ * iconOnly
38
+ * renderLeadingIcon={({ size, color }) => <AppSearchIcon size={size} color={color} />}
39
+ * accessibilityLabel="Search"
40
+ * />
41
+ * ```
42
+ *
43
+ * This is `GlyphButton`'s `children?: ReactNode | ((foreground: string) => ReactNode)`
44
+ * generalised to the slots that take an icon rather than a child — including
45
+ * its precedence rule: where a family offers both, the renderer wins over the
46
+ * icon component.
47
+ */
48
+ export type BloomIconRenderer = (paint: BloomIconPaint) => ReactNode;
49
+
50
+ /**
51
+ * Resolve an icon slot that offers both forms.
52
+ *
53
+ * `render` wins when present, matching `GlyphButton`, where the caller-painted
54
+ * glyph wins over `icon`. Returns `null` when neither is set, so a call site
55
+ * stays a single expression.
56
+ */
57
+ export function resolveIconSlot(
58
+ render: BloomIconRenderer | undefined,
59
+ size: number,
60
+ color: string,
61
+ fallback: () => ReactNode,
62
+ ): ReactNode {
63
+ if (render) return render({ size, color });
64
+ return fallback();
65
+ }
@@ -416,7 +416,12 @@ function PageHeaderComponent({
416
416
  style={[
417
417
  styles.container,
418
418
  { paddingTop: padTop },
419
- overlay ? [styles.overlay, { zIndex: Z_INDEX.floating }] : null,
419
+ overlay ? [styles.overlay, {
420
+ zIndex: Z_INDEX.floating,
421
+ // Android's native scroll surfaces also participate in elevation.
422
+ // Keep this local chrome above its sibling list without a portal.
423
+ ...(!isWeb ? { elevation: Z_INDEX.floating } : {}),
424
+ }] : null,
420
425
  containerWeb,
421
426
  placement === 'overlap' ? { marginBottom: -height, zIndex: Z_INDEX.floating } : null,
422
427
  style,
@@ -30,6 +30,7 @@ import {
30
30
  Pressable,
31
31
  StyleSheet,
32
32
  View,
33
+ type GestureResponderEvent,
33
34
  type ViewStyle,
34
35
  } from 'react-native';
35
36
  import { Gesture, GestureDetector } from 'react-native-gesture-handler';
@@ -112,6 +113,28 @@ type BarContextValue = {
112
113
 
113
114
  const BarContext = createContext<BarContextValue | null>(null);
114
115
 
116
+ /**
117
+ * Whether the bar's `Tap` gesture has already reported the touch a button's
118
+ * press came from. The gesture activates on RELEASE, after the `Pressable` has
119
+ * fired, and it cancels that press on iOS alone:
120
+ *
121
+ * - iOS: RNGH sets `cancelsTouchesInView`, so a press that still arrives came
122
+ * from something the gesture never saw (VoiceOver, a hardware keyboard).
123
+ * - Android: nothing is cancelled. Measured on a Pixel 10 Pro, one tap produced
124
+ * `PRESSABLE selectIndex(1)` AND `GESTURE tap selectIndex(1)` ~600ms apart.
125
+ * A press there cannot say whether it was a touch, so every press is taken
126
+ * for one.
127
+ * - Web: nothing is cancelled either. Measured in Mention's web build, one
128
+ * mouse click reached `onIndexChange` twice. react-native-web says which
129
+ * input it was: a pointer press arrives as the DOM `click`, and a keyboard
130
+ * Enter or Space as `keyup`, which the gesture never sees.
131
+ */
132
+ function gestureReportedPress(event: GestureResponderEvent): boolean {
133
+ if (Platform.OS === 'android') return true;
134
+ if (Platform.OS === 'web') return (event.nativeEvent as { type?: string }).type === 'click';
135
+ return false;
136
+ }
137
+
115
138
  function OptionalGesture({ enabled, children, ...props }: ComponentProps<typeof GestureDetector> & { enabled: boolean }) {
116
139
  return enabled ? <GestureDetector {...props}>{children}</GestureDetector> : <Fragment>{children}</Fragment>;
117
140
  }
@@ -699,8 +722,8 @@ function TabBarButtonBody({
699
722
  if (isFocused === undefined) bar?.selectIndex(index);
700
723
  }}
701
724
  onPress={(event) => {
702
- // The bar's GestureDetector normally consumes touches; this still fires
703
- // for assistive-technology activation (VoiceOver) and keyboard focus.
725
+ // Fires for keyboard and assistive-technology activation, and on
726
+ // Android and web also for the pointer taps the gesture reports.
704
727
  if (bar) {
705
728
  // Appear at the tab when hidden, slide to it when visible — the same
706
729
  // rule the tap gesture and the controlled path follow. Skipped on the
@@ -713,20 +736,20 @@ function TabBarButtonBody({
713
736
  bar.highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
714
737
  }
715
738
  setMinimized(minimized, 0);
716
- // Controlled path only, and NOT on Android. On the focus-driven path the
717
- // trigger's own `onPress` below performs the navigation, so reporting
718
- // the selection here as well would navigate twice.
719
- //
720
- // ANDROID IS THE ONE PLATFORM THAT DOUBLES. RNGH sets
721
- // `cancelsTouchesInView` on iOS, so a recognised tap cancels this press
722
- // and the gesture above is the only reporter there; react-native-web
723
- // likewise routes a keyboard Enter or Space through this press and
724
- // nothing else. Android cancels nothing — measured on a Pixel 10 Pro,
725
- // one tap produced `PRESSABLE selectIndex(1)` AND `GESTURE tap
726
- // selectIndex(1)` ~600ms apart on a busy JS thread — so there, and only
727
- // there, this press stands down.
728
- if (isFocused === undefined && (bar?.scrollable || Platform.OS !== 'android')) bar?.selectIndex(index);
729
- onPress?.(event);
739
+ // A tap the bar's gesture already reported is not reported again
740
+ // (`gestureReportedPress`). A scrollable bar has no gesture, so its
741
+ // presses are the only reporter.
742
+ const reported = bar != null && !bar.scrollable && gestureReportedPress(event);
743
+ // Controlled path only. On the focus-driven path the trigger's own
744
+ // `onPress` below performs the navigation, so reporting the selection
745
+ // here as well would navigate twice.
746
+ if (isFocused === undefined && !reported) bar?.selectIndex(index);
747
+ // The trigger's `onPress` is for what the gesture cannot see (keyboard,
748
+ // assistive technology). On web a pointer click has already navigated
749
+ // through `onIndexChange`, so it stands down. Android keeps calling it:
750
+ // a press there cannot tell a touch from a keyboard or D-pad, and
751
+ // dropping it would strand keyboard navigation.
752
+ if (!(reported && Platform.OS === 'web')) onPress?.(event);
730
753
  }}
731
754
  // `Pressable`'s `style` also accepts a function of the press state; both
732
755
  // forms are composed on top of the flex-share base so a caller can tint
package/src/tabs/Tabs.tsx CHANGED
@@ -780,6 +780,9 @@ const TabsBarComponent = forwardRef<TabsDragController, TabsProps>(function Tabs
780
780
  ref={scrollRef}
781
781
  {...stripA11y}
782
782
  horizontal
783
+ // A tab strip owns its intrinsic cross-axis height. RN ScrollView's
784
+ // default flexGrow: 1 otherwise consumes the list's remaining space.
785
+ style={{ flexGrow: 0, flexShrink: 0 }}
783
786
  showsHorizontalScrollIndicator={false}
784
787
  contentContainerStyle={[containerStyle, style]}
785
788
  onLayout={(e) => {