Skip to content

Commit ea20e26

Browse files
committed
fix(fab): address review feedback
Remove the surface preset and static elevation override. Apply hover elevation across platforms and restore focus when hiding a focused FAB. Separate hidden and disabled semantics, and rewrite the migration guide around v5 consumer changes.
1 parent 6b92400 commit ea20e26

12 files changed

Lines changed: 127 additions & 113 deletions

File tree

‎docs/6.x/docs/guides/migration.md‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -161,21 +161,30 @@ e.g.:
161161

162162
### FAB
163163

164-
The color presets for `FAB` and `FAB.Extended` now match the MD3 color roles:
165-
`primary`, `primaryContainer` (default), `secondary`, `secondaryContainer`,
166-
`tertiary`, `tertiaryContainer`, `surface`, and `branded`.
164+
To preserve the v5 FAB color treatment, update the `variant` prop:
167165

168-
If you used an earlier 6.x build, replace `tonalPrimary`, `tonalSecondary`, and
169-
`tonalTertiary` with `primaryContainer`, `secondaryContainer`, and
170-
`tertiaryContainer`. The old names have been removed without aliases. This also
171-
applies to the FAB menu trigger's `variant`.
166+
| v5 | v6 |
167+
| --- | --- |
168+
| `primary` | `primaryContainer` |
169+
| `secondary` | `secondaryContainer` |
170+
| `tertiary` | `tertiaryContainer` |
172171

173-
Both `surface` and `branded` use `surfaceContainerHigh`. Surface FAB content uses
174-
`primary`; branded content defaults to `onSurface`. A custom icon source can
175-
render brand artwork in its own colors.
172+
If you omit `variant`, no change is needed. Replace `variant="surface"` with
173+
one of the supported color variants, such as `primaryContainer`.
176174

177-
On web, hovering a FAB raises its elevation from level 3 to level 4. Focused and
178-
pressed states use level 3. The large FAB icon remains **36dp**.
175+
For custom colors, replace `color` with `contentColor` and move
176+
`style.backgroundColor` to `containerColor`:
177+
178+
```diff
179+
<FAB
180+
icon="plus"
181+
- color="#ffffff"
182+
- style={{ backgroundColor: '#6750a4', position: 'absolute', bottom: 16, right: 16 }}
183+
+ contentColor="#ffffff"
184+
+ containerColor="#6750a4"
185+
+ style={{ position: 'absolute', bottom: 16, right: 16 }}
186+
/>
187+
```
179188

180189
### TextInput
181190

