@oxyhq/bloom 1.0.0 → 1.0.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 (158) hide show
  1. package/README.md +16 -4
  2. package/docs/accordion.mdx +69 -0
  3. package/docs/activity-heatmap.mdx +71 -0
  4. package/docs/admonition.mdx +77 -0
  5. package/docs/alert-dialog.mdx +75 -0
  6. package/docs/alert.mdx +93 -0
  7. package/docs/animated-check.mdx +53 -0
  8. package/docs/aspect-ratio.mdx +45 -0
  9. package/docs/avatar-group.mdx +91 -0
  10. package/docs/avatar.mdx +126 -0
  11. package/docs/badge.mdx +74 -0
  12. package/docs/benefit-list.mdx +56 -0
  13. package/docs/bottom-sheet.mdx +103 -0
  14. package/docs/button.mdx +218 -0
  15. package/docs/card.mdx +106 -0
  16. package/docs/checkbox.mdx +59 -0
  17. package/docs/chip.mdx +53 -0
  18. package/docs/code.mdx +50 -0
  19. package/docs/combobox.mdx +64 -0
  20. package/docs/command.mdx +85 -0
  21. package/docs/composition-bar.mdx +63 -0
  22. package/docs/connection-dots.mdx +50 -0
  23. package/docs/connection-status.mdx +56 -0
  24. package/docs/content-panel.mdx +62 -0
  25. package/docs/context-menu.mdx +65 -0
  26. package/docs/design-tokens.mdx +254 -0
  27. package/docs/dialog.mdx +277 -0
  28. package/docs/divider.mdx +56 -0
  29. package/docs/dot-grid-meter.mdx +66 -0
  30. package/docs/dropdown-menu.mdx +161 -0
  31. package/docs/error-boundary.mdx +72 -0
  32. package/docs/fab.mdx +67 -0
  33. package/docs/field.mdx +70 -0
  34. package/docs/fill.mdx +58 -0
  35. package/docs/fonts.mdx +144 -0
  36. package/docs/frosted-icon-button.mdx +60 -0
  37. package/docs/getting-started.mdx +75 -0
  38. package/docs/grid.mdx +64 -0
  39. package/docs/hooks.mdx +81 -0
  40. package/docs/icon-circle.mdx +66 -0
  41. package/docs/icons.mdx +69 -0
  42. package/docs/image-aspect-ratio-cache.mdx +63 -0
  43. package/docs/image-resolver.mdx +66 -0
  44. package/docs/index.mdx +44 -0
  45. package/docs/input-group.mdx +58 -0
  46. package/docs/item.mdx +69 -0
  47. package/docs/kbd.mdx +57 -0
  48. package/docs/label.mdx +50 -0
  49. package/docs/link-preview.mdx +66 -0
  50. package/docs/list.mdx +72 -0
  51. package/docs/loading.mdx +129 -0
  52. package/docs/media-inset-border.mdx +52 -0
  53. package/docs/menubar.mdx +86 -0
  54. package/docs/migrating-to-1.0.mdx +434 -0
  55. package/docs/motion.mdx +83 -0
  56. package/docs/overlay.mdx +75 -0
  57. package/docs/popover.mdx +73 -0
  58. package/docs/portal.mdx +63 -0
  59. package/docs/pressable-scale.mdx +51 -0
  60. package/docs/profile-card.mdx +66 -0
  61. package/docs/progressive-blur.mdx +54 -0
  62. package/docs/prompt-input.mdx +183 -0
  63. package/docs/provider.mdx +64 -0
  64. package/docs/radio-indicator.mdx +60 -0
  65. package/docs/radio.mdx +65 -0
  66. package/docs/scroll.mdx +66 -0
  67. package/docs/search.mdx +67 -0
  68. package/docs/segmented-control.mdx +76 -0
  69. package/docs/select.mdx +93 -0
  70. package/docs/settings-list.mdx +186 -0
  71. package/docs/skeleton.mdx +66 -0
  72. package/docs/slider.mdx +73 -0
  73. package/docs/stat-bar.mdx +59 -0
  74. package/docs/stat-widgets.mdx +33 -0
  75. package/docs/styles.mdx +61 -0
  76. package/docs/subtle-hover.mdx +53 -0
  77. package/docs/surfaces.mdx +72 -0
  78. package/docs/switch.mdx +90 -0
  79. package/docs/tab-bar.mdx +386 -0
  80. package/docs/tabs.mdx +75 -0
  81. package/docs/text-field.mdx +187 -0
  82. package/docs/theme.mdx +67 -0
  83. package/docs/toast.mdx +285 -0
  84. package/docs/tooltip.mdx +67 -0
  85. package/docs/typography.mdx +88 -0
  86. package/docs/user-hover-card.mdx +140 -0
  87. package/docs/zoomable-image-gallery.mdx +66 -0
  88. package/lib/commonjs/design-tokens/tokens-json.js +13 -4
  89. package/lib/commonjs/design-tokens/tokens-json.js.map +1 -1
  90. package/lib/commonjs/design-tokens/tokens.json +6227 -265
  91. package/lib/commonjs/theme/ambient-store.js +16 -1
  92. package/lib/commonjs/theme/ambient-store.js.map +1 -1
  93. package/lib/commonjs/theme/color-policy.js +30 -33
  94. package/lib/commonjs/theme/color-policy.js.map +1 -1
  95. package/lib/commonjs/theme/color-presets.js +413 -166
  96. package/lib/commonjs/theme/color-presets.js.map +1 -1
  97. package/lib/commonjs/theme/glass-colors.js +18 -21
  98. package/lib/commonjs/theme/glass-colors.js.map +1 -1
  99. package/lib/commonjs/theme/index.js +30 -0
  100. package/lib/commonjs/theme/index.js.map +1 -1
  101. package/lib/commonjs/theme/index.web.js +30 -0
  102. package/lib/commonjs/theme/index.web.js.map +1 -1
  103. package/lib/module/design-tokens/tokens-json.js +14 -5
  104. package/lib/module/design-tokens/tokens-json.js.map +1 -1
  105. package/lib/module/design-tokens/tokens.json +6227 -265
  106. package/lib/module/theme/ambient-store.js +16 -1
  107. package/lib/module/theme/ambient-store.js.map +1 -1
  108. package/lib/module/theme/color-policy.js +30 -33
  109. package/lib/module/theme/color-policy.js.map +1 -1
  110. package/lib/module/theme/color-presets.js +412 -165
  111. package/lib/module/theme/color-presets.js.map +1 -1
  112. package/lib/module/theme/glass-colors.js +18 -21
  113. package/lib/module/theme/glass-colors.js.map +1 -1
  114. package/lib/module/theme/index.js +1 -1
  115. package/lib/module/theme/index.js.map +1 -1
  116. package/lib/module/theme/index.web.js +1 -1
  117. package/lib/module/theme/index.web.js.map +1 -1
  118. package/lib/typescript/commonjs/design-tokens/tokens-json.d.ts +12 -3
  119. package/lib/typescript/commonjs/design-tokens/tokens-json.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/theme/ambient-store.d.ts.map +1 -1
  121. package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/theme/color-presets.d.ts +386 -62
  123. package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
  124. package/lib/typescript/commonjs/theme/glass-colors.d.ts +18 -21
  125. package/lib/typescript/commonjs/theme/glass-colors.d.ts.map +1 -1
  126. package/lib/typescript/commonjs/theme/index.d.ts +2 -2
  127. package/lib/typescript/commonjs/theme/index.d.ts.map +1 -1
  128. package/lib/typescript/commonjs/theme/index.web.d.ts +2 -2
  129. package/lib/typescript/commonjs/theme/index.web.d.ts.map +1 -1
  130. package/lib/typescript/module/design-tokens/tokens-json.d.ts +12 -3
  131. package/lib/typescript/module/design-tokens/tokens-json.d.ts.map +1 -1
  132. package/lib/typescript/module/theme/ambient-store.d.ts.map +1 -1
  133. package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
  134. package/lib/typescript/module/theme/color-presets.d.ts +386 -62
  135. package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
  136. package/lib/typescript/module/theme/glass-colors.d.ts +18 -21
  137. package/lib/typescript/module/theme/glass-colors.d.ts.map +1 -1
  138. package/lib/typescript/module/theme/index.d.ts +2 -2
  139. package/lib/typescript/module/theme/index.d.ts.map +1 -1
  140. package/lib/typescript/module/theme/index.web.d.ts +2 -2
  141. package/lib/typescript/module/theme/index.web.d.ts.map +1 -1
  142. package/package.json +5 -2
  143. package/src/__tests__/support/collision-fixture-barrel.ts +20 -0
  144. package/src/__tests__/support/constructed-style-sheets.ts +68 -0
  145. package/src/__tests__/support/press-host.ts +30 -0
  146. package/src/__tests__/support/rendered-style.ts +99 -0
  147. package/src/__tests__/support/unread-hook-fixture.ts +33 -0
  148. package/src/design-tokens/tokens-json.ts +29 -9
  149. package/src/design-tokens/tokens.json +6227 -265
  150. package/src/glass/GlassSurface.tsx +2 -2
  151. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +4082 -0
  152. package/src/theme/__tests__/fixtures/color-engine-golden.json +1 -0
  153. package/src/theme/ambient-store.ts +21 -1
  154. package/src/theme/color-policy.ts +33 -38
  155. package/src/theme/color-presets.ts +473 -146
  156. package/src/theme/glass-colors.ts +18 -21
  157. package/src/theme/index.ts +16 -1
  158. package/src/theme/index.web.ts +16 -1
