@oxyhq/bloom 0.10.0 → 0.12.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 (81) hide show
  1. package/lib/commonjs/button/Button.js +11 -5
  2. package/lib/commonjs/button/Button.js.map +1 -1
  3. package/lib/commonjs/button/Button.web.js +9 -5
  4. package/lib/commonjs/button/Button.web.js.map +1 -1
  5. package/lib/commonjs/fab/Fab.js +199 -0
  6. package/lib/commonjs/fab/Fab.js.map +1 -0
  7. package/lib/commonjs/fab/Fab.web.js +246 -0
  8. package/lib/commonjs/fab/Fab.web.js.map +1 -0
  9. package/lib/commonjs/fab/index.js +13 -0
  10. package/lib/commonjs/fab/index.js.map +1 -0
  11. package/lib/commonjs/fab/index.web.js +13 -0
  12. package/lib/commonjs/fab/index.web.js.map +1 -0
  13. package/lib/commonjs/fab/types.js +6 -0
  14. package/lib/commonjs/fab/types.js.map +1 -0
  15. package/lib/commonjs/index.js +8 -0
  16. package/lib/commonjs/index.js.map +1 -1
  17. package/lib/commonjs/index.web.js +24 -16
  18. package/lib/commonjs/index.web.js.map +1 -1
  19. package/lib/module/button/Button.js +11 -5
  20. package/lib/module/button/Button.js.map +1 -1
  21. package/lib/module/button/Button.web.js +9 -5
  22. package/lib/module/button/Button.web.js.map +1 -1
  23. package/lib/module/fab/Fab.js +194 -0
  24. package/lib/module/fab/Fab.js.map +1 -0
  25. package/lib/module/fab/Fab.web.js +241 -0
  26. package/lib/module/fab/Fab.web.js.map +1 -0
  27. package/lib/module/fab/index.js +4 -0
  28. package/lib/module/fab/index.js.map +1 -0
  29. package/lib/module/fab/index.web.js +14 -0
  30. package/lib/module/fab/index.web.js.map +1 -0
  31. package/lib/module/fab/types.js +4 -0
  32. package/lib/module/fab/types.js.map +1 -0
  33. package/lib/module/index.js +1 -0
  34. package/lib/module/index.js.map +1 -1
  35. package/lib/module/index.web.js +1 -0
  36. package/lib/module/index.web.js.map +1 -1
  37. package/lib/typescript/commonjs/button/Button.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/button/Button.web.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/fab/Fab.d.ts +5 -0
  40. package/lib/typescript/commonjs/fab/Fab.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/fab/Fab.web.d.ts +5 -0
  42. package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -0
  43. package/lib/typescript/commonjs/fab/index.d.ts +3 -0
  44. package/lib/typescript/commonjs/fab/index.d.ts.map +1 -0
  45. package/lib/typescript/commonjs/fab/index.web.d.ts +3 -0
  46. package/lib/typescript/commonjs/fab/index.web.d.ts.map +1 -0
  47. package/lib/typescript/commonjs/fab/types.d.ts +99 -0
  48. package/lib/typescript/commonjs/fab/types.d.ts.map +1 -0
  49. package/lib/typescript/commonjs/index.d.ts +2 -0
  50. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/index.web.d.ts +2 -0
  52. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  53. package/lib/typescript/module/button/Button.d.ts.map +1 -1
  54. package/lib/typescript/module/button/Button.web.d.ts.map +1 -1
  55. package/lib/typescript/module/fab/Fab.d.ts +5 -0
  56. package/lib/typescript/module/fab/Fab.d.ts.map +1 -0
  57. package/lib/typescript/module/fab/Fab.web.d.ts +5 -0
  58. package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -0
  59. package/lib/typescript/module/fab/index.d.ts +3 -0
  60. package/lib/typescript/module/fab/index.d.ts.map +1 -0
  61. package/lib/typescript/module/fab/index.web.d.ts +3 -0
  62. package/lib/typescript/module/fab/index.web.d.ts.map +1 -0
  63. package/lib/typescript/module/fab/types.d.ts +99 -0
  64. package/lib/typescript/module/fab/types.d.ts.map +1 -0
  65. package/lib/typescript/module/index.d.ts +2 -0
  66. package/lib/typescript/module/index.d.ts.map +1 -1
  67. package/lib/typescript/module/index.web.d.ts +2 -0
  68. package/lib/typescript/module/index.web.d.ts.map +1 -1
  69. package/package.json +17 -1
  70. package/src/__tests__/Fab.test.tsx +76 -0
  71. package/src/__tests__/Fab.web.test.tsx +114 -0
  72. package/src/button/Button.tsx +11 -5
  73. package/src/button/Button.web.tsx +8 -5
  74. package/src/fab/Fab.stories.tsx +71 -0
  75. package/src/fab/Fab.tsx +189 -0
  76. package/src/fab/Fab.web.tsx +258 -0
  77. package/src/fab/index.ts +2 -0
  78. package/src/fab/index.web.ts +12 -0
  79. package/src/fab/types.ts +125 -0
  80. package/src/index.ts +2 -0
  81. package/src/index.web.ts +2 -0
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import React from 'react';
6
+ import { act } from 'react';
7
+ import { createRoot, type Root } from 'react-dom/client';
8
+ import { getByRole, getByText, getByLabelText, fireEvent } from '@testing-library/dom';
9
+ import '@testing-library/jest-dom';
10
+
11
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
12
+ import { Fab } from '../fab/Fab.web';
13
+
14
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ let container: HTMLDivElement;
17
+ let root: Root;
18
+
19
+ function mount(ui: React.ReactElement): HTMLElement {
20
+ act(() => {
21
+ root.render(
22
+ <BloomThemeProvider mode="light" colorPreset="teal">
23
+ {ui}
24
+ </BloomThemeProvider>,
25
+ );
26
+ });
27
+ return container;
28
+ }
29
+
30
+ beforeEach(() => {
31
+ container = document.createElement('div');
32
+ document.body.appendChild(container);
33
+ root = createRoot(container);
34
+ });
35
+
36
+ afterEach(() => {
37
+ act(() => root.unmount());
38
+ container.remove();
39
+ });
40
+
41
+ describe('Fab.web', () => {
42
+ it('renders a real <button> element', () => {
43
+ const c = mount(<Fab accessibilityLabel="Add" icon={<span>+</span>} />);
44
+ const fab = getByRole(c, 'button', { name: 'Add' });
45
+ expect(fab.tagName).toBe('BUTTON');
46
+ });
47
+
48
+ it('defaults to type="button"', () => {
49
+ const c = mount(<Fab accessibilityLabel="Add" icon={<span>+</span>} />);
50
+ expect(getByRole(c, 'button')).toHaveAttribute('type', 'button');
51
+ });
52
+
53
+ it('fires both onClick and onPress on click', () => {
54
+ const onClick = jest.fn();
55
+ const onPress = jest.fn();
56
+ const c = mount(
57
+ <Fab accessibilityLabel="Add" onClick={onClick} onPress={onPress} icon={<span>+</span>} />,
58
+ );
59
+ act(() => {
60
+ fireEvent.click(getByRole(c, 'button'));
61
+ });
62
+ expect(onClick).toHaveBeenCalledTimes(1);
63
+ expect(onPress).toHaveBeenCalledTimes(1);
64
+ });
65
+
66
+ it('does not fire handlers when disabled', () => {
67
+ const onPress = jest.fn();
68
+ const c = mount(
69
+ <Fab accessibilityLabel="Add" disabled onPress={onPress} icon={<span>+</span>} />,
70
+ );
71
+ const fab = getByRole(c, 'button');
72
+ expect(fab).toBeDisabled();
73
+ act(() => {
74
+ fireEvent.click(fab);
75
+ });
76
+ expect(onPress).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it('uses position: sticky for the default bottom-right placement (NOT fixed)', () => {
80
+ const c = mount(<Fab accessibilityLabel="Add" icon={<span>+</span>} />);
81
+ const fab = getByRole(c, 'button');
82
+ expect(fab.style.position).toBe('sticky');
83
+ expect(fab.style.bottom).toBe('16px');
84
+ expect(fab.style.alignSelf).toBe('flex-end');
85
+ });
86
+
87
+ it('applies no positioning for placement="static"', () => {
88
+ const c = mount(<Fab accessibilityLabel="Add" placement="static" icon={<span>+</span>} />);
89
+ const fab = getByRole(c, 'button');
90
+ expect(fab.style.position).toBe('');
91
+ });
92
+
93
+ it('renders an extended label', () => {
94
+ const c = mount(<Fab label="Compose" icon={<span>+</span>} />);
95
+ expect(getByText(c, 'Compose')).toBeTruthy();
96
+ });
97
+
98
+ it('applies aria-label from accessibilityLabel', () => {
99
+ const c = mount(<Fab accessibilityLabel="Add post" icon={<span>+</span>} />);
100
+ expect(getByLabelText(c, 'Add post')).toBeTruthy();
101
+ });
102
+
103
+ it('passes className through so consumer layout classes win', () => {
104
+ const c = mount(<Fab accessibilityLabel="Add" className="my-fab" icon={<span>+</span>} />);
105
+ const fab = getByRole(c, 'button');
106
+ expect(fab).toHaveClass('bloom-fab');
107
+ expect(fab).toHaveClass('my-fab');
108
+ });
109
+
110
+ it('exposes testID as data-testid', () => {
111
+ const c = mount(<Fab accessibilityLabel="Add" testID="my-fab" icon={<span>+</span>} />);
112
+ expect(c.querySelector('[data-testid="my-fab"]')?.tagName).toBe('BUTTON');
113
+ });
114
+ });
@@ -61,6 +61,11 @@ const SIZE_CONFIG = {
61
61
 
62
62
  const ICON_HIT_SLOP = { top: 10, bottom: 10, left: 10, right: 10 } as const;
63
63
 
64
+ // Fully-rounded (pill/capsule) corner radius. Large enough to stay a perfect
65
+ // capsule at any button height. Shared by every text button variant so they all
66
+ // read as pills; the icon variant keeps its own large radius to stay a circle.
67
+ const PILL_RADIUS = 999;
68
+
64
69
  const PRESS_SCALE = 0.97;
65
70
  const SCALE_VARIANTS = new Set<string>(['primary', 'secondary', 'inverse']);
66
71
 
@@ -152,30 +157,31 @@ const ButtonComponent: React.FC<ButtonProps> = ({
152
157
  styles.backgroundColor = isDestructive
153
158
  ? theme.colors.negative
154
159
  : theme.colors.primary;
155
- styles.borderRadius = 20;
160
+ styles.borderRadius = PILL_RADIUS;
156
161
  break;
157
162
  case 'secondary':
158
163
  styles.backgroundColor = 'transparent';
159
164
  styles.borderWidth = 1;
160
165
  styles.borderColor = theme.colors.border;
161
- styles.borderRadius = 20;
166
+ styles.borderRadius = PILL_RADIUS;
162
167
  break;
163
168
  case 'inverse':
164
169
  styles.backgroundColor = '#FFFFFF';
165
- styles.borderRadius = 20;
170
+ styles.borderRadius = PILL_RADIUS;
166
171
  break;
167
172
  case 'icon':
168
- styles.borderRadius = 100;
173
+ styles.borderRadius = PILL_RADIUS;
169
174
  styles.padding = 8;
170
175
  styles.width = sizeConfig.minHeight;
171
176
  styles.height = sizeConfig.minHeight;
172
177
  break;
173
178
  case 'ghost':
174
179
  styles.backgroundColor = 'transparent';
175
- styles.borderRadius = 8;
180
+ styles.borderRadius = PILL_RADIUS;
176
181
  break;
177
182
  case 'text':
178
183
  styles.backgroundColor = 'transparent';
184
+ styles.borderRadius = PILL_RADIUS;
179
185
  styles.paddingVertical = 4;
180
186
  styles.paddingHorizontal = 8;
181
187
  break;
@@ -47,9 +47,12 @@ const SIZE_ALIAS: Record<ButtonSize, NativeSize> = {
47
47
  icon: 'medium',
48
48
  };
49
49
 
50
- const PILL_RADIUS = 20;
51
- const GHOST_RADIUS = 8;
52
- const ICON_RADIUS = 100;
50
+ // Fully-rounded (pill/capsule) corner radius — large enough to stay a perfect
51
+ // capsule at any button height. Mirrors native `PILL_RADIUS` so web and native
52
+ // produce the SAME fully-rounded shape. The icon variant reuses it too: a square
53
+ // button with this radius renders as a perfect circle.
54
+ const PILL_RADIUS = 999;
55
+ const ICON_RADIUS = PILL_RADIUS;
53
56
  const PRESS_SCALE = 0.97;
54
57
 
55
58
  /** Variants that get a tactile press-scale (matches native `SCALE_VARIANTS`). */
@@ -183,7 +186,7 @@ function resolveVariantStyle(
183
186
  };
184
187
  case 'ghost':
185
188
  return {
186
- container: { backgroundColor: 'transparent', borderRadius: GHOST_RADIUS },
189
+ container: { backgroundColor: 'transparent', borderRadius: PILL_RADIUS },
187
190
  textColor: c.primary,
188
191
  ringColor: c.primary,
189
192
  };
@@ -206,7 +209,7 @@ function resolveVariantStyle(
206
209
  case 'link':
207
210
  default:
208
211
  return {
209
- container: { backgroundColor: 'transparent', borderRadius: 0 },
212
+ container: { backgroundColor: 'transparent', borderRadius: PILL_RADIUS },
210
213
  textColor: c.primary,
211
214
  ringColor: c.primary,
212
215
  };
@@ -0,0 +1,71 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import type { Meta, StoryObj } from '@storybook/react-vite';
4
+
5
+ import { Fab } from './Fab';
6
+ import * as Icons from '../icons';
7
+
8
+ const meta: Meta<typeof Fab> = {
9
+ title: 'Components/Fab',
10
+ component: Fab,
11
+ args: {
12
+ accessibilityLabel: 'Compose',
13
+ icon: <Icons.PlusLarge_Stroke2_Corner0_Rounded size="lg" fill="#fff" />,
14
+ onPress: () => {},
15
+ },
16
+ argTypes: {
17
+ variant: {
18
+ control: 'select',
19
+ options: ['primary', 'secondary', 'surface'],
20
+ },
21
+ size: {
22
+ control: 'select',
23
+ options: ['small', 'medium', 'large'],
24
+ },
25
+ placement: {
26
+ control: 'select',
27
+ options: ['bottom-right', 'bottom-left', 'top-right', 'top-left', 'static'],
28
+ },
29
+ disabled: { control: 'boolean' },
30
+ },
31
+ };
32
+
33
+ export default meta;
34
+
35
+ type Story = StoryObj<typeof Fab>;
36
+
37
+ /** Default circular icon FAB, anchored to the bottom-right of its container. */
38
+ export const Default: Story = {};
39
+
40
+ /** Lower-emphasis surface FAB. */
41
+ export const Surface: Story = {
42
+ args: { variant: 'surface' },
43
+ };
44
+
45
+ /** Extended FAB: icon + label pill. */
46
+ export const Extended: Story = {
47
+ args: { label: 'Compose' },
48
+ };
49
+
50
+ /**
51
+ * The FAB anchored to the bottom-right of a CONSTRAINED column. On web the FAB
52
+ * uses `position: sticky`, so it tracks the bottom of THIS column (not the
53
+ * viewport) — exactly what a multi-column app layout needs.
54
+ */
55
+ export const InContainerColumn: Story = {
56
+ render: (args) => (
57
+ <View
58
+ style={{
59
+ position: 'relative',
60
+ width: 360,
61
+ height: 480,
62
+ borderWidth: 1,
63
+ borderColor: '#ddd',
64
+ borderRadius: 12,
65
+ overflow: 'hidden',
66
+ }}
67
+ >
68
+ <Fab {...args} />
69
+ </View>
70
+ ),
71
+ };
@@ -0,0 +1,189 @@
1
+ import React, { memo, useMemo } from 'react';
2
+ import {
3
+ Animated,
4
+ Pressable,
5
+ Text,
6
+ View,
7
+ type TextStyle,
8
+ type ViewStyle,
9
+ } from 'react-native';
10
+
11
+ import { useTheme } from '../theme/use-theme';
12
+ import { usePressAnimation } from '../hooks/usePressAnimation';
13
+ import { useInteractionState } from '../hooks/useInteractionState';
14
+ import type { FabPlacement, FabProps, FabSize, FabVariant } from './types';
15
+
16
+ export type { FabProps, FabVariant, FabSize, FabPlacement } from './types';
17
+
18
+ const SIZE_CONFIG: Record<FabSize, { diameter: number; iconBox: number; fontSize: number }> = {
19
+ small: { diameter: 40, iconBox: 20, fontSize: 14 },
20
+ medium: { diameter: 56, iconBox: 24, fontSize: 15 },
21
+ large: { diameter: 64, iconBox: 28, fontSize: 16 },
22
+ };
23
+
24
+ const PILL_RADIUS = 999;
25
+ const PRESS_SCALE = 0.94;
26
+ const DEFAULT_OFFSET = 16;
27
+ const DEFAULT_Z_INDEX = 50;
28
+ const HIT_SLOP = { top: 8, bottom: 8, left: 8, right: 8 } as const;
29
+
30
+ /**
31
+ * Compute the `position: absolute` anchoring for a placement. The FAB pins to
32
+ * the nearest positioned ancestor (the consumer's column container), NOT the
33
+ * screen — so it never escapes a constrained layout. `static` returns no
34
+ * positioning (consumer-controlled).
35
+ */
36
+ function placementStyle(placement: FabPlacement, offset: number): ViewStyle {
37
+ if (placement === 'static') return {};
38
+ const style: ViewStyle = { position: 'absolute' };
39
+ if (placement === 'bottom-right' || placement === 'bottom-left') {
40
+ style.bottom = offset;
41
+ } else {
42
+ style.top = offset;
43
+ }
44
+ if (placement === 'bottom-right' || placement === 'top-right') {
45
+ style.right = offset;
46
+ } else {
47
+ style.left = offset;
48
+ }
49
+ return style;
50
+ }
51
+
52
+ const FabComponent: React.FC<FabProps> = ({
53
+ onPress,
54
+ icon,
55
+ children,
56
+ label,
57
+ variant = 'primary',
58
+ size = 'medium',
59
+ placement = 'bottom-right',
60
+ offset = DEFAULT_OFFSET,
61
+ disabled = false,
62
+ accessibilityLabel,
63
+ accessibilityHint,
64
+ style,
65
+ labelStyle,
66
+ className,
67
+ testID,
68
+ zIndex = DEFAULT_Z_INDEX,
69
+ }) => {
70
+ const theme = useTheme();
71
+ const sizeConfig = SIZE_CONFIG[size];
72
+ const isExtended = label != null && label.length > 0;
73
+ const content = icon ?? children;
74
+
75
+ const { scaleAnim, onPressIn, onPressOut } = usePressAnimation(
76
+ disabled ? undefined : PRESS_SCALE,
77
+ );
78
+ const { state: pressed, onIn: onPressedIn, onOut: onPressedOut } =
79
+ useInteractionState();
80
+
81
+ const resolvedColors = useMemo(() => resolveVariant(variant, theme.colors), [variant, theme.colors]);
82
+
83
+ const containerStyle = useMemo((): ViewStyle => {
84
+ const base: ViewStyle = {
85
+ alignItems: 'center',
86
+ justifyContent: 'center',
87
+ flexDirection: 'row',
88
+ backgroundColor: resolvedColors.background,
89
+ borderRadius: PILL_RADIUS,
90
+ height: sizeConfig.diameter,
91
+ // Elevation / shadow (native).
92
+ shadowColor: theme.colors.shadow,
93
+ shadowOffset: { width: 0, height: 4 },
94
+ shadowOpacity: 0.3,
95
+ shadowRadius: 8,
96
+ elevation: 6,
97
+ };
98
+ if (isExtended) {
99
+ base.paddingHorizontal = size === 'small' ? 14 : 20;
100
+ base.gap = 8;
101
+ } else {
102
+ base.width = sizeConfig.diameter;
103
+ }
104
+ return base;
105
+ }, [resolvedColors.background, sizeConfig.diameter, theme.colors.shadow, isExtended, size]);
106
+
107
+ const labelTextStyle = useMemo((): TextStyle => ({
108
+ fontSize: sizeConfig.fontSize,
109
+ fontWeight: '600',
110
+ color: resolvedColors.foreground,
111
+ }), [sizeConfig.fontSize, resolvedColors.foreground]);
112
+
113
+ const handlePressIn = disabled
114
+ ? undefined
115
+ : () => {
116
+ onPressIn();
117
+ onPressedIn();
118
+ };
119
+ const handlePressOut = disabled
120
+ ? undefined
121
+ : () => {
122
+ onPressOut();
123
+ onPressedOut();
124
+ };
125
+
126
+ return (
127
+ <Animated.View
128
+ style={[
129
+ placementStyle(placement, offset),
130
+ { zIndex, transform: [{ scale: scaleAnim }] },
131
+ style,
132
+ ]}
133
+ >
134
+ <Pressable
135
+ {...(className ? ({ className } as Record<string, string>) : {})}
136
+ style={[
137
+ containerStyle,
138
+ disabled && { opacity: 0.5 },
139
+ pressed && !disabled && { opacity: 0.9 },
140
+ ]}
141
+ onPress={disabled ? undefined : onPress}
142
+ onPressIn={handlePressIn}
143
+ onPressOut={handlePressOut}
144
+ disabled={disabled}
145
+ hitSlop={HIT_SLOP}
146
+ accessibilityRole="button"
147
+ accessibilityLabel={accessibilityLabel ?? label}
148
+ accessibilityHint={accessibilityHint}
149
+ accessibilityState={{ disabled }}
150
+ testID={testID}
151
+ >
152
+ {content != null && (
153
+ <View
154
+ style={{
155
+ width: sizeConfig.iconBox,
156
+ height: sizeConfig.iconBox,
157
+ alignItems: 'center',
158
+ justifyContent: 'center',
159
+ }}
160
+ >
161
+ {content}
162
+ </View>
163
+ )}
164
+ {isExtended && (
165
+ <Text style={[labelTextStyle, labelStyle]} numberOfLines={1}>
166
+ {label}
167
+ </Text>
168
+ )}
169
+ </Pressable>
170
+ </Animated.View>
171
+ );
172
+ };
173
+
174
+ function resolveVariant(
175
+ variant: FabVariant,
176
+ colors: ReturnType<typeof useTheme>['colors'],
177
+ ): { background: string; foreground: string } {
178
+ switch (variant) {
179
+ case 'secondary':
180
+ case 'surface':
181
+ return { background: colors.card, foreground: colors.text };
182
+ case 'primary':
183
+ default:
184
+ return { background: colors.primary, foreground: colors.primaryForeground };
185
+ }
186
+ }
187
+
188
+ export const Fab = memo(FabComponent);
189
+ Fab.displayName = 'Fab';