@oxyhq/bloom 0.11.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 (67) hide show
  1. package/lib/commonjs/fab/Fab.js +199 -0
  2. package/lib/commonjs/fab/Fab.js.map +1 -0
  3. package/lib/commonjs/fab/Fab.web.js +246 -0
  4. package/lib/commonjs/fab/Fab.web.js.map +1 -0
  5. package/lib/commonjs/fab/index.js +13 -0
  6. package/lib/commonjs/fab/index.js.map +1 -0
  7. package/lib/commonjs/fab/index.web.js +13 -0
  8. package/lib/commonjs/fab/index.web.js.map +1 -0
  9. package/lib/commonjs/fab/types.js +6 -0
  10. package/lib/commonjs/fab/types.js.map +1 -0
  11. package/lib/commonjs/index.js +8 -0
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/index.web.js +24 -16
  14. package/lib/commonjs/index.web.js.map +1 -1
  15. package/lib/module/fab/Fab.js +194 -0
  16. package/lib/module/fab/Fab.js.map +1 -0
  17. package/lib/module/fab/Fab.web.js +241 -0
  18. package/lib/module/fab/Fab.web.js.map +1 -0
  19. package/lib/module/fab/index.js +4 -0
  20. package/lib/module/fab/index.js.map +1 -0
  21. package/lib/module/fab/index.web.js +14 -0
  22. package/lib/module/fab/index.web.js.map +1 -0
  23. package/lib/module/fab/types.js +4 -0
  24. package/lib/module/fab/types.js.map +1 -0
  25. package/lib/module/index.js +1 -0
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/index.web.js +1 -0
  28. package/lib/module/index.web.js.map +1 -1
  29. package/lib/typescript/commonjs/fab/Fab.d.ts +5 -0
  30. package/lib/typescript/commonjs/fab/Fab.d.ts.map +1 -0
  31. package/lib/typescript/commonjs/fab/Fab.web.d.ts +5 -0
  32. package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -0
  33. package/lib/typescript/commonjs/fab/index.d.ts +3 -0
  34. package/lib/typescript/commonjs/fab/index.d.ts.map +1 -0
  35. package/lib/typescript/commonjs/fab/index.web.d.ts +3 -0
  36. package/lib/typescript/commonjs/fab/index.web.d.ts.map +1 -0
  37. package/lib/typescript/commonjs/fab/types.d.ts +99 -0
  38. package/lib/typescript/commonjs/fab/types.d.ts.map +1 -0
  39. package/lib/typescript/commonjs/index.d.ts +2 -0
  40. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  41. package/lib/typescript/commonjs/index.web.d.ts +2 -0
  42. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  43. package/lib/typescript/module/fab/Fab.d.ts +5 -0
  44. package/lib/typescript/module/fab/Fab.d.ts.map +1 -0
  45. package/lib/typescript/module/fab/Fab.web.d.ts +5 -0
  46. package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -0
  47. package/lib/typescript/module/fab/index.d.ts +3 -0
  48. package/lib/typescript/module/fab/index.d.ts.map +1 -0
  49. package/lib/typescript/module/fab/index.web.d.ts +3 -0
  50. package/lib/typescript/module/fab/index.web.d.ts.map +1 -0
  51. package/lib/typescript/module/fab/types.d.ts +99 -0
  52. package/lib/typescript/module/fab/types.d.ts.map +1 -0
  53. package/lib/typescript/module/index.d.ts +2 -0
  54. package/lib/typescript/module/index.d.ts.map +1 -1
  55. package/lib/typescript/module/index.web.d.ts +2 -0
  56. package/lib/typescript/module/index.web.d.ts.map +1 -1
  57. package/package.json +17 -1
  58. package/src/__tests__/Fab.test.tsx +76 -0
  59. package/src/__tests__/Fab.web.test.tsx +114 -0
  60. package/src/fab/Fab.stories.tsx +71 -0
  61. package/src/fab/Fab.tsx +189 -0
  62. package/src/fab/Fab.web.tsx +258 -0
  63. package/src/fab/index.ts +2 -0
  64. package/src/fab/index.web.ts +12 -0
  65. package/src/fab/types.ts +125 -0
  66. package/src/index.ts +2 -0
  67. 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
