Skip to content

Commit d5ccf0a

Browse files
committed
feat(fab): add focus ring
1 parent e7c6ca9 commit d5ccf0a

4 files changed

Lines changed: 151 additions & 25 deletions

File tree

src/components/FAB/FabShell.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
AccessibilityState,
44
ColorValue,
55
GestureResponderEvent,
6+
Platform,
67
PressableAndroidRippleConfig,
78
StyleProp,
89
StyleSheet,
@@ -24,8 +25,12 @@ import {
2425
FloatingActionButtonSize,
2526
FloatingActionButtonTokens,
2627
FloatingActionButtonVariant,
28+
FOCUS_RING_INSET,
29+
FOCUS_RING_THICKNESS,
30+
webNoOutline,
2731
} from './tokens';
2832
import { useFabVisibility } from './useFabVisibility';
33+
import { useFocusRing } from './useFocusRing';
2934
import { getDimensions, resolveColors } from './utils';
3035
import { useInternalTheme } from '../../core/theming';
3136
import type { ShapeToken } from '../../theme/utils/shape';
@@ -286,6 +291,15 @@ const FabShell = forwardRef<View, FabShellProps>(
286291
[borderRadius, containerBg]
287292
);
288293

294+
const { focusedSV, onFocus, onBlur } = useFocusRing();
295+
const focusRingStyle = useAnimatedStyle(
296+
() => ({
297+
opacity: focusedSV.value ? 1 : 0,
298+
borderRadius: borderRadius.value + FOCUS_RING_INSET,
299+
}),
300+
[borderRadius]
301+
);
302+
289303
return (
290304
<Reanimated.View
291305
ref={ref}
@@ -304,11 +318,16 @@ const FabShell = forwardRef<View, FabShellProps>(
304318
borderless
305319
background={background}
306320
onPress={onPress}
321+
onFocus={onFocus}
322+
onBlur={onBlur}
307323
accessibilityLabel={accessibilityLabel}
308324
accessibilityRole="button"
309325
accessibilityState={accessibilityState}
310326
testID={testID}
311-
style={children ? styles.fill : null}
327+
style={[
328+
children ? styles.fill : null,
329+
Platform.OS === 'web' ? webNoOutline : null,
330+
]}
312331
>
313332
{children ?? (
314333
<FabContent
@@ -333,6 +352,13 @@ const FabShell = forwardRef<View, FabShellProps>(
333352
)}
334353
</TouchableRipple>
335354
</Reanimated.View>
355+
<Reanimated.View
356+
style={[
357+
styles.focusRing,
358+
{ borderColor: theme.colors.secondary },
359+
focusRingStyle,
360+
]}
361+
/>
336362
</Reanimated.View>
337363
);
338364
}
@@ -356,6 +382,15 @@ const styles = StyleSheet.create({
356382
pointerEventsNone: {
357383
pointerEvents: 'none',
358384
},
385+
focusRing: {
386+
position: 'absolute',
387+
top: -FOCUS_RING_INSET,
388+
left: -FOCUS_RING_INSET,
389+
right: -FOCUS_RING_INSET,
390+
bottom: -FOCUS_RING_INSET,
391+
borderWidth: FOCUS_RING_THICKNESS,
392+
pointerEvents: 'none',
393+
},
359394
});
360395

361396
export default FabShell;

src/components/FAB/FloatingActionButtonMenu.tsx

Lines changed: 64 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from 'react';
22
import {
33
ColorValue,
44
GestureResponderEvent,
5+
Platform,
56
StyleSheet,
67
View,
78
} from 'react-native';
@@ -23,7 +24,11 @@ import {
2324
FloatingActionButtonSize,
2425
FloatingActionButtonTokens,
2526
FloatingActionButtonVariant,
27+
FOCUS_RING_INSET,
28+
FOCUS_RING_THICKNESS,
29+
webNoOutline,
2630
} from './tokens';
31+
import { useFocusRing } from './useFocusRing';
2732
import { resolveColors } from './utils';
2833
import { useLocale } from '../../core/locale';
2934
import { useInternalTheme } from '../../core/theming';
@@ -242,33 +247,56 @@ const MenuItem = ({
242247
const { height, iconSize, leading, trailing, iconLabelGap, shape } =
243248
FloatingActionButtonMenuTokens.listItem;
244249
const borderRadius = resolveCornerRadius(theme, shape);
250+
251+
const { focusedSV, onFocus, onBlur } = useFocusRing();
252+
const focusRingStyle = useAnimatedStyle(() => ({
253+
opacity: focusedSV.value ? 1 : 0,
254+
}));
255+
245256
return (
246-
<View
247-
style={[
248-
styles.menuItem,
249-
{ height, borderRadius, backgroundColor: colors.container },
250-
]}
251-
>
252-
<TouchableRipple
253-
borderless
254-
onPress={onPress}
255-
accessibilityRole="button"
256-
accessibilityLabel={accessibilityLabel ?? label}
257-
style={{ borderRadius }}
258-
testID={testID}
257+
<View style={styles.menuItemWrapper}>
258+
<View
259+
style={[
260+
styles.menuItem,
261+
{ height, borderRadius, backgroundColor: colors.container },
262+
]}
259263
>
260-
<FabContent
261-
icon={icon}
262-
label={label}
263-
contentColor={colors.content}
264-
height={height}
265-
iconSize={iconSize}
266-
leading={leading}
267-
trailing={trailing}
268-
iconLabelGap={iconLabelGap}
264+
<TouchableRipple
265+
borderless
266+
onPress={onPress}
267+
onFocus={onFocus}
268+
onBlur={onBlur}
269+
accessibilityRole="button"
270+
accessibilityLabel={accessibilityLabel ?? label}
271+
style={[
272+
{ borderRadius },
273+
Platform.OS === 'web' ? webNoOutline : null,
274+
]}
269275
testID={testID}
270-
/>
271-
</TouchableRipple>
276+
>
277+
<FabContent
278+
icon={icon}
279+
label={label}
280+
contentColor={colors.content}
281+
height={height}
282+
iconSize={iconSize}
283+
leading={leading}
284+
trailing={trailing}
285+
iconLabelGap={iconLabelGap}
286+
testID={testID}
287+
/>
288+
</TouchableRipple>
289+
</View>
290+
<Animated.View
291+
style={[
292+
styles.menuItemFocusRing,
293+
{
294+
borderColor: theme.colors.secondary,
295+
borderRadius: borderRadius + FOCUS_RING_INSET,
296+
},
297+
focusRingStyle,
298+
]}
299+
/>
272300
</View>
273301
);
274302
};
@@ -690,9 +718,21 @@ const styles = StyleSheet.create({
690718
itemsEnd: {
691719
right: 0,
692720
},
721+
menuItemWrapper: {
722+
position: 'relative',
723+
},
693724
menuItem: {
694725
overflow: 'hidden',
695726
},
727+
menuItemFocusRing: {
728+
position: 'absolute',
729+
top: -FOCUS_RING_INSET,
730+
left: -FOCUS_RING_INSET,
731+
right: -FOCUS_RING_INSET,
732+
bottom: -FOCUS_RING_INSET,
733+
borderWidth: FOCUS_RING_THICKNESS,
734+
pointerEvents: 'none',
735+
},
696736
triggerSlot: {
697737
justifyContent: 'flex-start',
698738
},

src/components/FAB/tokens.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import type { ViewStyle } from 'react-native';
2+
3+
import { tokens } from '../../theme/tokens';
14
import type {
25
ColorRole,
36
Elevation,
@@ -116,3 +119,10 @@ export const FloatingActionButtonMenuTokens = {
116119
listItem,
117120
spacing,
118121
};
122+
123+
const focusIndicator = tokens.md.sys.state.focusIndicator;
124+
export const FOCUS_RING_THICKNESS = focusIndicator.thickness;
125+
export const FOCUS_RING_OUTER_OFFSET = focusIndicator.outerOffset;
126+
export const FOCUS_RING_INSET = FOCUS_RING_OUTER_OFFSET + FOCUS_RING_THICKNESS;
127+
128+
export const webNoOutline = { outline: 'none' } as unknown as ViewStyle;

src/components/FAB/useFocusRing.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import * as React from 'react';
2+
import { Platform } from 'react-native';
3+
4+
import { useSharedValue, type SharedValue } from 'react-native-reanimated';
5+
6+
export type FocusRingState = {
7+
/**
8+
* `true` when the surface is keyboard-focused. Drive the focus ring's
9+
* `opacity` from this in a `useAnimatedStyle`.
10+
*/
11+
focusedSV: SharedValue<boolean>;
12+
/** Wire to the `Pressable`/`TouchableRipple`'s `onFocus`. */
13+
onFocus: () => void;
14+
/** Wire to the `Pressable`/`TouchableRipple`'s `onBlur`. */
15+
onBlur: () => void;
16+
};
17+
18+
/**
19+
* Drives an MD3 focus indicator for FAB-flavored surfaces. On web, focus is
20+
* gated by `:focus-visible` so a mouse click does not light the ring; on
21+
* native, every focus event is honored.
22+
*/
23+
export function useFocusRing(): FocusRingState {
24+
const focusedSV = useSharedValue(false);
25+
26+
const onFocus = React.useCallback(() => {
27+
if (
28+
Platform.OS === 'web' &&
29+
!document.activeElement?.matches(':focus-visible')
30+
) {
31+
return;
32+
}
33+
focusedSV.value = true;
34+
}, [focusedSV]);
35+
36+
const onBlur = React.useCallback(() => {
37+
focusedSV.value = false;
38+
}, [focusedSV]);
39+
40+
return { focusedSV, onFocus, onBlur };
41+
}

0 commit comments

Comments
 (0)