@@ -0,0 +1,126 @@
1
+ ---
2
+ title: Avatar
3
+ description: Image avatar with initials fallback, verified badge, and squircle option.
4
+ order: 12
5
+ ---
6
+
7
+ # Avatar
8
+
9
+ Display a user's avatar. Accepts a URL, an `ImageSourcePropType`, or no image at all — falls back to a colored circle with the first letter of `name` (deterministic per name). Optional verified badge. Optional squircle shape.
10
+
11
+ ## Basic
12
+
13
+ ```tsx
14
+ import { Avatar } from '@oxyhq/bloom';
15
+
16
+ <Avatar uri="https://cloud.oxy.so/avatars/abc.png" size={48} />
17
+ ```
18
+
19
+ ## Initials fallback
20
+
21
+ ```tsx
22
+ <Avatar name="Ada Lovelace" size={48} />
23
+ ```
24
+
25
+ Picks a deterministic background color from the `name` so the same user always gets the same color. White letter on top.
26
+
27
+ ## Verified badge
28
+
29
+ ```tsx
30
+ <Avatar uri={user.avatarUrl} verified size={64} />
31
+ ```
32
+
33
+ Custom badge:
34
+
35
+ ```tsx
36
+ <Avatar uri={user.avatarUrl} verified verifiedIcon={<StarIcon />} size={64} />
37
+ ```
38
+
39
+ ## Squircle shape
40
+
41
+ ```tsx
42
+ <Avatar uri={user.avatarUrl} shape="squircle" size={56} />
43
+ ```
44
+
45
+ Requires `react-native-svg`. Matches the iOS app-icon shape — a softened super-ellipse.
46
+
47
+ ## Pressable
48
+
49
+ ```tsx
50
+ <Avatar uri={user.avatarUrl} size={40} onPress={() => navigate('Profile', { id: user.id })} />
51
+ ```
52
+
53
+ Wraps the avatar in `TouchableOpacity` when `onPress` is set.
54
+
55
+ ## Custom placeholder
56
+
57
+ ```tsx
58
+ <Avatar
59
+ placeholderColor={theme.colors.primary}
60
+ placeholderIcon={<PersonIcon />}
61
+ size={64}
62
+ />
63
+ ```
64
+
65
+ When no `source`, `uri`, or `name` resolves, you can fully control the placeholder.
66
+
67
+ ## Props
68
+
69
+ | Prop | Type | Default | Description |
70
+ |------|------|---------|-------------|
71
+ | `source?` | `string \| ImageSourcePropType \| null` | — | Flexible image source. Takes precedence over `uri`. |
72
+ | `uri?` | `string` | — | Direct URI string. |
73
+ | `variant?` | `string` | `'thumb'` | Rendition forwarded to the `ImageResolver` when `source` is a bare file ID. Defaults to `'thumb'` so avatars never request the full-size original; pass an explicit larger variant when you need a bigger image. Ignored for full URL / `{uri}` sources. |
74
+ | `fallbackSource?` | `ImageSourcePropType` | — | Shown when `source`/`uri` errors. |
75
+ | `size?` | `number` | `40` | Diameter in dp. |
76
+ | `verified?` | `boolean` | `false` | Show a verified badge bottom-right. |
77
+ | `verifiedIcon?` | `React.ReactNode` | check icon | Custom badge node. |
78
+ | `shape?` | `'circle' \| 'squircle'` | `'circle'` | `'squircle'` needs `react-native-svg`. |
79
+ | `style?` | `StyleProp<ViewStyle>` | — | Container style. |
80
+ | `imageStyle?` | `StyleProp<ImageStyle>` | — | Image style (circle only). |
81
+ | `placeholderColor?` | `string` | derived | Override placeholder background. |
82
+ | `placeholderIcon?` | `React.ReactNode` | initial | Override placeholder icon/letter. |
83
+ | `name?` | `string` | — | Used to derive initials + a stable background color. |
84
+ | `onPress?` | `() => void` | — | Wraps in `TouchableOpacity`. |
85
+ | `testID?` | `string` | — | |
86
+
87
+ ## Recipes
88
+
89
+ ### Stacked avatars
90
+
91
+ ```tsx
92
+ <View style={{ flexDirection: 'row' }}>
93
+ {users.slice(0, 3).map((u, i) => (
94
+ <View key={u.id} style={{ marginLeft: i === 0 ? 0 : -8 }}>
95
+ <Avatar uri={u.avatarUrl} name={u.name} size={32} style={{ borderWidth: 2, borderColor: theme.colors.background }} />
96
+ </View>
97
+ ))}
98
+ {users.length > 3 ? (
99
+ <View style={{ marginLeft: -8, /* counter chip */ }}>
100
+ <Text>+{users.length - 3}</Text>
101
+ </View>
102
+ ) : null}
103
+ </View>
104
+ ```
105
+
106
+ ### Initials-only (no image)
107
+
108
+ ```tsx
109
+ <Avatar name={user.displayName ?? 'Anonymous'} size={40} />
110
+ ```
111
+
112
+ This is the recommended fallback when you don't have an avatar URL — never show a generic person silhouette unless you also have `placeholderColor` set to something brand-consistent.
113
+
114
+ ### With a status dot
115
+
116
+ ```tsx
117
+ <View>
118
+ <Avatar uri={user.avatarUrl} size={48} />
119
+ <View style={{
120
+ position: 'absolute', bottom: 0, right: 0,
121
+ width: 12, height: 12, borderRadius: 6,
122
+ backgroundColor: theme.colors.positive,
123
+ borderWidth: 2, borderColor: theme.colors.background,
124
+ }} />
125
+ </View>
126
+ ```
package/docs/badge.mdx ADDED
@@ -0,0 +1,74 @@
1
+ ---
2
+ title: Badge
3
+ description: Counts, dots and status pills — inline, or attached to the thing they are about.
4
+ order: 21
5
+ ---
6
+
7
+ # Badge
8
+
9
+ `Badge` is a small piece of state about something else: how many unread, which
10
+ status, whether there is anything new. It has two shapes behind one component —
11
+ an inline pill when it has no children, and an overlay pinned to a corner when
12
+ it wraps one.
13
+
14
+ ```tsx
15
+ import { Badge } from '@oxyhq/bloom/badge';
16
+
17
+ <Badge content="Beta" color="info" variant="subtle" />
18
+
19
+ <Badge content={5} color="error">
20
+ <IconCircle icon={BellIcon} />
21
+ </Badge>
22
+ ```
23
+
24
+ ## Which prop decides what
25
+
26
+ | prop | decides |
27
+ | ----------- | ----------------------------------------------------------- |
28
+ | `content` | the label; absent, the badge draws nothing |
29
+ | `color` | the semantic tone |
30
+ | `variant` | how loudly it is painted — `solid` · `subtle` · `outlined` |
31
+ | `dot` | drops the label entirely for a presence marker |
32
+ | `max` | caps a number and appends a plus |
33
+ | `invisible` | hides the badge **without unmounting the child** |
34
+ | `placement` | which corner, when it wraps a child |
35
+
36
+ `color` and `variant` are resolved together through `resolveAccentColors`,
37
+ which reads the `*Subtle` / `*SubtleForeground` pairs. That is why a subtle
38
+ badge is legible rather than one colour on the same colour — a tone lightened
39
+ by appending alpha to a token would not be. Concretely: an accent token
40
+ resolves to `rgb(...)`, so hand-appending a hex alpha suffix parses back
41
+ OPAQUE (contrast 1.00, a label on its own colour) rather than translucent —
42
+ `resolveAccentColors` is what makes the pairing legible instead of that.
43
+
44
+ ## Three props that each prevent a specific bug
45
+
46
+ **`max`** — an unread count is unbounded, and `1204` inside a 20px pill either
47
+ stretches the nav icon off screen or clips. `max={99}` renders `99+`.
48
+
49
+ **`invisible`** — the reason to prefer this over rendering the badge
50
+ conditionally: when a count reaches zero the child must stay exactly where it
51
+ is. Unmounting the badge is fine; unmounting the wrapper reflows the row.
52
+
53
+ ```tsx
54
+ <Badge content={unread} invisible={unread === 0}>
55
+ <InboxIcon />
56
+ </Badge>
57
+ ```
58
+
59
+ **`dot`** — a dot has no label to make legible, so it always paints the tone's
60
+ **fill**, whatever the `variant`. Following the variant made
61
+ `dot variant="outlined"` a fully transparent circle: visually absent, with
62
+ markup that reads as correct.
63
+
64
+ ## Accessibility
65
+
66
+ A badge is decoration for the control it sits on. The count must also be in
67
+ that control's accessible name — a screen reader user should hear
68
+ "Inbox, 5 unread", not "Inbox" and then a stray "5".
69
+
70
+ ```tsx
71
+ <Badge content={5}>
72
+ <Pressable accessibilityLabel={`Inbox, ${unread} unread`}>…</Pressable>
73
+ </Badge>
74
+ ```
@@ -0,0 +1,56 @@
1
+ ---
2
+ title: BenefitList
3
+ description: The "icon plus caption" card — what an app is asking for, in a consent step.
4
+ order: 60
5
+ ---
6
+
7
+ # BenefitList
8
+
9
+ A bordered card of icon-and-caption rows. It is the consent-screen shape: what
10
+ an app is about to get, one line each.
11
+
12
+ ```tsx
13
+ import { BenefitList, BenefitRow } from '@oxyhq/bloom/benefit-list';
14
+
15
+ <BenefitList accessibilityLabel="What you're sharing">
16
+ <BenefitRow icon={<PersonIcon size="sm" />} label="Your name and profile photo" />
17
+ <BenefitRow icon={<LockIcon size="sm" />} label="Your password is never shared" />
18
+ <BenefitRow icon={<ShieldIcon size="sm" />} label="You can revoke access at any time" />
19
+ </BenefitList>
20
+ ```
21
+
22
+ ## Which prop decides what
23
+
24
+ | prop | decides |
25
+ | -------------------- | --------------------------------------------------- |
26
+ | `icon` (row) | the glyph — required |
27
+ | `label` (row) | the caption, for the common one-line case |
28
+ | `children` (row) | rich content instead of a plain label |
29
+ | `accessibilityLabel` | names the card as a group |
30
+
31
+ ## Write what the user gets, not what the API grants
32
+
33
+ "Your name and profile photo" rather than "profile:read". A consent list is
34
+ read by someone deciding, and a scope name is not a decision they can make.
35
+
36
+ Say the limits too. "Your password is never shared" is a benefit row precisely
37
+ because it answers the question the user actually has.
38
+
39
+ ## `icon` is required for a reason
40
+
41
+ A row without a glyph collapses to a bullet list, and the icons are what make
42
+ the list scannable — someone skimming reads the shapes, not the sentences. If a
43
+ row has no natural icon, that is a sign it is prose rather than a benefit.
44
+
45
+ ## Keep the list short
46
+
47
+ Three to five rows. A consent screen with twelve is not more transparent — it
48
+ is a screen nobody reads, and the important row is buried among the routine
49
+ ones.
50
+
51
+ ## Chrome
52
+
53
+ The card is [`Card`](/docs/bloom/main/card)'s: the `card` background, a hairline
54
+ border, `shadow-s`, at the `radius-20` rung. It deliberately does **not** clip
55
+ its content, so an Android elevation draws the way it should under a rounded
56
+ view.
@@ -0,0 +1,103 @@
1
+ ---
2
+ title: BottomSheet
3
+ description: A standalone, draggable bottom sheet built on RN Modal + Reanimated + Gesture Handler.
4
+ order: 4
5
+ ---
6
+
7
+ # BottomSheet
8
+
9
+ A standalone, draggable bottom sheet built on React Native `Modal` + `react-native-reanimated` + `react-native-gesture-handler`. **Not** based on `@gorhom/bottom-sheet`, so it does not require `BottomSheetModalProvider`.
10
+
11
+ Use it when the compound `Dialog` API doesn't fit, when you want to avoid the Gorhom dependency, or when you need direct control over scroll, keyboard handling, or detached presentation.
12
+
13
+ ## Basic usage
14
+
15
+ ```tsx
16
+ import { useRef } from 'react';
17
+ import { BottomSheet, type BottomSheetRef } from '@oxyhq/bloom/bottom-sheet';
18
+
19
+ function Example() {
20
+ const sheetRef = useRef<BottomSheetRef>(null);
21
+
22
+ return (
23
+ <>
24
+ <Button onPress={() => sheetRef.current?.present()}>Open</Button>
25
+
26
+ <BottomSheet ref={sheetRef} onDismiss={() => console.log('dismissed')}>
27
+ <Text>Sheet content</Text>
28
+ </BottomSheet>
29
+ </>
30
+ );
31
+ }
32
+ ```
33
+
34
+ ## Ref methods
35
+
36
+ | Method | Description |
37
+ |--------|-------------|
38
+ | `present()` | Open the sheet. |
39
+ | `dismiss()` | Close and call `onDismiss`. |
40
+ | `close()` | Alias for `dismiss()`. |
41
+ | `expand()` | Snap to the upper position. |
42
+ | `collapse()` | Snap to the lower position. |
43
+ | `scrollTo(y, animated?)` | Programmatic scroll within the internal ScrollView. |
44
+
45
+ ## Props
46
+
47
+ | Prop | Type | Default | Description |
48
+ |------|------|---------|-------------|
49
+ | `children` | `ReactNode` | — | |
50
+ | `onDismiss?` | `() => void` | — | Fires when the sheet finishes closing. |
51
+ | `enablePanDownToClose?` | `boolean` | `true` | |
52
+ | `enableHandlePanningGesture?` | `boolean` | `true` | |
53
+ | `onDismissAttempt?` | `() => boolean` | — | Return `false` to veto a dismiss attempt. |
54
+ | `detached?` | `boolean` | `false` | Float with horizontal margins and rounded corners on all sides. |
55
+ | `showHandle?` | `boolean` | `true` | Toggle the drag handle pill. |
56
+ | `backdropOpacity?` | `number` | `0.5` | Opacity (0–1) of the dimming backdrop once fully visible. |
57
+ | `backgroundComponent?` | `ReactNode` | — | Custom background renderer. |
58
+ | `backdropComponent?` | `ReactNode` | — | Custom backdrop renderer. |
59
+ | `style?` | `StyleProp<ViewStyle>` | — | |
60
+ | `scrollable?` | `boolean` | `true` | When `false`, renders `children` directly without an internal `Animated.ScrollView` wrapper. Required when the sheet's content owns its own scrolling primitive (e.g. `FlatList`). |
61
+ | `manualActivation?` | `boolean` | `false` | When `true`, the body pan uses RNGH's `manualActivation` and only activates when the inner ScrollView is at the top AND the user has moved their finger downward by > 8dp. Recommended for sheets that contain a scrolling region on Android. |
62
+ | `dynamicBackdrop?` | `boolean` | `false` | Backdrop dims proportionally to drag distance — fades from full `backdropOpacity` to 30% as the sheet is pulled down 40% of the screen height. iOS Photos / iMessage look. |
63
+ | `handleComponent?` | `() => ReactNode` | — | Custom drag-handle renderer. Replaces the default 36×5 pill. |
64
+
65
+ ## Patterns
66
+
67
+ ### Sheet with a `FlatList` inside
68
+
69
+ Use `scrollable={false}` so the BottomSheet doesn't wrap the list in its own ScrollView, plus `manualActivation` so the drag handle remains the dedicated drag-to-dismiss surface while the list owns vertical scroll:
70
+
71
+ ```tsx
72
+ <BottomSheet ref={sheetRef} scrollable={false} manualActivation>
73
+ <FlatList data={items} renderItem={renderItem} />
74
+ </BottomSheet>
75
+ ```
76
+
77
+ ### iOS Photos-style backdrop dim
78
+
79
+ Combine `manualActivation` (so the inner photo grid keeps scroll ownership) with `dynamicBackdrop` (so the overlay fades as the user pulls down):
80
+
81
+ ```tsx
82
+ <BottomSheet
83
+ ref={sheetRef}
84
+ scrollable={false}
85
+ manualActivation
86
+ dynamicBackdrop
87
+ backdropOpacity={0.85}
88
+ >
89
+ <PhotoGrid />
90
+ </BottomSheet>
91
+ ```
92
+
93
+ ### Detached "floating" presentation
94
+
95
+ ```tsx
96
+ <BottomSheet ref={sheetRef} detached>
97
+ <PromptInput />
98
+ </BottomSheet>
99
+ ```
100
+
101
+ <Callout variant="info" title="Dialog vs BottomSheet">
102
+ If you just need a confirmation flow or a small custom panel, prefer [Dialog](/docs/bloom/main/dialog) — it cross-platforms to a centered modal on web. Reach for `BottomSheet` only when you need direct gesture / scroll control on native.
103
+ </Callout>
@@ -0,0 +1,218 @@
1
+ ---
2
+ title: Button
3
+ description: The all-purpose Bloom button — variants, sizes, loading state, icons.
4
+ order: 10
5
+ ---
6
+
7
+ # Button
8
+
9
+ The all-purpose Bloom button. Variants for primary/secondary/inverse/icon/ghost/text actions, three sizes, loading state, optional icons on either side, and full accessibility props.
10
+
11
+ ## Basic
12
+
13
+ ```tsx
14
+ import { Button } from '@oxyhq/bloom';
15
+
16
+ <Button onPress={save}>Save</Button>
17
+ ```
18
+
19
+ ## Variants
20
+
21
+ ```tsx
22
+ <Button variant="primary">Primary</Button>
23
+ <Button variant="secondary">Secondary</Button>
24
+ <Button variant="inverse">Inverse</Button>
25
+ <Button variant="ghost">Ghost</Button>
26
+ <Button variant="text">Text</Button>
27
+ <Button variant="icon" icon={<XIcon />} accessibilityLabel="Close" />
28
+ ```
29
+
30
+ `primary`, `secondary`, and `inverse` get a press-scale animation; the rest don't. `icon` renders as a square hit target — pair it with `accessibilityLabel`.
31
+
32
+ ## The filled variants are glass
33
+
34
+ `primary` and `destructive` are **tinted glass**: the brand colour at 85% over a `blur(10px)` of whatever is behind them, a hairline of the same colour at full strength, a lit white top rim and a soft drop shadow. The label is the fill's own on-colour, exactly as on the solid button — the pane IS the fill, so it carries the same label.
35
+
36
+ The glass is the sheen, not the wash. What reads as glass is the rim, the vertical gradient and the hairline; the 15% of the backdrop that shows through is a hint of depth, not the effect itself.
37
+
38
+ **One measured cost, stated because it is real.** The 15% bleed moves the pane toward the surface under it, which in light mode means lighter — the one direction a white label cannot afford. Of `primary`'s 340 preset × mode × surface combinations, **38 fall below WCAG AA**, in the band 4.17–4.49, all in light mode, on `blue`, `faircoin`, `green`, `lagoon`, `lavender`, `mint`, `olive`, `orange`, `pine`, `plum`, `pumpkin`, `rose`, `sky` and `yellow`. Both solid Button fills pass all 680 rows; glass `destructive` is also unaffected (0/340, worst 5.32). The alpha at which the failures reach zero is 0.89.
39
+
40
+ The previous 18-preset oracle was exactly 30/180. Those same rows and nine IDs remain unchanged; all eight added failures come from the new recipes. A browser helper briefly printed 45 because it parsed the structured `GLASS_SHEEN.bottom` channel object as CSS. That was drift in the measurement script, not Bloom's shipped baseline and not a threshold relaxed from 45 to 38.
41
+
42
+ If your app runs one of those fourteen presets in light mode and needs AA on its primary button, use `variant="inverse"` for that call site, or raise `GLASS_FILL_ALPHA`.
43
+
44
+ Over a retained black/mid-grey/white media sample, **25 of 204** preset × mode × backdrop rows fall below AA, worst 3.61 (`destructive`: 0). That is a sample rather than a promise about arbitrary pixels. If a CTA must be legible over an image, `variant="inverse"` is opaque and backdrop-independent:
45
+
46
+ ```tsx
47
+ <Button variant="inverse">Comprar</Button>
48
+ ```
49
+
50
+ `secondary`, `outline`, `ghost`, `text`, `link` and `icon` are unchanged: they have no fill for the tint to replace.
51
+
52
+ ### On Android the blur is not applied, and it cannot be
53
+
54
+ Measured on an Android emulator (API 37): the pane paints its tint and the backdrop behind it is **not blurred**. `expo-blur`'s `blurMethod` defaults to `'none'` on Android, and `dimezisBlurView` additionally needs a `blurTarget` ref pointing at a `BlurTargetView` that wraps the content to blur.
55
+
56
+ The obvious fix — wrap the app in a `BlurTargetView` and hand the ref down — **crashes the app**. A `BlurView` that is a DESCENDANT of the view it points at makes the Android render tree recurse into itself: `SIGSEGV` in `RenderThread`, `RenderNode::prepareTreeImpl` repeating past 500 frames. Reproduced twice, isolated to one target and one pane. A glass `Button` fills its caller and therefore sits inside the content it would need to blur, so the only topology available to it is the one that crashes.
57
+
58
+ Three topologies, all measured on device (over 10dp stripes, where `std` is the discriminator — the MEAN is nearly identical in every case, so a mean-only comparison reports "no difference"):
59
+
60
+ | `BlurView` position | result |
61
+ | --- | --- |
62
+ | descendant of its own `blurTarget` | **native crash** |
63
+ | sibling of the target | blurs; std 0.70 vs 11.95 unblurred |
64
+ | inside a `Modal`, target in the root window | blurs; std 0.68, matches the blurred composite to 0.36/255 |
65
+
66
+ So Bloom asks for no blur method on Android at all. What that costs:
67
+
68
+ - Over Bloom's own flat surfaces — the documented backdrop range — nothing visible changes. A blur over a uniform colour is a no-op, so the painted pane matched the web composite to **0.75/255**, and every AA number above still holds.
69
+ - Over **textured** content (a photograph, a busy list) the Android pane transmits that texture at 15% instead of smoothing it. The pill clip, the hairline, the rim, the sheen and the drop shadow are all unaffected.
70
+
71
+ The third row is where an Android blur is still available, and Bloom now takes it — but only for surfaces inside a real RN `<Modal>`, which is the bottom-sheet path and not a `Button`. See `docs/dialog.mdx` for how to switch it on.
72
+
73
+ **So on Android, "glass" means tint, top sheen, hairline and drop shadow — plus a blur only where a target exists.** A glass `Button` has the first four and never the fifth.
74
+
75
+ iOS uses `UIVisualEffectView` and needs none of this, but it has not been verified on hardware.
76
+
77
+ ### What a lower alpha would buy, and what it would cost
78
+
79
+ The recurring question about this material is whether it is transparent enough to be glass at all. `backdrop-filter` only acts on the fraction of the backdrop that gets **through** the pane, so at 0.85 the blur is running at full radius on 15% of the picture. Reproduce the whole trade with:
80
+
81
+ ```bash
82
+ bun run storybook # the DEV server; the script reads the theme through Vite
83
+ node scripts/measure-glass-alpha.mjs --url http://localhost:6006 --image docs/glass-alpha-sweep.png
84
+ ```
85
+
86
+ ![The same button at eleven fill alphas, over a flat page, a photograph and a hard stripe pattern](./glass-alpha-sweep.png)
87
+
88
+ Measured in Chrome off painted pixels, with the label removed from the measurement clones so a white glyph cannot be mistaken for backdrop:
89
+
90
+ | alpha | backdrop response | blur delta over a photo (max/mean) | blur delta on a flat page | `primary` rows < AA | `destructive` rows < AA |
91
+ | --- | --- | --- | --- | --- | --- |
92
+ | 0.89 | 28.0 | 13 / 3.5 | 1 | 0 / 340 | 0 / 340 |
93
+ | 0.88 | 31.0 | 15 / 3.8 | 1 | 5 / 340 | 0 / 340 |
94
+ | 0.87 | 33.0 | 16 / 4.0 | 1 | 14 / 340 | 0 / 340 |
95
+ | 0.86 | 36.0 | 17 / 4.4 | 1 | 25 / 340 | 0 / 340 |
96
+ | **0.85** (shipped) | 38.0 | 18 / 4.7 | 1 | 38 / 340 | 0 / 340 |
97
+ | 0.75 | 63.7 | 31 / 7.8 | 1 | 216 / 340 | 68 / 340 |
98
+ | 0.65 | 88.7 | 43 / 10.9 | 1 | 252 / 340 | 340 / 340 |
99
+ | 0.55 | 114.7 | 55 / 14.2 | 1 | 272 / 340 | 340 / 340 |
100
+ | 0.45 | 139.3 | 67 / 17.4 | 0 | 301 / 340 | 340 / 340 |
101
+ | 0.35 | 165.3 | 79 / 20.6 | 1 | 335 / 340 | 340 / 340 |
102
+ | 0.25 | 190.3 | 91 / 23.8 | 1 | 335 / 340 | 340 / 340 |
103
+
104
+ Everything is out of 255. Backdrop response is how far the painted pane moves between a white and a black backdrop — linear in `1 - alpha`, at 25.4 ± 0.7 per 0.10 of alpha. Blur delta is how much the picture changes when the blur is switched off, which is the honest form of "can you see the blur".
105
+
106
+ Three things the table settles:
107
+
108
+ - **On a flat page the blur is worth ~1/255 at every alpha, including 0.25.** There is nothing behind the pane to blur, so lowering the alpha does not make a flat page glassy — it only makes the button paler. That is the whole of the "it doesn't look glassy" complaint, and no alpha fixes it.
109
+ - **Over a photograph the blur is already visible at 0.85** (18/255 peak). Where there is something to see, the shipped material is doing the thing.
110
+ - **Legibility falls off a cliff immediately below 0.85.** One step down takes `primary` from 38 to 216 failures and `destructive` from 0 to 68; two steps down puts every one of `destructive`'s 340 rows under AA.
111
+
112
+ So there is no single alpha that both matches the reference and reads as glass on a flat surface. That is a property of the material — a blur needs a backdrop — not a constant nobody has tuned yet. To make a specific surface read as glass, put something behind it.
113
+
114
+ ## Sizes
115
+
116
+ ```tsx
117
+ <Button size="small">Small</Button>
118
+ <Button size="medium">Medium</Button>
119
+ <Button size="large">Large</Button>
120
+ ```
121
+
122
+ Heights are 32 / 36 / 44 dp; font sizes 14 / 15 / 16. The height comes from `minHeight` alone — the vertical padding is sized to keep the label's line box underneath it — so a button measures the same on native and on web, and in every variant. The one exception is `text` / `link`, which clears `minHeight` on web so the control hugs its label and sits inline.
123
+
124
+ Native adds vertical `hitSlop` to bring `small` and `medium` up to a 44 dp touch target; `large` is already there. Horizontal slop is zero, so two buttons in a row cannot steal each other's presses. On web `hitSlop` does not exist and the target is the box: 32 / 36 / 44 px.
125
+
126
+ ## Loading
127
+
128
+ ```tsx
129
+ const [busy, setBusy] = useState(false);
130
+
131
+ <Button
132
+ loading={busy}
133
+ onPress={async () => {
134
+ setBusy(true);
135
+ try { await submit(); } finally { setBusy(false); }
136
+ }}
137
+ >
138
+ Submit
139
+ </Button>
140
+ ```
141
+
142
+ When `loading` is true, a centered spinner appears, children stay in the layout (hidden) so width doesn't jump, and presses are blocked. `loadingColor` overrides the spinner color (defaults to the resolved text color).
143
+
144
+ ## With an icon
145
+
146
+ ```tsx
147
+ <Button icon={<DownloadIcon />} iconPosition="left">Download</Button>
148
+ <Button icon={<ArrowRightIcon />} iconPosition="right">Continue</Button>
149
+ ```
150
+
151
+ ## Disabled
152
+
153
+ ```tsx
154
+ <Button disabled>Can't click me</Button>
155
+ ```
156
+
157
+ `disabled` blocks presses, drops opacity, and removes the press animation.
158
+
159
+ ## Props
160
+
161
+ | Prop | Type | Default | Description |
162
+ |------|------|---------|-------------|
163
+ | `onPress?` | `() => void` | — | Press handler. |
164
+ | `children?` | `React.ReactNode` | — | Button label. Strings are auto-wrapped in `Text`. |
165
+ | `disabled?` | `boolean` | `false` | Blocks presses + drops opacity. |
166
+ | `variant?` | `'primary' \| 'secondary' \| 'inverse' \| 'icon' \| 'ghost' \| 'text'` | `'primary'` | Visual style. |
167
+ | `size?` | `'small' \| 'medium' \| 'large'` | `'medium'` | Padding + font size + min height. |
168
+ | `icon?` | `React.ReactNode` | — | Icon node rendered next to the label. |
169
+ | `iconPosition?` | `'left' \| 'right'` | `'left'` | Which side of the label. |
170
+ | `loading?` | `boolean` | `false` | Show centered spinner, block presses. |
171
+ | `loadingColor?` | `string` | text color | Override the spinner color. |
172
+ | `style?` | `StyleProp<ViewStyle>` | — | Outer container style. |
173
+ | `textStyle?` | `StyleProp<TextStyle>` | — | Label style. |
174
+ | `accessibilityLabel?` | `string` | — | Required for icon-only buttons. |
175
+ | `accessibilityHint?` | `string` | — | |
176
+ | `hitSlop?` | `{ top, bottom, left, right }` | — | Expand the touch target. |
177
+ | `testID?` | `string` | — | RN testing id. |
178
+ | `className?` | `string` | — | NativeWind class (web). |
179
+
180
+ ## Recipes
181
+
182
+ ### Destructive confirmation
183
+
184
+ ```tsx
185
+ <Button variant="primary" style={{ backgroundColor: theme.colors.negative }} onPress={destroy}>
186
+ Delete forever
187
+ </Button>
188
+ ```
189
+
190
+ For most destructive actions, prefer a `<Dialog>` with `color: 'destructive'` actions over a destructive button in the main flow.
191
+
192
+ ### Submit button with promise
193
+
194
+ ```tsx
195
+ const [busy, setBusy] = useState(false);
196
+
197
+ <Button
198
+ loading={busy}
199
+ onPress={async () => {
200
+ setBusy(true);
201
+ try { await save(form); toast.success('Saved'); }
202
+ catch (err) { toast.error(extractErrorMessage(err)); }
203
+ finally { setBusy(false); }
204
+ }}
205
+ >
206
+ Save changes
207
+ </Button>
208
+ ```
209
+
210
+ ### Icon button row
211
+
212
+ ```tsx
213
+ <View style={{ flexDirection: 'row', gap: 4 }}>
214
+ <Button variant="icon" icon={<BoldIcon />} accessibilityLabel="Bold" />
215
+ <Button variant="icon" icon={<ItalicIcon />} accessibilityLabel="Italic" />
216
+ <Button variant="icon" icon={<LinkIcon />} accessibilityLabel="Link" />
217
+ </View>
218
+ ```