@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.
- package/lib/commonjs/button/Button.js +11 -5
- package/lib/commonjs/button/Button.js.map +1 -1
- package/lib/commonjs/button/Button.web.js +9 -5
- package/lib/commonjs/button/Button.web.js.map +1 -1
- package/lib/commonjs/fab/Fab.js +199 -0
- package/lib/commonjs/fab/Fab.js.map +1 -0
- package/lib/commonjs/fab/Fab.web.js +246 -0
- package/lib/commonjs/fab/Fab.web.js.map +1 -0
- package/lib/commonjs/fab/index.js +13 -0
- package/lib/commonjs/fab/index.js.map +1 -0
- package/lib/commonjs/fab/index.web.js +13 -0
- package/lib/commonjs/fab/index.web.js.map +1 -0
- package/lib/commonjs/fab/types.js +6 -0
- package/lib/commonjs/fab/types.js.map +1 -0
- package/lib/commonjs/index.js +8 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +24 -16
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/module/button/Button.js +11 -5
- package/lib/module/button/Button.js.map +1 -1
- package/lib/module/button/Button.web.js +9 -5
- package/lib/module/button/Button.web.js.map +1 -1
- package/lib/module/fab/Fab.js +194 -0
- package/lib/module/fab/Fab.js.map +1 -0
- package/lib/module/fab/Fab.web.js +241 -0
- package/lib/module/fab/Fab.web.js.map +1 -0
- package/lib/module/fab/index.js +4 -0
- package/lib/module/fab/index.js.map +1 -0
- package/lib/module/fab/index.web.js +14 -0
- package/lib/module/fab/index.web.js.map +1 -0
- package/lib/module/fab/types.js +4 -0
- package/lib/module/fab/types.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +1 -0
- package/lib/module/index.web.js.map +1 -1
- package/lib/typescript/commonjs/button/Button.d.ts.map +1 -1
- package/lib/typescript/commonjs/button/Button.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/fab/Fab.d.ts +5 -0
- package/lib/typescript/commonjs/fab/Fab.d.ts.map +1 -0
- package/lib/typescript/commonjs/fab/Fab.web.d.ts +5 -0
- package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/fab/index.d.ts +3 -0
- package/lib/typescript/commonjs/fab/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/fab/index.web.d.ts +3 -0
- package/lib/typescript/commonjs/fab/index.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/fab/types.d.ts +99 -0
- package/lib/typescript/commonjs/fab/types.d.ts.map +1 -0
- package/lib/typescript/commonjs/index.d.ts +2 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.web.d.ts +2 -0
- package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
- package/lib/typescript/module/button/Button.d.ts.map +1 -1
- package/lib/typescript/module/button/Button.web.d.ts.map +1 -1
- package/lib/typescript/module/fab/Fab.d.ts +5 -0
- package/lib/typescript/module/fab/Fab.d.ts.map +1 -0
- package/lib/typescript/module/fab/Fab.web.d.ts +5 -0
- package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -0
- package/lib/typescript/module/fab/index.d.ts +3 -0
- package/lib/typescript/module/fab/index.d.ts.map +1 -0
- package/lib/typescript/module/fab/index.web.d.ts +3 -0
- package/lib/typescript/module/fab/index.web.d.ts.map +1 -0
- package/lib/typescript/module/fab/types.d.ts +99 -0
- package/lib/typescript/module/fab/types.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +2 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/index.web.d.ts +2 -0
- package/lib/typescript/module/index.web.d.ts.map +1 -1
- package/package.json +17 -1
- package/src/__tests__/Fab.test.tsx +76 -0
- package/src/__tests__/Fab.web.test.tsx +114 -0
- package/src/button/Button.tsx +11 -5
- package/src/button/Button.web.tsx +8 -5
- package/src/fab/Fab.stories.tsx +71 -0
- package/src/fab/Fab.tsx +189 -0
- package/src/fab/Fab.web.tsx +258 -0
- package/src/fab/index.ts +2 -0
- package/src/fab/index.web.ts +12 -0
- package/src/fab/types.ts +125 -0
- package/src/index.ts +2 -0
- 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
|
+
});
|
package/src/button/Button.tsx
CHANGED
|
@@ -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 =
|
|
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 =
|
|
166
|
+
styles.borderRadius = PILL_RADIUS;
|
|
162
167
|
break;
|
|
163
168
|
case 'inverse':
|
|
164
169
|
styles.backgroundColor = '#FFFFFF';
|
|
165
|
-
styles.borderRadius =
|
|
170
|
+
styles.borderRadius = PILL_RADIUS;
|
|
166
171
|
break;
|
|
167
172
|
case 'icon':
|
|
168
|
-
styles.borderRadius =
|
|
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 =
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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:
|
|
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:
|
|
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
|
+
};
|
package/src/fab/Fab.tsx
ADDED
|
@@ -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';
|