@oxyhq/bloom 0.11.0 → 0.12.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 (67) hide show
  1. package/lib/commonjs/fab/Fab.js +202 -0
  2. package/lib/commonjs/fab/Fab.js.map +1 -0
  3. package/lib/commonjs/fab/Fab.web.js +260 -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 +197 -0
  16. package/lib/module/fab/Fab.js.map +1 -0
  17. package/lib/module/fab/Fab.web.js +255 -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 +103 -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 +103 -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 +127 -0
  60. package/src/fab/Fab.stories.tsx +71 -0
  61. package/src/fab/Fab.tsx +192 -0
  62. package/src/fab/Fab.web.tsx +272 -0
  63. package/src/fab/index.ts +2 -0
  64. package/src/fab/index.web.ts +12 -0
  65. package/src/fab/types.ts +129 -0
  66. package/src/index.ts +2 -0
  67. package/src/index.web.ts +2 -0
@@ -0,0 +1,127 @@
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('sets margin-top: auto for bottom placements so a short column pins the FAB to the bottom', () => {
88
+ const c = mount(<Fab accessibilityLabel="Add" icon={<span>+</span>} />);
89
+ expect(getByRole(c, 'button').style.marginTop).toBe('auto');
90
+ });
91
+
92
+ it('does NOT set margin-top: auto for top placements', () => {
93
+ const c = mount(<Fab accessibilityLabel="Add" placement="top-right" icon={<span>+</span>} />);
94
+ const fab = getByRole(c, 'button');
95
+ expect(fab.style.marginTop).toBe('');
96
+ expect(fab.style.top).toBe('16px');
97
+ });
98
+
99
+ it('applies no positioning for placement="static"', () => {
100
+ const c = mount(<Fab accessibilityLabel="Add" placement="static" icon={<span>+</span>} />);
101
+ const fab = getByRole(c, 'button');
102
+ expect(fab.style.position).toBe('');
103
+ expect(fab.style.marginTop).toBe('');
104
+ });
105
+
106
+ it('renders an extended label', () => {
107
+ const c = mount(<Fab label="Compose" icon={<span>+</span>} />);
108
+ expect(getByText(c, 'Compose')).toBeTruthy();
109
+ });
110
+
111
+ it('applies aria-label from accessibilityLabel', () => {
112
+ const c = mount(<Fab accessibilityLabel="Add post" icon={<span>+</span>} />);
113
+ expect(getByLabelText(c, 'Add post')).toBeTruthy();
114
+ });
115
+
116
+ it('passes className through so consumer layout classes win', () => {
117
+ const c = mount(<Fab accessibilityLabel="Add" className="my-fab" icon={<span>+</span>} />);
118
+ const fab = getByRole(c, 'button');
119
+ expect(fab).toHaveClass('bloom-fab');
120
+ expect(fab).toHaveClass('my-fab');
121
+ });
122
+
123
+ it('exposes testID as data-testid', () => {
124
+ const c = mount(<Fab accessibilityLabel="Add" testID="my-fab" icon={<span>+</span>} />);
125
+ expect(c.querySelector('[data-testid="my-fab"]')?.tagName).toBe('BUTTON');
126
+ });
127
+ });
@@ -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,192 @@
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
+ // Material-style FAB scale. `small` (40px, the compact FAB Mention uses) keeps a
19
+ // full 24px icon box so the glyph reads clearly; `medium` (56px) is the
20
+ // canonical FAB; `large` (64px) is the high-prominence action.
21
+ const SIZE_CONFIG: Record<FabSize, { diameter: number; iconBox: number; fontSize: number }> = {
22
+ small: { diameter: 40, iconBox: 24, fontSize: 14 },
23
+ medium: { diameter: 56, iconBox: 24, fontSize: 15 },
24
+ large: { diameter: 64, iconBox: 28, fontSize: 16 },
25
+ };
26
+
27
+ const PILL_RADIUS = 999;
28
+ const PRESS_SCALE = 0.94;
29
+ const DEFAULT_OFFSET = 16;
30
+ const DEFAULT_Z_INDEX = 50;
31
+ const HIT_SLOP = { top: 8, bottom: 8, left: 8, right: 8 } as const;
32
+
33
+ /**
34
+ * Compute the `position: absolute` anchoring for a placement. The FAB pins to
35
+ * the nearest positioned ancestor (the consumer's column container), NOT the
36
+ * screen — so it never escapes a constrained layout. `static` returns no
37
+ * positioning (consumer-controlled).
38
+ */
39
+ function placementStyle(placement: FabPlacement, offset: number): ViewStyle {
40
+ if (placement === 'static') return {};
41
+ const style: ViewStyle = { position: 'absolute' };
42
+ if (placement === 'bottom-right' || placement === 'bottom-left') {
43
+ style.bottom = offset;
44
+ } else {
45
+ style.top = offset;
46
+ }
47
+ if (placement === 'bottom-right' || placement === 'top-right') {
48
+ style.right = offset;
49
+ } else {
50
+ style.left = offset;
51
+ }
52
+ return style;
53
+ }
54
+
55
+ const FabComponent: React.FC<FabProps> = ({
56
+ onPress,
57
+ icon,
58
+ children,
59
+ label,
60
+ variant = 'primary',
61
+ size = 'medium',
62
+ placement = 'bottom-right',
63
+ offset = DEFAULT_OFFSET,
64
+ disabled = false,
65
+ accessibilityLabel,
66
+ accessibilityHint,
67
+ style,
68
+ labelStyle,
69
+ className,
70
+ testID,
71
+ zIndex = DEFAULT_Z_INDEX,
72
+ }) => {
73
+ const theme = useTheme();
74
+ const sizeConfig = SIZE_CONFIG[size];
75
+ const isExtended = label != null && label.length > 0;
76
+ const content = icon ?? children;
77
+
78
+ const { scaleAnim, onPressIn, onPressOut } = usePressAnimation(
79
+ disabled ? undefined : PRESS_SCALE,
80
+ );
81
+ const { state: pressed, onIn: onPressedIn, onOut: onPressedOut } =
82
+ useInteractionState();
83
+
84
+ const resolvedColors = useMemo(() => resolveVariant(variant, theme.colors), [variant, theme.colors]);
85
+
86
+ const containerStyle = useMemo((): ViewStyle => {
87
+ const base: ViewStyle = {
88
+ alignItems: 'center',
89
+ justifyContent: 'center',
90
+ flexDirection: 'row',
91
+ backgroundColor: resolvedColors.background,
92
+ borderRadius: PILL_RADIUS,
93
+ height: sizeConfig.diameter,
94
+ // Elevation / shadow (native).
95
+ shadowColor: theme.colors.shadow,
96
+ shadowOffset: { width: 0, height: 4 },
97
+ shadowOpacity: 0.3,
98
+ shadowRadius: 8,
99
+ elevation: 6,
100
+ };
101
+ if (isExtended) {
102
+ base.paddingHorizontal = size === 'small' ? 14 : 20;
103
+ base.gap = 8;
104
+ } else {
105
+ base.width = sizeConfig.diameter;
106
+ }
107
+ return base;
108
+ }, [resolvedColors.background, sizeConfig.diameter, theme.colors.shadow, isExtended, size]);
109
+
110
+ const labelTextStyle = useMemo((): TextStyle => ({
111
+ fontSize: sizeConfig.fontSize,
112
+ fontWeight: '600',
113
+ color: resolvedColors.foreground,
114
+ }), [sizeConfig.fontSize, resolvedColors.foreground]);
115
+
116
+ const handlePressIn = disabled
117
+ ? undefined
118
+ : () => {
119
+ onPressIn();
120
+ onPressedIn();
121
+ };
122
+ const handlePressOut = disabled
123
+ ? undefined
124
+ : () => {
125
+ onPressOut();
126
+ onPressedOut();
127
+ };
128
+
129
+ return (
130
+ <Animated.View
131
+ style={[
132
+ placementStyle(placement, offset),
133
+ { zIndex, transform: [{ scale: scaleAnim }] },
134
+ style,
135
+ ]}
136
+ >
137
+ <Pressable
138
+ {...(className ? ({ className } as Record<string, string>) : {})}
139
+ style={[
140
+ containerStyle,
141
+ disabled && { opacity: 0.5 },
142
+ pressed && !disabled && { opacity: 0.9 },
143
+ ]}
144
+ onPress={disabled ? undefined : onPress}
145
+ onPressIn={handlePressIn}
146
+ onPressOut={handlePressOut}
147
+ disabled={disabled}
148
+ hitSlop={HIT_SLOP}
149
+ accessibilityRole="button"
150
+ accessibilityLabel={accessibilityLabel ?? label}
151
+ accessibilityHint={accessibilityHint}
152
+ accessibilityState={{ disabled }}
153
+ testID={testID}
154
+ >
155
+ {content != null && (
156
+ <View
157
+ style={{
158
+ width: sizeConfig.iconBox,
159
+ height: sizeConfig.iconBox,
160
+ alignItems: 'center',
161
+ justifyContent: 'center',
162
+ }}
163
+ >
164
+ {content}
165
+ </View>
166
+ )}
167
+ {isExtended && (
168
+ <Text style={[labelTextStyle, labelStyle]} numberOfLines={1}>
169
+ {label}
170
+ </Text>
171
+ )}
172
+ </Pressable>
173
+ </Animated.View>
174
+ );
175
+ };
176
+
177
+ function resolveVariant(
178
+ variant: FabVariant,
179
+ colors: ReturnType<typeof useTheme>['colors'],
180
+ ): { background: string; foreground: string } {
181
+ switch (variant) {
182
+ case 'secondary':
183
+ case 'surface':
184
+ return { background: colors.card, foreground: colors.text };
185
+ case 'primary':
186
+ default:
187
+ return { background: colors.primary, foreground: colors.primaryForeground };
188
+ }
189
+ }
190
+
191
+ export const Fab = memo(FabComponent);
192
+ Fab.displayName = 'Fab';
@@ -0,0 +1,272 @@
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
+ // Material-style FAB scale. `small` (40px, the compact FAB Mention uses) keeps a
18
+ // full 24px icon box so the glyph reads clearly; `medium` (56px) is the
19
+ // canonical FAB; `large` (64px) is the high-prominence action.
20
+ const SIZE_CONFIG: Record<FabSize, { diameter: number; iconBox: number; fontSize: number }> = {
21
+ small: { diameter: 40, iconBox: 24, fontSize: 14 },
22
+ medium: { diameter: 56, iconBox: 24, fontSize: 15 },
23
+ large: { diameter: 64, iconBox: 28, fontSize: 16 },
24
+ };
25
+
26
+ const PILL_RADIUS = 999;
27
+ const PRESS_SCALE = 0.94;
28
+ const DEFAULT_OFFSET = 16;
29
+ const DEFAULT_Z_INDEX = 50;
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Per-state CSS injection
33
+ //
34
+ // Inline styles cannot express `:hover` / `:focus-visible` / `:active`. We
35
+ // inject one static stylesheet (keyed by id, once) defining the interaction
36
+ // behavior for the base `bloom-fab` class; per-instance resolved colors stay
37
+ // inline. The focus-ring color is read from a CSS custom property the
38
+ // component sets inline (`--bloom-fab-ring`).
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const STYLE_ID = 'bloom-fab-web-css';
42
+
43
+ const BLOOM_FAB_CSS = `
44
+ .bloom-fab {
45
+ appearance: none;
46
+ -webkit-appearance: none;
47
+ box-sizing: border-box;
48
+ display: inline-flex;
49
+ align-items: center;
50
+ justify-content: center;
51
+ flex-direction: row;
52
+ gap: 8px;
53
+ margin: 0;
54
+ border: none;
55
+ font-family: inherit;
56
+ cursor: pointer;
57
+ user-select: none;
58
+ outline: none;
59
+ transition: opacity 120ms ease, transform 120ms ease, box-shadow 160ms ease, background-color 120ms ease;
60
+ }
61
+ .bloom-fab:disabled,
62
+ .bloom-fab[aria-disabled="true"] {
63
+ cursor: default;
64
+ opacity: 0.5;
65
+ }
66
+ .bloom-fab:not(:disabled):not([aria-disabled="true"]):hover {
67
+ box-shadow: var(--bloom-fab-shadow-hover);
68
+ }
69
+ .bloom-fab:not(:disabled):not([aria-disabled="true"]):active {
70
+ transform: scale(var(--bloom-fab-press-scale, 1));
71
+ }
72
+ .bloom-fab:focus-visible {
73
+ outline: 2px solid var(--bloom-fab-ring, currentColor);
74
+ outline-offset: 3px;
75
+ }
76
+ `;
77
+
78
+ function useFabCss(): void {
79
+ useEffect(() => {
80
+ if (typeof document === 'undefined') return;
81
+ if (document.getElementById(STYLE_ID)) return;
82
+ const style = document.createElement('style');
83
+ style.id = STYLE_ID;
84
+ style.textContent = BLOOM_FAB_CSS;
85
+ document.head.appendChild(style);
86
+ }, []);
87
+ }
88
+
89
+ /**
90
+ * Positioning for a placement on web.
91
+ *
92
+ * CRITICAL: the positioned placements use `position: sticky`, NOT `fixed`. A
93
+ * sticky element is positioned relative to its nearest scrolling ancestor and
94
+ * stays within its containing block — so the FAB tracks the bottom-right of the
95
+ * CENTRAL CONTENT COLUMN as it scrolls and never escapes to the viewport edge /
96
+ * over a side rail in a constrained multi-column app layout.
97
+ *
98
+ * Pinning a FAB to the BOTTOM of a column needs TWO mechanisms working together,
99
+ * because sticky alone is not enough:
100
+ *
101
+ * 1. `margin-top: auto` — in a flex COLUMN this consumes all the free vertical
102
+ * space, pushing the FAB to the bottom of the container EVEN WHEN THE
103
+ * CONTENT IS SHORT (e.g. an empty feed / loading spinner). Without it, a
104
+ * short column leaves the FAB at its natural flow position (mid-column) —
105
+ * which is exactly the "floating in the middle / broken" bug.
106
+ * 2. `position: sticky; bottom` — when the content is TALL and the column
107
+ * scrolls, sticky keeps the FAB pinned to the bottom of the viewport as the
108
+ * user scrolls, instead of scrolling away with the content.
109
+ *
110
+ * Horizontal placement uses `align-self` (sticky elements can't be pushed by
111
+ * `left`/`right` the way absolute ones are) plus an inline-edge margin for the
112
+ * `offset`. The consumer column MUST be a flex column that fills the available
113
+ * height, and the FAB MUST be its last child — documented in the usage story.
114
+ */
115
+ function placementStyle(placement: FabPlacement, offset: number): CSSProperties {
116
+ if (placement === 'static') return {};
117
+ const style: CSSProperties = { position: 'sticky' };
118
+ const isBottom = placement === 'bottom-right' || placement === 'bottom-left';
119
+ if (isBottom) {
120
+ style.bottom = offset;
121
+ // Push the FAB to the bottom of a (flex-column) container even when the
122
+ // content is too short to scroll, so it never floats mid-column.
123
+ style.marginTop = 'auto';
124
+ } else {
125
+ style.top = offset;
126
+ }
127
+ if (placement === 'bottom-right' || placement === 'top-right') {
128
+ style.alignSelf = 'flex-end';
129
+ style.marginRight = offset;
130
+ } else {
131
+ style.alignSelf = 'flex-start';
132
+ style.marginLeft = offset;
133
+ }
134
+ return style;
135
+ }
136
+
137
+ function resolveVariant(
138
+ variant: FabVariant,
139
+ c: Theme['colors'],
140
+ ): { background: string; foreground: string; ring: string } {
141
+ switch (variant) {
142
+ case 'secondary':
143
+ case 'surface':
144
+ return { background: c.card, foreground: c.text, ring: c.primary };
145
+ case 'primary':
146
+ default:
147
+ return { background: c.primary, foreground: c.primaryForeground, ring: c.primary };
148
+ }
149
+ }
150
+
151
+ const FabWebComponent: React.FC<FabProps> = ({
152
+ onPress,
153
+ onClick,
154
+ icon,
155
+ children,
156
+ label,
157
+ variant = 'primary',
158
+ size = 'medium',
159
+ placement = 'bottom-right',
160
+ offset = DEFAULT_OFFSET,
161
+ disabled = false,
162
+ accessibilityLabel,
163
+ 'aria-label': ariaLabelProp,
164
+ accessibilityHint,
165
+ style,
166
+ labelStyle,
167
+ className,
168
+ testID,
169
+ zIndex = DEFAULT_Z_INDEX,
170
+ id,
171
+ title,
172
+ type = 'button',
173
+ }) => {
174
+ useFabCss();
175
+ const theme = useTheme();
176
+ const reactId = useId();
177
+ const resolvedId = id ?? `bloom-fab-${reactId}`;
178
+
179
+ const sizeConfig = SIZE_CONFIG[size];
180
+ const isExtended = label != null && label.length > 0;
181
+ const content = icon ?? children;
182
+ const variantColors = useMemo(() => resolveVariant(variant, theme.colors), [variant, theme.colors]);
183
+
184
+ // Resolved-token shadows. The token is already a full color (per the Bloom
185
+ // web CSS-var contract), so it is used directly — never wrapped in hsl().
186
+ const restShadow = `0 4px 12px ${theme.colors.shadow}`;
187
+ const hoverShadow = `0 8px 20px ${theme.colors.shadow}`;
188
+
189
+ const containerStyle = useMemo((): CSSProperties => {
190
+ const base: CSSProperties = {
191
+ backgroundColor: variantColors.background,
192
+ color: variantColors.foreground,
193
+ borderRadius: PILL_RADIUS,
194
+ height: sizeConfig.diameter,
195
+ zIndex,
196
+ boxShadow: restShadow,
197
+ fontSize: sizeConfig.fontSize,
198
+ fontWeight: 600,
199
+ ['--bloom-fab-ring' as string]: variantColors.ring,
200
+ ['--bloom-fab-shadow-hover' as string]: hoverShadow,
201
+ ['--bloom-fab-press-scale' as string]: PRESS_SCALE,
202
+ ...placementStyle(placement, offset),
203
+ };
204
+ if (isExtended) {
205
+ base.paddingLeft = size === 'small' ? 14 : 20;
206
+ base.paddingRight = size === 'small' ? 14 : 20;
207
+ } else {
208
+ base.width = sizeConfig.diameter;
209
+ }
210
+ return base;
211
+ }, [
212
+ variantColors,
213
+ sizeConfig.diameter,
214
+ sizeConfig.fontSize,
215
+ zIndex,
216
+ restShadow,
217
+ hoverShadow,
218
+ placement,
219
+ offset,
220
+ isExtended,
221
+ size,
222
+ ]);
223
+
224
+ const handleClick = useCallback(
225
+ (event: MouseEvent<HTMLButtonElement>) => {
226
+ if (disabled) {
227
+ event.preventDefault();
228
+ return;
229
+ }
230
+ onClick?.(event);
231
+ onPress?.();
232
+ },
233
+ [disabled, onClick, onPress],
234
+ );
235
+
236
+ const ariaLabel = ariaLabelProp ?? accessibilityLabel ?? label;
237
+ const composedClassName = ['bloom-fab'].concat(className ? [className] : []).join(' ');
238
+
239
+ return (
240
+ <button
241
+ id={resolvedId}
242
+ type={type}
243
+ className={composedClassName}
244
+ style={{ ...containerStyle, ...(style as CSSProperties) }}
245
+ onClick={handleClick}
246
+ disabled={disabled}
247
+ aria-disabled={disabled || undefined}
248
+ aria-label={ariaLabel}
249
+ title={title ?? accessibilityHint}
250
+ data-testid={testID}
251
+ >
252
+ {content != null && (
253
+ <span
254
+ aria-hidden={isExtended ? 'true' : undefined}
255
+ style={{
256
+ display: 'inline-flex',
257
+ alignItems: 'center',
258
+ justifyContent: 'center',
259
+ width: sizeConfig.iconBox,
260
+ height: sizeConfig.iconBox,
261
+ }}
262
+ >
263
+ {content}
264
+ </span>
265
+ )}
266
+ {isExtended && <span style={labelStyle as CSSProperties}>{label}</span>}
267
+ </button>
268
+ );
269
+ };
270
+
271
+ export const Fab = memo(FabWebComponent);
272
+ 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';