+ });
@@ -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';
@@ -0,0 +1,258 @@
1
+ import React, {
2
+ memo,
3
+ useCallback,
4
+ useEffect,
5
+ useId,
6
+ useMemo,
7
+ type CSSProperties,
8
+ type MouseEvent,
9
+ } from 'react';
10
+
11
+ import { useTheme } from '../theme/use-theme';
12
+ import type { Theme } from '../theme/types';
13
+ import type { FabPlacement, FabProps, FabSize, FabVariant } from './types';
14
+
15
+ export type { FabProps, FabVariant, FabSize, FabPlacement } from './types';
16
+
17
+ const SIZE_CONFIG: Record<FabSize, { diameter: number; iconBox: number; fontSize: number }> = {
18
+ small: { diameter: 40, iconBox: 20, fontSize: 14 },
19
+ medium: { diameter: 56, iconBox: 24, fontSize: 15 },
20
+ large: { diameter: 64, iconBox: 28, fontSize: 16 },
21
+ };
22
+
23
+ const PILL_RADIUS = 999;
24
+ const PRESS_SCALE = 0.94;
25
+ const DEFAULT_OFFSET = 16;
26
+ const DEFAULT_Z_INDEX = 50;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Per-state CSS injection
30
+ //
31
+ // Inline styles cannot express `:hover` / `:focus-visible` / `:active`. We
32
+ // inject one static stylesheet (keyed by id, once) defining the interaction
33
+ // behavior for the base `bloom-fab` class; per-instance resolved colors stay
34
+ // inline. The focus-ring color is read from a CSS custom property the
35
+ // component sets inline (`--bloom-fab-ring`).
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const STYLE_ID = 'bloom-fab-web-css';
39
+
40
+ const BLOOM_FAB_CSS = `
41
+ .bloom-fab {
42
+ appearance: none;
43
+ -webkit-appearance: none;
44
+ box-sizing: border-box;
45
+ display: inline-flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ flex-direction: row;
49
+ gap: 8px;
50
+ margin: 0;
51
+ border: none;
52
+ font-family: inherit;
53
+ cursor: pointer;
54
+ user-select: none;
55
+ outline: none;
56
+ transition: opacity 120ms ease, transform 120ms ease, box-shadow 160ms ease, background-color 120ms ease;
57
+ }
58
+ .bloom-fab:disabled,
59
+ .bloom-fab[aria-disabled="true"] {
60
+ cursor: default;
61
+ opacity: 0.5;
62
+ }
63
+ .bloom-fab:not(:disabled):not([aria-disabled="true"]):hover {
64
+ box-shadow: var(--bloom-fab-shadow-hover);
65
+ }
66
+ .bloom-fab:not(:disabled):not([aria-disabled="true"]):active {
67
+ transform: scale(var(--bloom-fab-press-scale, 1));
68
+ }
69
+ .bloom-fab:focus-visible {
70
+ outline: 2px solid var(--bloom-fab-ring, currentColor);
71
+ outline-offset: 3px;
72
+ }
73
+ `;
74
+
75
+ function useFabCss(): void {
76
+ useEffect(() => {
77
+ if (typeof document === 'undefined') return;
78
+ if (document.getElementById(STYLE_ID)) return;
79
+ const style = document.createElement('style');
80
+ style.id = STYLE_ID;
81
+ style.textContent = BLOOM_FAB_CSS;
82
+ document.head.appendChild(style);
83
+ }, []);
84
+ }
85
+
86
+ /**
87
+ * Positioning for a placement on web.
88
+ *
89
+ * CRITICAL: the positioned placements use `position: sticky`, NOT `fixed`. A
90
+ * sticky element is positioned relative to its nearest scrolling ancestor and
91
+ * stays within its containing block — so the FAB tracks the bottom-right of the
92
+ * CENTRAL CONTENT COLUMN as it scrolls and never escapes to the viewport edge /
93
+ * over a side rail in a constrained multi-column app layout.
94
+ *
95
+ * To pin a sticky element to the BOTTOM of a column it must sit at the end of
96
+ * the scroll content with `align-self: flex-end` (consumer column should be a
97
+ * flex column) — documented in the component's usage story. We set `bottom`
98
+ * and a self-alignment so the common case works with zero consumer CSS beyond a
99
+ * `flex-col` column.
100
+ */
101
+ function placementStyle(placement: FabPlacement, offset: number): CSSProperties {
102
+ if (placement === 'static') return {};
103
+ const style: CSSProperties = { position: 'sticky' };
104
+ if (placement === 'bottom-right' || placement === 'bottom-left') {
105
+ style.bottom = offset;
106
+ } else {
107
+ style.top = offset;
108
+ }
109
+ // Sticky elements can't be pushed horizontally by left/right the way absolute
110
+ // ones are; we self-align within the (flex column) container instead, and add
111
+ // a horizontal margin for the offset so the FAB sits `offset` px from the
112
+ // column's inline edge.
113
+ if (placement === 'bottom-right' || placement === 'top-right') {
114
+ style.alignSelf = 'flex-end';
115
+ style.marginRight = offset;
116
+ } else {
117
+ style.alignSelf = 'flex-start';
118
+ style.marginLeft = offset;
119
+ }
120
+ return style;
121
+ }
122
+
123
+ function resolveVariant(
124
+ variant: FabVariant,
125
+ c: Theme['colors'],
126
+ ): { background: string; foreground: string; ring: string } {
127
+ switch (variant) {
128
+ case 'secondary':
129
+ case 'surface':
130
+ return { background: c.card, foreground: c.text, ring: c.primary };
131
+ case 'primary':
132
+ default:
133
+ return { background: c.primary, foreground: c.primaryForeground, ring: c.primary };
134
+ }
135
+ }
136
+
137
+ const FabWebComponent: React.FC<FabProps> = ({
138
+ onPress,
139
+ onClick,
140
+ icon,
141
+ children,
142
+ label,
143
+ variant = 'primary',
144
+ size = 'medium',
145
+ placement = 'bottom-right',
146
+ offset = DEFAULT_OFFSET,
147
+ disabled = false,
148
+ accessibilityLabel,
149
+ 'aria-label': ariaLabelProp,
150
+ accessibilityHint,
151
+ style,
152
+ labelStyle,
153
+ className,
154
+ testID,
155
+ zIndex = DEFAULT_Z_INDEX,
156
+ id,
157
+ title,
158
+ type = 'button',
159
+ }) => {
160
+ useFabCss();
161
+ const theme = useTheme();
162
+ const reactId = useId();
163
+ const resolvedId = id ?? `bloom-fab-${reactId}`;
164
+
165
+ const sizeConfig = SIZE_CONFIG[size];
166
+ const isExtended = label != null && label.length > 0;
167
+ const content = icon ?? children;
168
+ const variantColors = useMemo(() => resolveVariant(variant, theme.colors), [variant, theme.colors]);
169
+
170
+ // Resolved-token shadows. The token is already a full color (per the Bloom
171
+ // web CSS-var contract), so it is used directly — never wrapped in hsl().
172
+ const restShadow = `0 4px 12px ${theme.colors.shadow}`;
173
+ const hoverShadow = `0 8px 20px ${theme.colors.shadow}`;
174
+
175
+ const containerStyle = useMemo((): CSSProperties => {
176
+ const base: CSSProperties = {
177
+ backgroundColor: variantColors.background,
178
+ color: variantColors.foreground,
179
+ borderRadius: PILL_RADIUS,
180
+ height: sizeConfig.diameter,
181
+ zIndex,
182
+ boxShadow: restShadow,
183
+ fontSize: sizeConfig.fontSize,
184
+ fontWeight: 600,
185
+ ['--bloom-fab-ring' as string]: variantColors.ring,
186
+ ['--bloom-fab-shadow-hover' as string]: hoverShadow,
187
+ ['--bloom-fab-press-scale' as string]: PRESS_SCALE,
188
+ ...placementStyle(placement, offset),
189
+ };
190
+ if (isExtended) {
191
+ base.paddingLeft = size === 'small' ? 14 : 20;
192
+ base.paddingRight = size === 'small' ? 14 : 20;
193
+ } else {
194
+ base.width = sizeConfig.diameter;
195
+ }
196
+ return base;
197
+ }, [
198
+ variantColors,
199
+ sizeConfig.diameter,
200
+ sizeConfig.fontSize,
201
+ zIndex,
202
+ restShadow,
203
+ hoverShadow,
204
+ placement,
205
+ offset,
206
+ isExtended,
207
+ size,
208
+ ]);
209
+
210
+ const handleClick = useCallback(
211
+ (event: MouseEvent<HTMLButtonElement>) => {
212
+ if (disabled) {
213
+ event.preventDefault();
214
+ return;
215
+ }
216
+ onClick?.(event);
217
+ onPress?.();
218
+ },
219
+ [disabled, onClick, onPress],
220
+ );
221
+
222
+ const ariaLabel = ariaLabelProp ?? accessibilityLabel ?? label;
223
+ const composedClassName = ['bloom-fab'].concat(className ? [className] : []).join(' ');
224
+
225
+ return (
226
+ <button
227
+ id={resolvedId}
228
+ type={type}
229
+ className={composedClassName}
230
+ style={{ ...containerStyle, ...(style as CSSProperties) }}
231
+ onClick={handleClick}
232
+ disabled={disabled}
233
+ aria-disabled={disabled || undefined}
234
+ aria-label={ariaLabel}
235
+ title={title ?? accessibilityHint}
236
+ data-testid={testID}
237
+ >
238
+ {content != null && (
239
+ <span
240
+ aria-hidden={isExtended ? 'true' : undefined}
241
+ style={{
242
+ display: 'inline-flex',
243
+ alignItems: 'center',
244
+ justifyContent: 'center',
245
+ width: sizeConfig.iconBox,
246
+ height: sizeConfig.iconBox,
247
+ }}
248
+ >
249
+ {content}
250
+ </span>
251
+ )}
252
+ {isExtended && <span style={labelStyle as CSSProperties}>{label}</span>}
253
+ </button>
254
+ );
255
+ };
256
+
257
+ export const Fab = memo(FabWebComponent);
258
+ Fab.displayName = 'Fab';
@@ -0,0 +1,2 @@
1
+ export { Fab } from './Fab';
2
+ export type { FabProps, FabVariant, FabSize, FabPlacement } from './types';
@@ -0,0 +1,12 @@
1
+ // Web variant of the `./fab` barrel.
2
+ //
3
+ // The default barrel (`./index.ts`) re-exports the React Native (`Pressable`)
4
+ // implementation, whose `placement` anchoring uses `position: absolute` within
5
+ // the nearest positioned ancestor. The web fork (`./Fab.web`) renders a real
6
+ // HTML `<button>` and uses `position: sticky` for its anchored placements so
7
+ // the FAB tracks the bottom-right of its CONTAINING content column while
8
+ // scrolling — never the viewport edge (the bug that motivated moving the FAB
9
+ // into Bloom). Web bundlers select this file via the `"browser"` export
10
+ // condition in `package.json`'s `exports['./fab']`.
11
+ export { Fab } from './Fab.web';
12
+ export type { FabProps, FabVariant, FabSize, FabPlacement } from './types';