‎example/src/Examples/FABExample.tsx‎

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ const variants: FabColor[] = [
3737
'primaryContainer',
3838
'secondaryContainer',
3939
'tertiaryContainer',
40-
'surface',
4140
'branded',
4241
'custom',
4342
];
@@ -141,9 +140,7 @@ const FABExample = () => {
141140
<ChipRow
142141
label="Color"
143142
options={
144-
type === 'menu'
145-
? variants.filter((v) => v !== 'surface' && v !== 'branded')
146-
: variants
143+
type === 'menu' ? variants.filter((v) => v !== 'branded') : variants
147144
}
148145
value={variant}
149146
onChange={setVariant}
@@ -154,10 +151,7 @@ const FABExample = () => {
154151
options={types}
155152
value={type}
156153
onChange={(nextType) => {
157-
if (
158-
nextType === 'menu' &&
159-
(variant === 'surface' || variant === 'branded')
160-
) {
154+
if (nextType === 'menu' && variant === 'branded') {
161155
setVariant('primaryContainer');
162156
}
163157
setType(nextType);
@@ -234,10 +228,7 @@ const FABExample = () => {
234228
alignment={position}
235229
trigger={{
236230
icon: 'pencil',
237-
variant:
238-
activeVariant === 'surface' || activeVariant === 'branded'
239-
? undefined
240-
: activeVariant,
231+
variant: activeVariant === 'branded' ? undefined : activeVariant,
241232
containerColor: activeContainerColor,
242233
size,
243234
visible: showFab,

‎src/components/FAB/Extended.tsx‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,6 @@ export type Props = {
3939
label: string;
4040
/**
4141
* Role-color preset. Defaults to `primaryContainer`.
42-
* Choose `primary`, `primaryContainer`, `secondary`, `secondaryContainer`,
43-
* `tertiary`, `tertiaryContainer`, `surface`, or `branded`.
44-
* `surface` and `branded` use `surfaceContainerHigh`; `surface` uses primary
45-
* content, while `branded` uses on-surface content. Pass a custom icon source
46-
* to preserve brand artwork colors.
4742
*/
4843
variant?: Variant;
4944
/**

‎src/components/FAB/FAB.tsx‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,6 @@ export type Props = {
2222
icon: IconSource;
2323
/**
2424
* Role-color preset. Defaults to `primaryContainer`.
25-
* Choose `primary`, `primaryContainer`, `secondary`, `secondaryContainer`,
26-
* `tertiary`, `tertiaryContainer`, `surface`, or `branded`.
27-
* `surface` and `branded` use `surfaceContainerHigh`; `surface` uses primary
28-
* content, while `branded` uses on-surface content. Pass a custom icon source
29-
* to preserve brand artwork colors.
3025
*/
3126
variant?: Variant;
3227
/**

‎src/components/FAB/Menu.tsx‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export type MenuItemProps = {
5555
testID?: string;
5656
};
5757

58-
type MenuVariant = Exclude<Variant, 'surface' | 'branded'>;
58+
type MenuVariant = Exclude<Variant, 'branded'>;
5959

6060
export type MenuTriggerProps = {
6161
/**
@@ -457,7 +457,6 @@ const MorphingTrigger = ({
457457
testID={testID}
458458
>
459459
<Shell
460-
elevation={Tokens.stateElevation.enabled}
461460
size={size}
462461
variant={triggerVariant}
463462
containerColor={triggerContainerColor}

‎src/components/FAB/Shell.tsx‎

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { getDimensions, resolveColors } from './utils';
2929
import { useInternalTheme } from '../../core/theming';
3030
import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
3131
import { toRawSpring } from '../../theme/tokens/sys/motion';
32-
import type { Elevation, ThemeProp } from '../../theme/types';
32+
import type { ThemeProp } from '../../theme/types';
3333
import type { ShapeToken } from '../../theme/utils/shape';
3434
import type { IconSource } from '../Icon';
3535
import Surface from '../Surface';
@@ -77,11 +77,6 @@ export type ShellProps = {
7777
* Trailing-padding override.
7878
*/
7979
trailing?: number;
80-
/**
81-
* Resting elevation level. Defaults to the FAB's enabled-state elevation.
82-
* Pass `0` to disable the shadow entirely.
83-
*/
84-
elevation?: Elevation;
8580
/**
8681
* When `false`, the shell animates out (scale + alpha) and stops accepting
8782
* touches.
@@ -194,7 +189,6 @@ const Shell = ({
194189
iconSize,
195190
leading,
196191
trailing,
197-
elevation,
198192
visible = true,
199193
onPress,
200194
'aria-label': ariaLabel = label,
@@ -219,21 +213,17 @@ const Shell = ({
219213
const theme = useInternalTheme(themeOverrides);
220214
const [hovered, setHovered] = React.useState(false);
221215
const [pressed, setPressed] = React.useState(false);
222-
const [focused, setFocused] = React.useState(false);
223216
const touchableRef = React.useRef<View>(null);
217+
const previousFocusedElement = React.useRef<HTMLElement | null>(null);
224218

225-
// Explicit elevations (including flat menu items) keep their own treatment.
226219
const resolvedElevation =
227-
elevation ??
228-
(Platform.OS === 'web' && visible && onPress
220+
visible && onPress
229221
? pressed
230222
? Tokens.stateElevation.pressed
231-
: focused
232-
? Tokens.stateElevation.focus
233-
: hovered
234-
? Tokens.stateElevation.hover
235-
: Tokens.stateElevation.enabled
236-
: Tokens.stateElevation.enabled);
223+
: hovered
224+
? Tokens.stateElevation.hover
225+
: Tokens.stateElevation.enabled
226+
: Tokens.stateElevation.enabled;
237227

238228
const dimensions = React.useMemo(
239229
() => getDimensions({ theme, size, shape, iconSize, leading, trailing }),
@@ -317,16 +307,32 @@ const Shell = ({
317307
const { focusedSV, onFocus, onBlur } = useFocusRing();
318308

319309
React.useEffect(() => {
320-
if (!visible || !onPress) {
321-
if (Platform.OS === 'web') {
322-
touchableRef.current?.blur();
310+
if (!visible) {
311+
if (Platform.OS === 'web' && typeof document !== 'undefined') {
312+
const target: unknown = touchableRef.current;
313+
if (
314+
target instanceof HTMLElement &&
315+
target === document.activeElement
316+
) {
317+
if (previousFocusedElement.current?.isConnected) {
318+
previousFocusedElement.current.focus({ preventScroll: true });
319+
}
320+
// The previous element may have been removed or become unfocusable.
321+
if (target === document.activeElement) target.blur();
322+
}
323323
}
324324
setHovered(false);
325325
setPressed(false);
326-
setFocused(false);
327326
onBlur();
328327
}
329-
}, [visible, onPress, onBlur]);
328+
}, [visible, onBlur]);
329+
330+
React.useEffect(() => {
331+
if (!onPress) {
332+
setHovered(false);
333+
setPressed(false);
334+
}
335+
}, [onPress]);
330336

331337
const focusRingStyle = useAnimatedStyle(
332338
() => ({
@@ -359,19 +365,26 @@ const Shell = ({
359365
borderless
360366
background={background}
361367
onPress={visible ? onPress : undefined}
362-
disabled={!onPress || !visible}
368+
disabled={!onPress}
369+
accessible={visible}
370+
focusable={visible && !!onPress}
371+
tabIndex={visible ? undefined : -1}
363372
onHoverIn={() => setHovered(true)}
364373
onHoverOut={() => setHovered(false)}
365374
onPressIn={() => setPressed(true)}
366375
onPressOut={() => setPressed(false)}
367-
onFocus={() => {
368-
setFocused(true);
376+
onFocus={(event) => {
377+
if (Platform.OS === 'web') {
378+
const previous =
379+
'relatedTarget' in event.nativeEvent
380+
? event.nativeEvent.relatedTarget
381+
: null;
382+
previousFocusedElement.current =
383+
previous instanceof HTMLElement ? previous : null;
384+
}
369385
onFocus();
370386
}}
371-
onBlur={() => {
372-
setFocused(false);
373-
onBlur();
374-
}}
387+
onBlur={onBlur}
375388
aria-label={ariaLabel}
376389
role="button"
377390
aria-checked={ariaChecked}

‎src/components/FAB/tokens.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ export type Variant =
1515
| 'primaryContainer'
1616
| 'secondaryContainer'
1717
| 'tertiaryContainer'
18-
| 'surface'
1918
| 'branded';
2019

2120
export type Size = 'default' | 'medium' | 'large';
@@ -70,7 +69,7 @@ const stateElevation = {
7069
} as const satisfies Record<string, Elevation>;
7170

7271
const variants = {
73-
surface: { container: 'surfaceContainerHigh', content: 'primary' },
72+
// Branded artwork has no prescribed icon color; onSurface is a fallback.
7473
branded: { container: 'surfaceContainerHigh', content: 'onSurface' },
7574
primary: { container: 'primary', content: 'onPrimary' },
7675
secondary: { container: 'secondary', content: 'onSecondary' },

‎src/components/__tests__/FAB.test.tsx‎

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
shadow,
1111
} from '../../theme/tokens/sys/elevation';
1212
import FAB from '../FAB';
13-
import Shell from '../FAB/Shell';
1413

1514
afterEach(() => {
1615
jest.restoreAllMocks();
@@ -150,20 +149,12 @@ it.each(['icon', 'extended'] as const)(
150149
}
151150
);
152151

153-
it('keeps an explicit shell elevation of zero on hover', async () => {
154-
jest.replaceProperty(Platform, 'OS', 'web');
155-
await render(<Shell icon="plus" onPress={() => {}} elevation={0} />);
156-
await fireEvent(screen.getByTestId('fab-shell'), 'hoverIn');
157-
const [flatShadow] = shadow(0, getTheme().colors.shadow);
158-
expect(screen.getByTestId('fab-shell-container')).toHaveStyle(flatShadow);
159-
});
160-
161152
it('does not enable a FAB without an action when adding interaction handlers', async () => {
162153
await render(<FAB icon="plus" aria-label="Create" />);
163154
expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled();
164155
});
165156

166-
it('hides an invisible FAB from accessibility and disables its action', async () => {
157+
it('hides an invisible FAB from accessibility and keyboard navigation without marking it disabled', async () => {
167158
const onPress = jest.fn();
168159
await render(
169160
<FAB icon="plus" aria-label="Create" onPress={onPress} visible={false} />
@@ -172,7 +163,10 @@ it('hides an invisible FAB from accessibility and disables its action', async ()
172163
const fab = screen.getByTestId('floating-action-button', {
173164
includeHiddenElements: true,
174165
});
175-
expect(fab).toBeDisabled();
166+
expect(fab).not.toBeDisabled();
167+
expect(fab).toHaveProp('accessible', false);
168+
expect(fab).toHaveProp('focusable', false);
169+
expect(fab).toHaveProp('tabIndex', -1);
176170
await userEvent.press(fab);
177171
expect(onPress).not.toHaveBeenCalled();
178172
});
@@ -190,7 +184,7 @@ it('clears interaction elevation when a FAB is hidden and shown again', async ()
190184
);
191185
});
192186

193-
it('keeps the menu trigger at its existing elevation on web', async () => {
187+
it('uses the shared FAB hover elevation for the menu trigger', async () => {
194188
jest.replaceProperty(Platform, 'OS', 'web');
195189
await render(
196190
<FAB.Menu
@@ -204,22 +198,40 @@ it('keeps the menu trigger at its existing elevation on web', async () => {
204198
/>
205199
);
206200
await fireEvent(screen.getByTestId('fab-shell'), 'hoverIn');
207-
const [restingShadow] = shadow(3, getTheme().colors.shadow);
208-
expect(screen.getByTestId('fab-shell-container')).toHaveStyle(restingShadow);
201+
const [hoverShadow] = shadow(4, getTheme().colors.shadow);
202+
expect(screen.getByTestId('fab-shell-container')).toHaveStyle(hoverShadow);
209203
});
210204

211205
it.each(['ios', 'android'] as const)(
212-
'keeps native elevation unchanged on hover on %s',
206+
'applies hover elevation alongside focus and restores it after press on %s',
213207
async (platform) => {
214208
jest.replaceProperty(Platform, 'OS', platform);
215209
await render(<FAB icon="plus" onPress={() => {}} />);
216-
await fireEvent(screen.getByTestId('floating-action-button'), 'hoverIn');
210+
const fab = screen.getByTestId('floating-action-button');
211+
const container = screen.getByTestId('floating-action-button-container');
217212
const [restingShadow] = shadow(3, getTheme().colors.shadow);
218-
expect(screen.getByTestId('floating-action-button-container')).toHaveStyle(
213+
const [hoverShadow] = shadow(4, getTheme().colors.shadow);
214+
const restingStyle =
219215
platform === 'android'
220216
? { elevation: androidElevationLevels[3] }
221-
: restingShadow
222-
);
217+
: restingShadow;
218+
const hoverStyle =
219+
platform === 'android'
220+
? { elevation: androidElevationLevels[4] }
221+
: hoverShadow;
222+
223+
await fireEvent(fab, 'focus', { nativeEvent: {} });
224+
expect(container).toHaveStyle(restingStyle);
225+
await fireEvent(fab, 'hoverIn');
226+
expect(container).toHaveStyle(hoverStyle);
227+
await fireEvent(fab, 'focus', { nativeEvent: {} });
228+
expect(container).toHaveStyle(hoverStyle);
229+
await fireEvent(fab, 'pressIn');
230+
expect(container).toHaveStyle(restingStyle);
231+
await fireEvent(fab, 'pressOut');
232+
expect(container).toHaveStyle(hoverStyle);
233+
await fireEvent(fab, 'hoverOut');
234+
expect(container).toHaveStyle(restingStyle);
223235
}
224236
);
225237

‎src/components/__tests__/FABUtils.test.tsx‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getDimensions, resolveColors } from '../FAB/utils';
55

66
describe('resolveColors', () => {
77
it.each([false, true])(
8-
'resolves every MD3 color variant (dark=%s)',
8+
'resolves every FAB color preset (dark=%s)',
99
(dark) => {
1010
const theme = getTheme(dark);
1111
const variants = {
@@ -15,7 +15,6 @@ describe('resolveColors', () => {
1515
secondaryContainer: ['secondaryContainer', 'onSecondaryContainer'],
1616
tertiary: ['tertiary', 'onTertiary'],
1717
tertiaryContainer: ['tertiaryContainer', 'onTertiaryContainer'],
18-
surface: ['surfaceContainerHigh', 'primary'],
1918
branded: ['surfaceContainerHigh', 'onSurface'],
2019
} as const;
2120

@@ -26,7 +25,6 @@ describe('resolveColors', () => {
2625
'secondaryContainer',
2726
'tertiary',
2827
'tertiaryContainer',
29-
'surface',
3028
'branded',
3129
] as const;
3230
for (const variant of variantNames) {

0 commit comments

Comments
 (0)