From b205fc5c4b8edbe2a35a3353586103ded6218c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Wed, 26 Aug 2026 15:47:40 +0200 Subject: [PATCH 1/3] fix: expand interactive targets to the 48dp minimum --- src/components/Checkbox/Checkbox.tsx | 7 +- src/components/Chip/Chip.tsx | 43 +++- src/components/IconButton/IconButton.tsx | 26 ++- .../TouchableRipple.native.tsx | 119 ++++++++++ .../TouchableRipple/TouchableRipple.tsx | 106 ++++++++- .../Appbar/__snapshots__/Appbar.test.tsx.snap | 64 +++--- .../__snapshots__/Checkbox.test.tsx.snap | 7 + .../__snapshots__/CheckboxItem.test.tsx.snap | 4 + src/components/__tests__/Chip.test.tsx | 38 ++++ .../__snapshots__/RadioButton.test.tsx.snap | 4 + .../RadioButtonGroup.test.tsx.snap | 1 + .../RadioButtonItem.test.tsx.snap | 8 + .../__tests__/TouchableRipple.test.tsx | 212 +++++++++++++++++- .../__tests__/TouchableRippleWeb.test.tsx | 134 +++++++++++ .../__snapshots__/Banner.test.tsx.snap | 4 + .../__snapshots__/Button.test.tsx.snap | 13 ++ .../__snapshots__/Chip.test.tsx.snap | 34 ++- .../__snapshots__/DataTable.test.tsx.snap | 195 +++++++--------- .../__snapshots__/DrawerItem.test.tsx.snap | 3 + .../__tests__/__snapshots__/FAB.test.tsx.snap | 13 ++ .../__snapshots__/FABExtended.test.tsx.snap | 6 + .../__snapshots__/FABMenu.test.tsx.snap | 25 +++ .../__snapshots__/IconButton.test.tsx.snap | 80 +++---- .../__snapshots__/ListAccordion.test.tsx.snap | 6 + .../__snapshots__/ListItem.test.tsx.snap | 8 + .../__snapshots__/ListSection.test.tsx.snap | 6 + .../__snapshots__/Menu.test.tsx.snap | 7 + .../__snapshots__/MenuItem.test.tsx.snap | 5 + .../__snapshots__/Searchbar.test.tsx.snap | 80 +++---- .../SegmentedButton.test.tsx.snap | 2 + .../__snapshots__/Snackbar.test.tsx.snap | 1 + .../__snapshots__/TextInput.test.tsx.snap | 128 +++++------ .../__snapshots__/ToggleButton.test.tsx.snap | 48 ++-- src/theme/tokens/sys/state.ts | 7 + 34 files changed, 1081 insertions(+), 363 deletions(-) create mode 100644 src/components/__tests__/TouchableRippleWeb.test.tsx diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index f2527383d7..165c54c3b7 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -80,9 +80,10 @@ const { const FOCUS_THICKNESS = tokens.md.sys.state.focusIndicator.thickness; // Focus indicator is a circular ring at the 40dp state-layer boundary. -// We don't apply `focusIndicator.outerOffset` here because the surrounding -// `TouchableRipple borderless` clips overflow to the tap-target shape, -// so a ring drawn outside the 40dp circle would be cropped. +// We don't apply `focusIndicator.outerOffset`, so the ring stays inside the 40dp +// circle. `TouchableRipple borderless` used to crop anything outside it; on web +// it no longer does, since the touchable cannot clip without clipping the touch +// target. Native still clips. Check both when revisiting the offset. const FOCUS_RING_SIZE = STATE_LAYER_SIZE; const FOCUS_RING_RADIUS = STATE_LAYER_SIZE / 2; diff --git a/src/components/Chip/Chip.tsx b/src/components/Chip/Chip.tsx index b6482e9209..a651077bc8 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -169,6 +169,25 @@ export type Props = $Omit, 'mode'> & { * export default MyComponent; * ``` */ +/** + * Room the chip reserves on its right for the close button, which fills all of + * it, so the body stops here and the two divide the chip. + * + * MD3 splits the same way and does not give a chip's trailing action 48dp; in + * material-web it is 24x24 with no expansion. This column is wider than that and + * gets no vertical expansion, so the strips above and below belong to the body + * and a near miss activates the chip rather than deleting it. + * @see https://github.com/material-components/material-web/blob/main/chips/internal/_trailing-icon.scss + */ +const CLOSE_AFFORDANCE_WIDTH = 34; + +/** + * Floor for the clamp below. The glyph is 18dp and sits 8dp from the right, so + * under this it hangs over the chip body, and part of the visible icon would + * activate the chip instead of removing it. + */ +const CLOSE_AFFORDANCE_MIN_WIDTH = 26; + const Chip = ({ mode = 'flat', children, @@ -273,7 +292,7 @@ const Chip = ({ : 8 * multiplier, }; const contentSpacings = { - paddingRight: onClose ? 34 : 0, + paddingRight: onClose ? CLOSE_AFFORDANCE_WIDTH : 0, }; const labelTextStyle = { color: textColor, @@ -399,8 +418,12 @@ const Chip = ({ disabled={disabled} role="button" aria-label={closeIconAccessibilityLabel} + style={styles.closeButton} > - + {closeIcon ? ( ) : ( @@ -451,6 +474,10 @@ const styles = StyleSheet.create({ md3CloseIcon: { marginRight: 8, padding: 0, + // `styles.icon` sets `alignSelf: 'center'`, which beats `alignItems` on the + // parent. Without this the glyph centres in the wider column and moves 4dp + // left. + alignSelf: 'flex-end', }, md3LabelText: { textAlignVertical: 'center', @@ -481,9 +508,19 @@ const styles = StyleSheet.create({ closeButtonStyle: { position: 'absolute', right: 0, + width: CLOSE_AFFORDANCE_WIDTH, + // A chip narrower than this column would hand the whole thing to the close + // button. Never more than half, never less than the glyph needs; minWidth + // wins over maxWidth. + minWidth: CLOSE_AFFORDANCE_MIN_WIDTH, + maxWidth: '50%', + height: '100%', + }, + closeButton: { + width: '100%', height: '100%', + // Vertical only. The glyph pins itself horizontally with `alignSelf`. justifyContent: 'center', - alignItems: 'center', }, touchable: { width: '100%', diff --git a/src/components/IconButton/IconButton.tsx b/src/components/IconButton/IconButton.tsx index 270c9289ac..5fa9b635fc 100644 --- a/src/components/IconButton/IconButton.tsx +++ b/src/components/IconButton/IconButton.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Animated, StyleSheet, View } from 'react-native'; +import { Animated, Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, @@ -181,7 +181,7 @@ const IconButton = ({ pointerEvents="none" style={[ StyleSheet.absoluteFill, - { backgroundColor, opacity: backgroundOpacity }, + { backgroundColor, opacity: backgroundOpacity, borderRadius }, ]} /> )} @@ -190,15 +190,18 @@ const IconButton = ({ centered onPress={onPress} aria-label={ariaLabel} - style={[styles.touchable, contentStyle]} + style={[ + styles.touchable, + { borderRadius }, + // The Surface used to clip the ripple, so the touchable does it now. + // Native only: its own overflow does not clip its hitSlop, but on web + // it would clip the touch target, where the container already clips. + Platform.OS !== 'web' && styles.clipToShape, + contentStyle, + ]} role="button" aria-disabled={disabled} disabled={disabled} - hitSlop={ - TouchableRipple.supported - ? { top: 10, left: 10, bottom: 10, right: 10 } - : { top: 6, left: 6, bottom: 6, right: 6 } - } testID={testID} {...rest} > @@ -216,7 +219,9 @@ const IconButton = ({ const styles = StyleSheet.create({ container: { - overflow: 'hidden', + // No `overflow: 'hidden'`. An ancestor that clips also clips the touch + // target, which is why the hitSlop this component used to pass never + // applied. The overlay and the touchable clip themselves instead. margin: 6, elevation: 0, }, @@ -225,6 +230,9 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, + clipToShape: { + overflow: 'hidden', + }, }); export default IconButton; diff --git a/src/components/TouchableRipple/TouchableRipple.native.tsx b/src/components/TouchableRipple/TouchableRipple.native.tsx index 513355afa5..55468f7096 100644 --- a/src/components/TouchableRipple/TouchableRipple.native.tsx +++ b/src/components/TouchableRipple/TouchableRipple.native.tsx @@ -6,6 +6,8 @@ import type { ViewStyle, GestureResponderEvent, ColorValue, + Insets, + LayoutChangeEvent, } from 'react-native'; import type { PressableProps } from './Pressable'; @@ -14,12 +16,81 @@ import { getTouchableRippleColors } from './utils'; import { SettingsContext } from '../../core/settings'; import type { Settings } from '../../core/settings'; import { useInternalTheme } from '../../core/theming'; +import { tokens } from '../../theme/tokens'; import type { ThemeProp } from '../../types'; import hasTouchHandler from '../../utils/hasTouchHandler'; const ANDROID_VERSION_LOLLIPOP = 21; const ANDROID_VERSION_PIE = 28; +const { minInteractiveSize } = tokens.md.sys.state; + +/** + * The underlay fills the touchable absolutely and has no radius of its own, so + * it paints square corners over a rounded one. A clipping ancestor used to hide + * that, and those ancestors have to stop clipping for the expansion to work. + */ +const getUnderlayShape = (style: StyleProp): ViewStyle => { + const flat = StyleSheet.flatten(style); + + if (!flat) { + return {}; + } + + const { + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, + } = flat; + + return { + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, + }; +}; + +/** + * Slop needed to bring a rendered size up to `minInteractiveSize`. Expands + * outside the bounds rather than resizing, so a 40dp state layer keeps its 40dp + * and gains 4dp per side. Returns undefined when the size is already enough, so + * that case does not re-render. + * @see https://developer.android.com/develop/ui/compose/accessibility/api-defaults + */ +const getExpansion = (width: number, height: number): Insets | undefined => { + // A collapsed touchable would otherwise claim 24dp of slop around a point + // where nothing is drawn. + if (width === 0 || height === 0) { + return undefined; + } + + const horizontal = Math.max(0, (minInteractiveSize - width) / 2); + const vertical = Math.max(0, (minInteractiveSize - height) / 2); + + if (horizontal === 0 && vertical === 0) { + return undefined; + } + + return { + top: vertical, + bottom: vertical, + left: horizontal, + right: horizontal, + }; +}; + export type Props = PressableProps & { borderless?: boolean; background?: PressableAndroidRippleConfig; @@ -46,6 +117,8 @@ const TouchableRipple = ({ underlayColor, children, theme: themeOverrides, + hitSlop, + onLayout, ref, ...rest }: Props) => { @@ -63,6 +136,47 @@ const TouchableRipple = ({ const disabled = disabledProp || !hasPassedTouchHandler; + const [expansion, setExpansion] = React.useState( + undefined + ); + + // A caller hitSlop wins, so there is nothing to measure for. `null` counts as + // supplied, it means "no slop". + const shouldMeasure = hitSlop === undefined; + + // Gates whether the measurement is applied, not whether it happens. RN emits + // onLayout on mount and on layout change, so a touchable that mounts disabled + // gets no event once it is enabled and would stay small. + const shouldExpand = shouldMeasure && !disabled; + + const handleLayout = React.useCallback( + (event: LayoutChangeEvent) => { + onLayout?.(event); + + const { width, height } = event.nativeEvent.layout; + const next = getExpansion(width, height); + + setExpansion((current) => { + // Nothing changed, so a big enough touchable does not re-render. + if (current === next) { + return current; + } + if ( + current && + next && + current.top === next.top && + current.bottom === next.bottom && + current.left === next.left && + current.right === next.right + ) { + return current; + } + return next; + }); + }, + [onLayout] + ); + const { calculatedRippleColor, calculatedUnderlayColor } = getTouchableRippleColors({ theme, @@ -92,6 +206,8 @@ const TouchableRipple = ({ {...rest} ref={ref} disabled={disabled} + hitSlop={shouldExpand ? expansion : hitSlop} + onLayout={shouldMeasure ? handleLayout : onLayout} style={[useForeground && styles.overflowHidden, style]} android_ripple={androidRipple} > @@ -105,6 +221,8 @@ const TouchableRipple = ({ {...rest} ref={ref} disabled={disabled} + hitSlop={shouldExpand ? expansion : hitSlop} + onLayout={shouldMeasure ? handleLayout : onLayout} style={[borderless && styles.overflowHidden, style]} > {({ pressed }) => ( @@ -114,6 +232,7 @@ const TouchableRipple = ({ testID="touchable-ripple-underlay" style={[ styles.underlay, + getUnderlayShape(style), { backgroundColor: calculatedUnderlayColor }, ]} /> diff --git a/src/components/TouchableRipple/TouchableRipple.tsx b/src/components/TouchableRipple/TouchableRipple.tsx index d913b1ee3c..6432dfffcc 100644 --- a/src/components/TouchableRipple/TouchableRipple.tsx +++ b/src/components/TouchableRipple/TouchableRipple.tsx @@ -15,12 +15,58 @@ import { getTouchableRippleColors } from './utils'; import { SettingsContext } from '../../core/settings'; import type { Settings } from '../../core/settings'; import { useInternalTheme } from '../../core/theming'; +import { tokens } from '../../theme/tokens'; import type { ThemeProp } from '../../types'; import hasTouchHandler from '../../utils/hasTouchHandler'; +const { minInteractiveSize } = tokens.md.sys.state; + +/** + * react-native-web removed `hitSlop` in 0.13.0, so web needs a real element the + * browser can hit-test instead. An absolutely positioned box at least the + * minimum target size, which is what material-web does, and it costs no layout. + * @see https://github.com/necolas/react-native-web/releases/tag/0.13.0 + * @see https://github.com/material-components/material-web/blob/main/iconbutton/internal/_shared.scss + */ +const getTouchTargetStyle = (hitSlop: PressableProps['hitSlop']): ViewStyle => { + // `undefined` means the caller said nothing, so the minimum applies. `null` + // means "no slop", same as native. + if (hitSlop === undefined) { + return styles.touchTarget; + } + if (hitSlop === null) { + return styles.noTouchTarget; + } + + // A caller hitSlop wins here too, so web matches native instead of ignoring + // the prop. + const inset = (value: number | undefined) => -(value ?? 0); + + return typeof hitSlop === 'number' + ? { + position: 'absolute', + top: inset(hitSlop), + bottom: inset(hitSlop), + left: inset(hitSlop), + right: inset(hitSlop), + } + : { + position: 'absolute', + top: inset(hitSlop.top), + bottom: inset(hitSlop.bottom), + left: inset(hitSlop.left), + right: inset(hitSlop.right), + }; +}; + export type Props = PressableProps & { /** * Whether to render the ripple outside the view bounds. + * + * On web the ripple is bounded by its own container, so this no longer clips + * the touchable's content. The touchable cannot clip without clipping the + * touch target, so children needing a rounded shape carry the radius + * themselves. */ borderless?: boolean; /** @@ -105,12 +151,14 @@ export type Props = PressableProps & { const TouchableRipple = ({ style, background: _background, - borderless = false, + // consumed so it does not reach the DOM; the ripple container clips regardless + borderless: _borderless = false, disabled: disabledProp, rippleColor, underlayColor: _underlayColor, children, theme: themeOverrides, + hitSlop, ref, ...rest }: Props) => { @@ -178,7 +226,16 @@ const TouchableRipple = ({ borderTopRightRadius: style.borderTopRightRadius, borderBottomRightRadius: style.borderBottomRightRadius, borderBottomLeftRadius: style.borderBottomLeftRadius, - overflow: centered ? 'visible' : 'hidden', + // The touchable cannot clip, it would clip the touch target too, so + // the ripple is contained here. This container is inset to the + // touchable and copies its radii, so it clips to the same shape. + // + // Always, not `centered ? 'visible' : 'hidden'` as before. A ripple + // that escaped used to be caught by whichever ancestor clipped, and + // those ancestors have to stop. ToggleButton hit this: it passes + // `borderless={false}` to IconButton, which spreads it over its own, + // so the Surface was holding the ripple in. + overflow: 'hidden', }); // Create span to show the ripple effect @@ -282,7 +339,6 @@ const TouchableRipple = ({ disabled={disabled} style={(state) => [ styles.touchable, - borderless && styles.borderless, // focused state is not ready yet: https://github.com/necolas/react-native-web/issues/1849 // state.focused && { backgroundColor: ___ }, state.hovered && { backgroundColor: hoverColor }, @@ -290,11 +346,26 @@ const TouchableRipple = ({ typeof style === 'function' ? style(state) : style, ]} > - {(state) => - React.Children.only( - typeof children === 'function' ? children(state) : children - ) - } + {(state) => ( + <> + {/* Before the children, not after. It hit-tests, so as the last + sibling it covers anything interactive inside the touchable and + takes its presses, e.g. a pressable List.Item with a control in + `right`. Ahead of them it still covers the area outside the + touchable, where there is nothing else to hit. + Nothing that cannot be pressed gets a target, same as native. */} + {!disabled && ( + + )} + {React.Children.only( + typeof children === 'function' ? children(state) : children + )} + + )} ); }; @@ -317,8 +388,23 @@ const styles = StyleSheet.create({ cursor: 'auto', }), }, - borderless: { - overflow: 'hidden', + noTouchTarget: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + }, + touchTarget: { + position: 'absolute', + top: '50%', + left: '50%', + // max(minInteractiveSize, 100%), same as MD3 web's .touch + width: '100%', + height: '100%', + minWidth: minInteractiveSize, + minHeight: minInteractiveSize, + transform: [{ translateX: '-50%' }, { translateY: '-50%' }], }, }); diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index a5d9d95766..9327719388 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -109,7 +109,6 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -144,17 +143,10 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -173,6 +165,12 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -299,7 +297,6 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -334,17 +331,10 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -363,6 +353,12 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -488,7 +484,6 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -523,17 +518,10 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -552,6 +540,12 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] @@ -732,7 +726,6 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "borderWidth": 0, "elevation": 0, "flex": 1, - "overflow": "hidden", "shadowColor": "rgba(0, 0, 0, 1)", "shadowOffset": { "height": 0, @@ -766,17 +759,10 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A centered={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -795,6 +781,12 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "flexGrow": 1, "justifyContent": "center", }, + { + "borderRadius": 20, + }, + { + "overflow": "hidden", + }, undefined, ], ] diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index 54f2e4f7a4..cc3dc7f938 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -27,6 +27,7 @@ exports[`renders Checkbox with custom testID 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -213,6 +214,7 @@ exports[`renders checked Checkbox with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -398,6 +400,7 @@ exports[`renders checked Checkbox with onPress 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -583,6 +586,7 @@ exports[`renders indeterminate Checkbox 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -755,6 +759,7 @@ exports[`renders indeterminate Checkbox with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -927,6 +932,7 @@ exports[`renders unchecked Checkbox with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1112,6 +1118,7 @@ exports[`renders unchecked Checkbox with onPress 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap index 0f7f92073b..8846a0c2fc 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap @@ -26,6 +26,7 @@ exports[`can render leading checkbox control 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -81,6 +82,7 @@ exports[`can render leading checkbox control 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -304,6 +306,7 @@ exports[`renders unchecked 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -397,6 +400,7 @@ exports[`renders unchecked 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/Chip.test.tsx b/src/components/__tests__/Chip.test.tsx index 644906ae33..c676da734a 100644 --- a/src/components/__tests__/Chip.test.tsx +++ b/src/components/__tests__/Chip.test.tsx @@ -402,3 +402,41 @@ it('animated value changes correctly', async () => { transform: [{ scale: 1.5 }], }); }); + +describe('close affordance', () => { + // The chip already reserved room on its right, but only the icon was tappable, + // so the body owned the rest of that column. MD3 has the primary action stop + // where the trailing one starts. + it('fills the column the chip reserves for it', async () => { + await render( + {}} onClose={() => {}}> + Example + + ); + + expect(screen.getByLabelText('Close')).toHaveStyle({ + width: '100%', + height: '100%', + }); + }); + + it('keeps the close glyph pinned right so it does not drift', async () => { + await render( + {}} onClose={() => {}}> + Example + + ); + + // `styles.icon` sets alignSelf center, which would otherwise win and move + // the glyph 4dp left + expect(screen.getByTestId('chip-close-icon')).toHaveStyle({ + alignSelf: 'flex-end', + }); + }); + + it('is not rendered without onClose', async () => { + await render( {}}>Example); + + expect(screen.queryByLabelText('Close')).not.toBeOnTheScreen(); + }); +}); diff --git a/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap index c20910f20e..31dffb6970 100644 --- a/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap +++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap @@ -26,6 +26,7 @@ exports[`RadioButton RadioButton with custom testID renders properly 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -113,6 +114,7 @@ exports[`RadioButton on default platform renders properly 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -199,6 +201,7 @@ exports[`RadioButton on ios platform renders properly 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -285,6 +288,7 @@ exports[`RadioButton when RadioButton is wrapped by RadioButtonContext.Provider onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap index 1ae7f560be..54a237c099 100644 --- a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap +++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonGroup.test.tsx.snap @@ -29,6 +29,7 @@ exports[`RadioButtonGroup renders properly 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap index 5867b1408c..ef32fb657b 100644 --- a/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap +++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButtonItem.test.tsx.snap @@ -26,6 +26,7 @@ exports[`can render leading radio button control 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -80,6 +81,7 @@ exports[`can render leading radio button control 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -204,6 +206,7 @@ exports[`can render the Android radio button on different platforms 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -294,6 +297,7 @@ exports[`can render the Android radio button on different platforms 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -356,6 +360,7 @@ exports[`can render the iOS radio button on different platforms 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -446,6 +451,7 @@ exports[`can render the iOS radio button on different platforms 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -534,6 +540,7 @@ exports[`renders unchecked 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -624,6 +631,7 @@ exports[`renders unchecked 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index f3c4cb1168..a96c63cd10 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -1,8 +1,9 @@ +import * as React from 'react'; import { Platform, Text } from 'react-native'; import type { GestureResponderEvent } from 'react-native'; import { describe, expect, it, jest } from '@jest/globals'; -import { userEvent } from '@testing-library/react-native'; +import { act, fireEvent, userEvent } from '@testing-library/react-native'; import { render, screen } from '../../test-utils'; import TouchableRipple from '../TouchableRipple/TouchableRipple.native'; @@ -68,5 +69,214 @@ describe('TouchableRipple', () => { const underlay = screen.getByTestId('touchable-ripple-underlay'); expect(underlay).toHaveStyle({ backgroundColor: 'purple' }); }); + + it('takes the shape of the touchable so it does not square off the corners', async () => { + await render( + + Press me! + + ); + + expect(screen.getByTestId('touchable-ripple-underlay')).toHaveStyle({ + borderRadius: 4, + }); + }); + + it('takes per-corner radii too', async () => { + await render( + + Press me! + + ); + + expect(screen.getByTestId('touchable-ripple-underlay')).toHaveStyle({ + borderTopLeftRadius: 8, + borderBottomRightRadius: 2, + }); + }); + }); + + describe('minimum interactive size', () => { + const layout = (width: number, height: number) => ({ + nativeEvent: { layout: { width, height, x: 0, y: 0 } }, + }); + + // hitSlop has no user-visible effect here, the renderer does not lay views + // out or hit-test them. Real behaviour is checked on device; this only stops + // the props being dropped. + /* eslint-disable no-restricted-syntax */ + const hitSlopOf = () => screen.getByTestId('touchable').props.hitSlop; + const onLayoutOf = () => screen.getByTestId('touchable').props.onLayout; + /* eslint-enable no-restricted-syntax */ + + const renderTouchable = async (props = {}) => { + await render( + {}} {...props}> + Button + + ); + return screen.getByTestId('touchable'); + }; + + const fireLayout = async (width: number, height: number) => { + await act(async () => { + await fireEvent( + screen.getByTestId('touchable'), + 'layout', + layout(width, height) + ); + }); + }; + + it('expands a small target out to the minimum interactive size', async () => { + await renderTouchable(); + expect(hitSlopOf()).toBeUndefined(); + + await fireLayout(32, 32); + + // (48 - 32) / 2 on every side + expect(hitSlopOf()).toEqual({ top: 8, bottom: 8, left: 8, right: 8 }); + }); + + it('expands each axis independently', async () => { + await renderTouchable(); + + await fireLayout(40, 100); + + expect(hitSlopOf()).toEqual({ top: 0, bottom: 0, left: 4, right: 4 }); + }); + + it('leaves a target that is already big enough alone', async () => { + await renderTouchable(); + + await fireLayout(48, 48); + + expect(hitSlopOf()).toBeUndefined(); + }); + + it('lets a caller-supplied hitSlop win', async () => { + await renderTouchable({ hitSlop: 2 }); + + await fireLayout(32, 32); + + expect(hitSlopOf()).toBe(2); + }); + + it('does not expand a touchable with no touch handlers', async () => { + await render( + + Not a control + + ); + + expect(hitSlopOf()).toBeUndefined(); + }); + + // Measuring and applying are separate. RN emits onLayout on mount and on + // layout change, so measuring only once interactive would mean no event ever + // arrives and the target stays small. + it('measures even while it cannot be pressed', async () => { + await renderTouchable({ disabled: true }); + + expect(onLayoutOf()).toEqual(expect.any(Function)); + expect(hitSlopOf()).toBeUndefined(); + }); + + it('does not expand a disabled touchable', async () => { + await renderTouchable({ disabled: true }); + + await fireLayout(32, 32); + + expect(hitSlopOf()).toBeUndefined(); + }); + + it('keeps the measurement across losing and regaining interactivity', async () => { + const Harness = ({ disabled }: { disabled: boolean }) => ( + {}} + > + Button + + ); + const view = await render(); + const expanded = { top: 8, bottom: 8, left: 8, right: 8 }; + + await fireLayout(32, 32); + expect(hitSlopOf()).toEqual(expanded); + + await act(async () => { + await view.rerender(); + }); + expect(hitSlopOf()).toBeUndefined(); + + // back again, with no second layout event to rely on + await act(async () => { + await view.rerender(); + }); + expect(hitSlopOf()).toEqual(expanded); + }); + + it('still calls a caller-supplied onLayout', async () => { + const onLayout = jest.fn(); + await renderTouchable({ onLayout }); + + await fireLayout(32, 32); + + expect(onLayout).toHaveBeenCalledTimes(1); + }); + + describe('render cost', () => { + // TouchableRipple renders everywhere, so the cost of measuring is worth + // pinning down. + const withProfiler = async () => { + const commits: string[] = []; + await render( + commits.push(phase)} + > + {}}> + Button + + + ); + return commits; + }; + + it('costs no extra render when the target is already big enough', async () => { + const commits = await withProfiler(); + expect(commits).toEqual(['mount']); + + await fireLayout(56, 56); + + // the updater returned the identical value, so React bails out + expect(commits).toEqual(['mount']); + }); + + it('costs one extra render when the target is too small', async () => { + const commits = await withProfiler(); + + await fireLayout(32, 32); + + expect(commits).toEqual(['mount', 'update']); + }); + + it('settles after a repeated layout at the same size', async () => { + const commits = await withProfiler(); + + await fireLayout(32, 32); + await fireLayout(32, 32); + await fireLayout(32, 32); + + // React renders once more before it can bail out on an unchanged value, + // then stops. Three more layout events, one more render. + expect(commits).toEqual(['mount', 'update', 'update']); + }); + }); }); }); diff --git a/src/components/__tests__/TouchableRippleWeb.test.tsx b/src/components/__tests__/TouchableRippleWeb.test.tsx new file mode 100644 index 0000000000..ccb07f18eb --- /dev/null +++ b/src/components/__tests__/TouchableRippleWeb.test.tsx @@ -0,0 +1,134 @@ +import { Text } from 'react-native'; + +import { describe, expect, it } from '@jest/globals'; + +import { render, screen } from '../../test-utils'; +import type TouchableRippleType from '../TouchableRipple/TouchableRipple'; + +// The web variant, required with its extension on purpose. A bare specifier +// resolves to `TouchableRipple.native.tsx` under the jest preset, so importing +// it the normal way silently tests the native file and none of this runs. +// +// The preset sets `Platform.OS` to 'ios' and there is no DOM, so this renders the +// web source on the native renderer. It pins props and element order, nothing +// more. Hit testing, stacking order, computed styles and clipping ancestors have +// to be checked in a browser. Pressing here would throw, `handlePressIn` reaches +// for `window`. +const TouchableRipple: typeof TouchableRippleType = + require('../TouchableRipple/TouchableRipple.tsx').default; + +const TARGET = 'touchable-ripple-touch-target'; + +// The target is `aria-hidden`, the button already carries the semantics. Testing +// library skips hidden elements, so queries have to opt in or they find nothing +// and the negative cases pass for free. +const HIDDEN = { includeHiddenElements: true } as const; + +describe('TouchableRipple (web)', () => { + // The target is invisible by design, so there is no user-visible assertion to + // make about it. Its style is the behaviour. + const styleOf = (testID: string) => { + // eslint-disable-next-line no-restricted-syntax + const { style } = screen.getByTestId(testID, HIDDEN).props; + return Array.isArray(style) ? Object.assign({}, ...style.flat()) : style; + }; + + it('renders a minimum sized touch target for an interactive touchable', async () => { + await render( + {}}> + Button + + ); + + expect(screen.getByTestId(TARGET, HIDDEN)).toBeOnTheScreen(); + expect(styleOf(TARGET)).toMatchObject({ + position: 'absolute', + minWidth: 48, + minHeight: 48, + width: '100%', + height: '100%', + }); + }); + + it('renders the touch target before the children so it cannot cover them', async () => { + // It hit-tests, so as the last sibling it covers anything interactive inside + // the touchable, e.g. a pressable List.Item with a control in `right`. + await render( + {}}> + child-marker + + ); + + const tree = JSON.stringify(screen.toJSON()); + + expect(tree.indexOf(TARGET)).toBeGreaterThan(-1); + expect(tree.indexOf(TARGET)).toBeLessThan(tree.indexOf('child-marker')); + }); + + it('does not render a touch target when there are no touch handlers', async () => { + await render( + + Not a control + + ); + + expect(screen.queryByTestId(TARGET, HIDDEN)).not.toBeOnTheScreen(); + }); + + it('does not render a touch target when disabled', async () => { + await render( + {}}> + Button + + ); + + expect(screen.queryByTestId(TARGET, HIDDEN)).not.toBeOnTheScreen(); + }); + + it('lets a caller-supplied hitSlop size the target instead', async () => { + await render( + {}}> + Button + + ); + + expect(styleOf(TARGET)).toEqual({ + position: 'absolute', + top: -6, + bottom: -6, + left: -6, + right: -6, + }); + }); + + it('accepts a per-edge hitSlop', async () => { + await render( + {}}> + Button + + ); + + expect(styleOf(TARGET)).toEqual({ + position: 'absolute', + top: -4, + bottom: -0, + left: -8, + right: -0, + }); + }); + + it('no longer clips the touchable itself, which would clip the target', async () => { + await render( + {}} testID="touchable"> + Button + + ); + + const style = styleOf('touchable'); + + // check we have the touchable's own style first, or the absence below passes + // against any empty object + expect(style).toMatchObject({ position: 'relative' }); + expect(style.overflow).toBeUndefined(); + }); +}); diff --git a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap index 4e75db8cc7..867ceb8c76 100644 --- a/src/components/__tests__/__snapshots__/Banner.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Banner.test.tsx.snap @@ -181,6 +181,7 @@ exports[`render visible banner, with custom theme 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -614,6 +615,7 @@ exports[`renders visible banner, with action buttons and with image 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -890,6 +892,7 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1042,6 +1045,7 @@ exports[`renders visible banner, with action buttons and without image 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/Button.test.tsx.snap b/src/components/__tests__/__snapshots__/Button.test.tsx.snap index bbc1fff1be..ded51c64a4 100644 --- a/src/components/__tests__/__snapshots__/Button.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Button.test.tsx.snap @@ -65,6 +65,7 @@ exports[`renders button with an accessibility hint 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -218,6 +219,7 @@ exports[`renders button with an accessibility label 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -370,6 +372,7 @@ exports[`renders button with button color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -522,6 +525,7 @@ exports[`renders button with color 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -674,6 +678,7 @@ exports[`renders button with custom testID 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -826,6 +831,7 @@ exports[`renders button with icon 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1027,6 +1033,7 @@ exports[`renders button with icon in reverse order 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1230,6 +1237,7 @@ exports[`renders contained contained with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1383,6 +1391,7 @@ exports[`renders disabled button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1535,6 +1544,7 @@ exports[`renders loading button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -1891,6 +1901,7 @@ exports[`renders outlined button with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2044,6 +2055,7 @@ exports[`renders text button by default 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -2196,6 +2208,7 @@ exports[`renders text button with mode 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} diff --git a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap index 7bf18dde0e..d87ad50074 100644 --- a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap @@ -64,6 +64,7 @@ exports[`renders chip with close button 1`] = ` onBlur={[Function]} onClick={[Function]} onFocus={[Function]} + onLayout={[Function]} onResponderGrant={[Function]} onResponderMove={[Function]} onResponderRelease={[Function]} @@ -204,11 +205,12 @@ exports[`renders chip with close button 1`] = ` @@ -244,6 +246,13 @@ exports[`renders chip with close button 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} role="button" + style={ + { + "height": "100%", + "justifyContent": "center", + "width": "100%", + } + } > @@ -542,6 +555,13 @@ exports[`renders chip with custom close button 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} role="button" + style={ + { + "height": "100%", + "justifyContent": "center", + "width": "100%", + } + } > Date: Thu, 27 Aug 2026 16:04:28 +0200 Subject: [PATCH 2/3] fix: hitslop and styling --- src/components/IconButton/IconButton.tsx | 28 +++++++++++++------ .../TouchableRipple.native.tsx | 16 +++++------ src/components/__tests__/IconButton.test.tsx | 21 ++++++++++++++ .../__tests__/TouchableRipple.test.tsx | 22 +++++++++++++++ 4 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/components/IconButton/IconButton.tsx b/src/components/IconButton/IconButton.tsx index 5fa9b635fc..bd5b3bdfd1 100644 --- a/src/components/IconButton/IconButton.tsx +++ b/src/components/IconButton/IconButton.tsx @@ -10,6 +10,7 @@ import type { import { getIconButtonColor } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { $RemoveChildren, ThemeProp } from '../../types'; +import { splitStyles } from '../../utils/splitStyles'; import ActivityIndicator from '../ActivityIndicator'; import CrossFadeIcon from '../CrossFadeIcon'; import Icon from '../Icon'; @@ -147,16 +148,26 @@ const IconButton = ({ const buttonSize = size + 2 * PADDING; - const { - borderWidth = mode === 'outlined' && !selected ? 1 : 0, - borderRadius = buttonSize / 2, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - } = (StyleSheet.flatten(style) || {}) as ViewStyle; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const flattenedStyle = (StyleSheet.flatten(style) || {}) as ViewStyle; + + const { borderWidth = mode === 'outlined' && !selected ? 1 : 0 } = + flattenedStyle; + + const [, borderRadiusStyles] = splitStyles( + flattenedStyle, + (style) => style.startsWith('border') && style.endsWith('Radius') + ); + + const shapeStyles = { + borderRadius: buttonSize / 2, + ...borderRadiusStyles, + }; const borderStyles = { borderWidth, - borderRadius, borderColor, + ...shapeStyles, }; return ( @@ -181,7 +192,8 @@ const IconButton = ({ pointerEvents="none" style={[ StyleSheet.absoluteFill, - { backgroundColor, opacity: backgroundOpacity, borderRadius }, + { backgroundColor, opacity: backgroundOpacity }, + shapeStyles, ]} /> )} @@ -192,7 +204,7 @@ const IconButton = ({ aria-label={ariaLabel} style={[ styles.touchable, - { borderRadius }, + shapeStyles, // The Surface used to clip the ripple, so the touchable does it now. // Native only: its own overflow does not clip its hitSlop, but on web // it would clip the touch target, where the container already clips. diff --git a/src/components/TouchableRipple/TouchableRipple.native.tsx b/src/components/TouchableRipple/TouchableRipple.native.tsx index 55468f7096..4e6ec11bb2 100644 --- a/src/components/TouchableRipple/TouchableRipple.native.tsx +++ b/src/components/TouchableRipple/TouchableRipple.native.tsx @@ -140,14 +140,12 @@ const TouchableRipple = ({ undefined ); - // A caller hitSlop wins, so there is nothing to measure for. `null` counts as - // supplied, it means "no slop". - const shouldMeasure = hitSlop === undefined; - // Gates whether the measurement is applied, not whether it happens. RN emits - // onLayout on mount and on layout change, so a touchable that mounts disabled - // gets no event once it is enabled and would stay small. - const shouldExpand = shouldMeasure && !disabled; + // onLayout on mount and on layout change, so a touchable that mounts disabled, + // or with a caller hitSlop, gets no event once that goes away and would stay + // small. A caller hitSlop wins while it is set; `null` counts as set, it means + // "no slop". + const shouldExpand = hitSlop === undefined && !disabled; const handleLayout = React.useCallback( (event: LayoutChangeEvent) => { @@ -207,7 +205,7 @@ const TouchableRipple = ({ ref={ref} disabled={disabled} hitSlop={shouldExpand ? expansion : hitSlop} - onLayout={shouldMeasure ? handleLayout : onLayout} + onLayout={handleLayout} style={[useForeground && styles.overflowHidden, style]} android_ripple={androidRipple} > @@ -222,7 +220,7 @@ const TouchableRipple = ({ ref={ref} disabled={disabled} hitSlop={shouldExpand ? expansion : hitSlop} - onLayout={shouldMeasure ? handleLayout : onLayout} + onLayout={handleLayout} style={[borderless && styles.overflowHidden, style]} > {({ pressed }) => ( diff --git a/src/components/__tests__/IconButton.test.tsx b/src/components/__tests__/IconButton.test.tsx index b28456c5ce..89284b99b6 100644 --- a/src/components/__tests__/IconButton.test.tsx +++ b/src/components/__tests__/IconButton.test.tsx @@ -19,6 +19,9 @@ const styles = StyleSheet.create({ slightlyRounded: { borderRadius: 4, }, + cutCorner: { + borderTopLeftRadius: 0, + }, }); it('renders icon button by default', async () => { @@ -85,6 +88,24 @@ it('renders icon button with small border radius', async () => { }); }); +it('clips to a custom corner radius', async () => { + await render( + {}} + style={styles.cutCorner} + /> + ); + + // The container stopped clipping so the touch target can escape it, so the + // touchable has to take the shape itself, corners included. + expect(screen.getByTestId('icon-button')).toHaveStyle({ + borderTopLeftRadius: 0, + }); +}); + describe('getIconButtonColor - icon color', () => { it('should return custom icon color', () => { expect( diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index a96c63cd10..b0ae9ea71b 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -221,6 +221,28 @@ describe('TouchableRipple', () => { expect(hitSlopOf()).toEqual(expanded); }); + it('expands once a caller-supplied hitSlop is taken away', async () => { + const Harness = ({ hitSlop }: { hitSlop?: number }) => ( + {}} + > + Button + + ); + const view = await render(); + + await fireLayout(32, 32); + expect(hitSlopOf()).toBe(2); + + // back to the default, with no second layout event to rely on + await act(async () => { + await view.rerender(); + }); + expect(hitSlopOf()).toEqual({ top: 8, bottom: 8, left: 8, right: 8 }); + }); + it('still calls a caller-supplied onLayout', async () => { const onLayout = jest.fn(); await renderTouchable({ onLayout }); From 295d3170fc2f0d93c1165e734cc5f1b2545eac21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Fri, 28 Aug 2026 16:05:36 +0200 Subject: [PATCH 3/3] feat: add MD3 keyboard focus indicators --- src/components/Card/Card.tsx | 8 + src/components/Checkbox/Checkbox.tsx | 52 +- src/components/Chip/Chip.tsx | 21 +- src/components/FAB/Menu.tsx | 41 +- src/components/FAB/Shell.tsx | 48 +- src/components/FAB/tokens.ts | 11 - src/components/FAB/useFocusRing.ts | 41 -- src/components/List/ListItem.tsx | 1 + .../RadioButton/RadioButtonItem.tsx | 1 + .../SegmentedButtons/SegmentedButtonItem.tsx | 1 + src/components/Switch/Switch.tsx | 53 +- .../TouchableRipple.native.tsx | 44 +- .../TouchableRipple/TouchableRipple.tsx | 42 +- .../__snapshots__/Checkbox.test.tsx.snap | 7 - .../__snapshots__/CheckboxItem.test.tsx.snap | 2 - .../__tests__/TouchableRipple.test.tsx | 68 +++ .../TouchableRippleFocusWeb.test.tsx | 95 +++ .../__snapshots__/Chip.test.tsx.snap | 30 +- .../__tests__/__snapshots__/FAB.test.tsx.snap | 286 --------- .../__snapshots__/FABExtended.test.tsx.snap | 132 ----- .../__snapshots__/FABMenu.test.tsx.snap | 550 ------------------ .../__snapshots__/Switch.test.tsx.snap | 132 ----- .../__tests__/focusRingWiring.test.tsx | 103 ++++ src/utils/__tests__/focusRingContrast.test.ts | 63 ++ src/utils/__tests__/useFocusRing.test.tsx | 136 +++++ src/utils/useFocusRing.ts | 91 +++ 26 files changed, 735 insertions(+), 1324 deletions(-) delete mode 100644 src/components/FAB/useFocusRing.ts create mode 100644 src/components/__tests__/TouchableRippleFocusWeb.test.tsx create mode 100644 src/components/__tests__/focusRingWiring.test.tsx create mode 100644 src/utils/__tests__/focusRingContrast.test.ts create mode 100644 src/utils/__tests__/useFocusRing.test.tsx create mode 100644 src/utils/useFocusRing.ts diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index f0fdc89407..7b489746a9 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -13,6 +13,7 @@ import { useInternalTheme } from '../../core/theming'; import type { $Omit, ThemeProp } from '../../types'; import hasTouchHandler from '../../utils/hasTouchHandler'; import { splitStyles } from '../../utils/splitStyles'; +import { getFocusRingStyle, useFocusRing } from '../../utils/useFocusRing'; import Surface from '../Surface'; type OutlinedCardProps = { @@ -141,6 +142,7 @@ const Card = ({ ...rest }: (OutlinedCardProps | ElevatedCardProps | ContainedCardProps) & Props) => { const theme = useInternalTheme(themeOverrides); + const focusRing = useFocusRing(disabled); const isMode = React.useCallback( (modeToCompare: Mode) => { return cardMode === modeToCompare; @@ -267,6 +269,12 @@ const Card = ({ onPress={onPress} onPressIn={handlePressIn} onPressOut={handlePressOut} + onFocus={focusRing.onFocus} + onBlur={focusRing.onBlur} + style={[ + borderRadiusCombinedStyles, + getFocusRingStyle(focusRing.focused, theme.colors.secondary), + ]} > {content} diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 165c54c3b7..8840b6db89 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -3,9 +3,7 @@ import { Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, - NativeSyntheticEvent, StyleProp, - TargetedEvent, ViewStyle, } from 'react-native'; @@ -22,9 +20,7 @@ import { getSelectionVisualState } from './utils'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; -import { tokens } from '../../theme/tokens'; import type { $RemoveChildren, ThemeProp } from '../../types'; -import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; export type Props = $RemoveChildren & { @@ -78,15 +74,6 @@ const { stateLayerSize: STATE_LAYER_SIZE, } = CheckboxTokens; -const FOCUS_THICKNESS = tokens.md.sys.state.focusIndicator.thickness; -// Focus indicator is a circular ring at the 40dp state-layer boundary. -// We don't apply `focusIndicator.outerOffset`, so the ring stays inside the 40dp -// circle. `TouchableRipple borderless` used to crop anything outside it; on web -// it no longer does, since the touchable cannot clip without clipping the touch -// target. Native still clips. Check both when revisiting the offset. -const FOCUS_RING_SIZE = STATE_LAYER_SIZE; -const FOCUS_RING_RADIUS = STATE_LAYER_SIZE / 2; - /** * Checkboxes allow the selection of multiple options from a set. * @@ -129,7 +116,6 @@ const Checkbox = ({ // Web (react-native-web) doesn't auto-mirror layout, so flip the mask // anchor manually for RTL. Native handles it via `I18nManager`. const flipMaskForWebRTL = Platform.OS === 'web' && direction === 'rtl'; - const [focused, setFocused] = React.useState(false); const selected = status === 'checked' || status === 'indeterminate'; @@ -227,19 +213,6 @@ const Checkbox = ({ } const showIndeterminate = nextGlyph === 'indeterminate'; - const handleFocus = React.useCallback( - (e: NativeSyntheticEvent) => { - if (disabled) return; - if (!isKeyboardFocusEvent(e)) return; - setFocused(true); - }, - [disabled] - ); - - const handleBlur = React.useCallback(() => { - setFocused(false); - }, []); - const checked: boolean | 'mixed' = status === 'indeterminate' ? 'mixed' : status === 'checked'; @@ -263,24 +236,12 @@ const Checkbox = ({ borderless centered onPress={onPress} - onFocus={handleFocus} - onBlur={handleBlur} disabled={disabled} {...accessibilityProps} testID={testID} - style={[ - styles.tapTarget, - Platform.OS === 'web' ? webNoOutline : undefined, - style, - ]} + style={[styles.tapTarget, style]} > - {focused && !disabled ? ( - - ) : null} { const theme = useInternalTheme(themeOverrides); + const closeFocusRing = useFocusRing(disabled); const isWeb = Platform.OS === 'web'; const { current: elevation } = React.useRef( @@ -318,6 +324,7 @@ const Chip = ({ > ({ - opacity: focusedSV.value ? 1 : 0, - })); + const { focused, onFocus, onBlur } = useFocusRing(); return ( @@ -260,10 +256,12 @@ const MenuItem = ({ style={[ styles.menuItem, { height, borderRadius, backgroundColor: colors.container }, + ...toStyleList(getFocusRingStyle(focused, theme.colors.secondary)), ]} > - ); }; @@ -687,15 +675,6 @@ const styles = StyleSheet.create({ menuItem: { overflow: 'hidden', }, - menuItemFocusRing: { - position: 'absolute', - top: -FOCUS_RING_INSET, - left: -FOCUS_RING_INSET, - right: -FOCUS_RING_INSET, - bottom: -FOCUS_RING_INSET, - borderWidth: FOCUS_RING_THICKNESS, - pointerEvents: 'none', - }, triggerSlot: { justifyContent: 'flex-start', }, diff --git a/src/components/FAB/Shell.tsx b/src/components/FAB/Shell.tsx index 2d2d60e4cd..e90402d663 100644 --- a/src/components/FAB/Shell.tsx +++ b/src/components/FAB/Shell.tsx @@ -16,19 +16,19 @@ import type { SharedValue } from 'react-native-reanimated'; import type { AnimatedStyle } from 'react-native-reanimated'; import Content from './Content'; -import { - Tokens, - FOCUS_RING_INSET, - FOCUS_RING_THICKNESS, - webNoOutline, -} from './tokens'; +import { Tokens } from './tokens'; import type { Size, Variant } from './tokens'; -import { useFocusRing } from './useFocusRing'; import { useVisibility } from './useVisibility'; import { getDimensions, resolveColors } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ShapeToken } from '../../theme/utils/shape'; import type { Elevation, ThemeProp } from '../../types'; +import { + getFocusRingStyle, + toStyleList, + useFocusRing, + webNoOutline, +} from '../../utils/useFocusRing'; import type { IconSource } from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -276,14 +276,7 @@ const Shell = ({ [borderRadius, containerBg] ); - const { focusedSV, onFocus, onBlur } = useFocusRing(); - const focusRingStyle = useAnimatedStyle( - () => ({ - opacity: focusedSV.value ? 1 : 0, - borderRadius: borderRadius.value + FOCUS_RING_INSET, - }), - [borderRadius] - ); + const { focused, onFocus, onBlur } = useFocusRing(); return ( - + {overlay} - ); }; @@ -366,15 +359,6 @@ const styles = StyleSheet.create({ pointerEventsNone: { pointerEvents: 'none', }, - focusRing: { - position: 'absolute', - top: -FOCUS_RING_INSET, - left: -FOCUS_RING_INSET, - right: -FOCUS_RING_INSET, - bottom: -FOCUS_RING_INSET, - borderWidth: FOCUS_RING_THICKNESS, - pointerEvents: 'none', - }, }); export default Shell; diff --git a/src/components/FAB/tokens.ts b/src/components/FAB/tokens.ts index 0fb79d1d9c..4188bc340a 100644 --- a/src/components/FAB/tokens.ts +++ b/src/components/FAB/tokens.ts @@ -1,6 +1,3 @@ -import type { ViewStyle } from 'react-native'; - -import { tokens } from '../../theme/tokens'; import type { ColorRole, Elevation, @@ -119,11 +116,3 @@ export const MenuTokens = { listItem, spacing, }; - -const focusIndicator = tokens.md.sys.state.focusIndicator; -export const FOCUS_RING_THICKNESS = focusIndicator.thickness; -export const FOCUS_RING_OUTER_OFFSET = focusIndicator.outerOffset; -export const FOCUS_RING_INSET = FOCUS_RING_OUTER_OFFSET + FOCUS_RING_THICKNESS; - -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -export const webNoOutline = { outline: 'none' } as unknown as ViewStyle; diff --git a/src/components/FAB/useFocusRing.ts b/src/components/FAB/useFocusRing.ts deleted file mode 100644 index bb056a10c8..0000000000 --- a/src/components/FAB/useFocusRing.ts +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from 'react'; -import { Platform } from 'react-native'; - -import { useSharedValue, type SharedValue } from 'react-native-reanimated'; - -export type FocusRingState = { - /** - * `true` when the surface is keyboard-focused. Drive the focus ring's - * `opacity` from this in a `useAnimatedStyle`. - */ - focusedSV: SharedValue; - /** Wire to the `Pressable`/`TouchableRipple`'s `onFocus`. */ - onFocus: () => void; - /** Wire to the `Pressable`/`TouchableRipple`'s `onBlur`. */ - onBlur: () => void; -}; - -/** - * Drives an MD3 focus indicator for FAB-flavored surfaces. On web, focus is - * gated by `:focus-visible` so a mouse click does not light the ring; on - * native, every focus event is honored. - */ -export function useFocusRing(): FocusRingState { - const focusedSV = useSharedValue(false); - - const onFocus = React.useCallback(() => { - if ( - Platform.OS === 'web' && - !document.activeElement?.matches(':focus-visible') - ) { - return; - } - focusedSV.value = true; - }, [focusedSV]); - - const onBlur = React.useCallback(() => { - focusedSV.value = false; - }, [focusedSV]); - - return { focusedSV, onFocus, onBlur }; -} diff --git a/src/components/List/ListItem.tsx b/src/components/List/ListItem.tsx index a6f0181f02..d9c09849d8 100644 --- a/src/components/List/ListItem.tsx +++ b/src/components/List/ListItem.tsx @@ -228,6 +228,7 @@ const ListItem = ({ handlePress({ onPress: onPress, diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index 4f701f0b48..4cd9beae41 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -194,6 +194,7 @@ const SegmentedButtonItem = ({ { + if (isDisabled) { + focusedSV.value = 0; + } + }, [isDisabled, focusedSV]); const checkedSV = useSharedValue(checked ? 1 : 0); const hasIconSV = useSharedValue(hasIcon ? 1 : 0); const isDisabledSV = useSharedValue(isDisabled ? 1 : 0); @@ -323,10 +332,6 @@ const Switch = ({ ], })); - const focusRingAnimatedStyle = useAnimatedStyle(() => ({ - opacity: focusedSV.value, - })); - const paint = resolveSwitchPaint(colors, isEnabled, checked); const stateLayerColor = checked ? colors.checkedStateLayerColor @@ -363,10 +368,11 @@ const Switch = ({ hoveredSV.value = 0; }} onFocus={(e) => { - if (!isKeyboardFocusEvent(e)) return; - focusedSV.value = 1; + focusRing.onFocus(e); + if (!isDisabled && isKeyboardFocusEvent(e)) focusedSV.value = 1; }} onBlur={() => { + focusRing.onBlur(); focusedSV.value = 0; }} android_ripple={{ color: 'transparent' }} @@ -384,6 +390,9 @@ const Switch = ({ style={[ styles.track, { backgroundColor: paint.track, opacity: trackOpacityValue }, + ...toStyleList( + getFocusRingStyle(focusRing.focused, colors.focusIndicatorColor) + ), ]} > {showOutline ? ( @@ -446,22 +455,6 @@ const Switch = ({ ) : null} - - ); }; @@ -525,10 +518,6 @@ const styles = StyleSheet.create({ height: SELECTED_ICON, pointerEvents: 'none', }, - focusRing: { - position: 'absolute', - pointerEvents: 'none', - }, absoluteFill: { position: 'absolute', top: 0, @@ -538,8 +527,4 @@ const styles = StyleSheet.create({ }, }); -// Web-only style; not in StyleSheet because `outline` is outside ViewStyle. -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -const webNoOutline = { outline: 'none' } as unknown as ViewStyle; - export default Switch; diff --git a/src/components/TouchableRipple/TouchableRipple.native.tsx b/src/components/TouchableRipple/TouchableRipple.native.tsx index 4e6ec11bb2..a97e97d386 100644 --- a/src/components/TouchableRipple/TouchableRipple.native.tsx +++ b/src/components/TouchableRipple/TouchableRipple.native.tsx @@ -8,6 +8,8 @@ import type { ColorValue, Insets, LayoutChangeEvent, + NativeSyntheticEvent, + TargetedEvent, } from 'react-native'; import type { PressableProps } from './Pressable'; @@ -19,6 +21,12 @@ import { useInternalTheme } from '../../core/theming'; import { tokens } from '../../theme/tokens'; import type { ThemeProp } from '../../types'; import hasTouchHandler from '../../utils/hasTouchHandler'; +import type { FocusRingPlacement } from '../../utils/useFocusRing'; +import { + getFocusRingStyle, + toStyleList, + useFocusRing, +} from '../../utils/useFocusRing'; const ANDROID_VERSION_LOLLIPOP = 21; const ANDROID_VERSION_PIE = 28; @@ -96,6 +104,18 @@ export type Props = PressableProps & { background?: PressableAndroidRippleConfig; centered?: boolean; disabled?: boolean; + /** + * Where to draw the MD3 keyboard focus indicator. + * + * - `outward` - just outside the bounds. The MD3 default. + * - `inward` - just inside, for controls a clipping ancestor would trim or + * that sit flush against a neighbour. + * - `none` - no indicator. Only for a control that draws its own. + * + * Has no effect on iOS, which does not dispatch focus events for a + * `Pressable`. + */ + focusRing?: FocusRingPlacement; onPress?: (e: GestureResponderEvent) => void | null; onLongPress?: (e: GestureResponderEvent) => void; onPressIn?: (e: GestureResponderEvent) => void; @@ -119,6 +139,9 @@ const TouchableRipple = ({ theme: themeOverrides, hitSlop, onLayout, + focusRing = 'outward', + onFocus, + onBlur, ref, ...rest }: Props) => { @@ -136,6 +159,19 @@ const TouchableRipple = ({ const disabled = disabledProp || !hasPassedTouchHandler; + const ring = useFocusRing(disabled || focusRing === 'none'); + const handleFocus = (e: NativeSyntheticEvent) => { + onFocus?.(e); + ring.onFocus(e); + }; + const handleBlur = (e: NativeSyntheticEvent) => { + onBlur?.(e); + ring.onBlur(); + }; + const ringStyles = toStyleList( + getFocusRingStyle(ring.focused, theme.colors.secondary, focusRing) + ); + const [expansion, setExpansion] = React.useState( undefined ); @@ -206,7 +242,9 @@ const TouchableRipple = ({ disabled={disabled} hitSlop={shouldExpand ? expansion : hitSlop} onLayout={handleLayout} - style={[useForeground && styles.overflowHidden, style]} + onFocus={handleFocus} + onBlur={handleBlur} + style={[useForeground && styles.overflowHidden, style, ...ringStyles]} android_ripple={androidRipple} > {React.Children.only(children)} @@ -221,7 +259,9 @@ const TouchableRipple = ({ disabled={disabled} hitSlop={shouldExpand ? expansion : hitSlop} onLayout={handleLayout} - style={[borderless && styles.overflowHidden, style]} + onFocus={handleFocus} + onBlur={handleBlur} + style={[borderless && styles.overflowHidden, style, ...ringStyles]} > {({ pressed }) => ( <> diff --git a/src/components/TouchableRipple/TouchableRipple.tsx b/src/components/TouchableRipple/TouchableRipple.tsx index 6432dfffcc..1f978f37b9 100644 --- a/src/components/TouchableRipple/TouchableRipple.tsx +++ b/src/components/TouchableRipple/TouchableRipple.tsx @@ -3,7 +3,9 @@ import { Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, + NativeSyntheticEvent, StyleProp, + TargetedEvent, ViewStyle, } from 'react-native'; @@ -18,6 +20,12 @@ import { useInternalTheme } from '../../core/theming'; import { tokens } from '../../theme/tokens'; import type { ThemeProp } from '../../types'; import hasTouchHandler from '../../utils/hasTouchHandler'; +import type { FocusRingPlacement } from '../../utils/useFocusRing'; +import { + getFocusRingStyle, + toStyleList, + useFocusRing, +} from '../../utils/useFocusRing'; const { minInteractiveSize } = tokens.md.sys.state; @@ -82,6 +90,18 @@ export type Props = PressableProps & { * Whether to prevent interaction with the touchable. */ disabled?: boolean; + /** + * Where to draw the MD3 keyboard focus indicator. + * + * - `outward` - just outside the bounds. The MD3 default. + * - `inward` - just inside, for controls a clipping ancestor would trim or + * that sit flush against a neighbour. + * - `none` - no indicator. Only for a control that draws its own. + * + * Has no effect on iOS, which does not dispatch focus events for a + * `Pressable`. + */ + focusRing?: FocusRingPlacement; /** * Function to execute on press. If not set, will cause the touchable to be disabled. */ @@ -159,6 +179,9 @@ const TouchableRipple = ({ children, theme: themeOverrides, hitSlop, + focusRing = 'outward', + onFocus, + onBlur, ref, ...rest }: Props) => { @@ -330,20 +353,35 @@ const TouchableRipple = ({ const disabled = disabledProp || !hasPassedTouchHandler; + const ring = useFocusRing(disabled || focusRing === 'none'); + const handleFocus = (e: NativeSyntheticEvent) => { + onFocus?.(e); + ring.onFocus(e); + }; + const handleBlur = (e: NativeSyntheticEvent) => { + onBlur?.(e); + ring.onBlur(); + }; + return ( [ styles.touchable, - // focused state is not ready yet: https://github.com/necolas/react-native-web/issues/1849 - // state.focused && { backgroundColor: ___ }, + // RNW's own `state.focused` fires for mouse clicks too, so the ring is + // driven by onFocus instead: https://github.com/necolas/react-native-web/issues/1849 state.hovered && { backgroundColor: hoverColor }, disabled && styles.disabled, typeof style === 'function' ? style(state) : style, + ...toStyleList( + getFocusRingStyle(ring.focused, theme.colors.secondary, focusRing) + ), ]} > {(state) => ( diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index cc3dc7f938..7cc0010786 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -49,7 +49,6 @@ exports[`renders Checkbox with custom testID 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -236,7 +235,6 @@ exports[`renders checked Checkbox with color 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -422,7 +420,6 @@ exports[`renders checked Checkbox with onPress 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -608,7 +605,6 @@ exports[`renders indeterminate Checkbox 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -781,7 +777,6 @@ exports[`renders indeterminate Checkbox with color 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -954,7 +949,6 @@ exports[`renders unchecked Checkbox with color 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -1140,7 +1134,6 @@ exports[`renders unchecked Checkbox with onPress 1`] = ` "width": 40, }, undefined, - undefined, ], ] } diff --git a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap index 8846a0c2fc..c8fad1923c 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap @@ -103,7 +103,6 @@ exports[`can render leading checkbox control 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -421,7 +420,6 @@ exports[`renders unchecked 1`] = ` "width": 40, }, undefined, - undefined, ], ] } diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index b0ae9ea71b..37ecb86837 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -302,3 +302,71 @@ describe('TouchableRipple', () => { }); }); }); + +describe('TouchableRipple focus ring', () => { + const focus = async () => { + await act(async () => { + await fireEvent(screen.getByTestId('ripple'), 'focus'); + }); + }; + + it('rings on keyboard focus and clears on blur', async () => { + await render( + {}}> + Button + + ); + + await focus(); + expect(screen.getByTestId('ripple')).toHaveStyle({ + outlineWidth: 3, + outlineOffset: 2, + }); + + await act(async () => { + await fireEvent(screen.getByTestId('ripple'), 'blur'); + }); + expect(screen.getByTestId('ripple')).not.toHaveStyle({ outlineWidth: 3 }); + }); + + // Inward is opt-in, for controls a clipping ancestor would trim. + it('draws the ring inward only when asked', async () => { + await render( + {}} focusRing="inward"> + Button + + ); + + await focus(); + expect(screen.getByTestId('ripple')).toHaveStyle({ + outlineWidth: 3, + outlineOffset: -3, + }); + }); + + // The non-interactive case is covered in useFocusRing's own tests. It cannot + // be asserted here: RNTL will not dispatch to a disabled element, so a + // touchable with no press handler passes for free. + it('does not ring when the ring is turned off', async () => { + await render( + {}} focusRing="none"> + Button + + ); + + await focus(); + expect(screen.getByTestId('ripple')).not.toHaveStyle({ outlineWidth: 3 }); + }); + + it('still calls a caller onFocus', async () => { + const onFocus = jest.fn(); + await render( + {}} onFocus={onFocus}> + Button + + ); + + await focus(); + expect(onFocus).toHaveBeenCalled(); + }); +}); diff --git a/src/components/__tests__/TouchableRippleFocusWeb.test.tsx b/src/components/__tests__/TouchableRippleFocusWeb.test.tsx new file mode 100644 index 0000000000..7b34a946ff --- /dev/null +++ b/src/components/__tests__/TouchableRippleFocusWeb.test.tsx @@ -0,0 +1,95 @@ +import { Platform, Text } from 'react-native'; + +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { act, fireEvent } from '@testing-library/react-native'; + +import { render, screen } from '../../test-utils'; +import { tokens } from '../../theme/tokens'; +// By extension: a bare import resolves to `.native` under the jest preset, so +// the web implementation would never be exercised. +import TouchableRipple from '../TouchableRipple/TouchableRipple.tsx'; + +const { thickness, outerOffset } = tokens.md.sys.state.focusIndicator; + +// react-native-web hands `onFocus` a real DOM node, and `isKeyboardFocusEvent` +// asks it whether it matches `:focus-visible`. +const keyboardFocus = { currentTarget: { matches: () => true } }; +const pointerFocus = { currentTarget: { matches: () => false } }; + +const focus = async (data: unknown) => { + await act(async () => { + await fireEvent(screen.getByTestId('ripple'), 'focus', data); + }); +}; + +const renderRipple = (props = {}) => + render( + {}} {...props}> + Button + + ); + +describe('TouchableRipple focus ring (web implementation)', () => { + const original = Platform.OS; + beforeEach(() => { + Platform.OS = 'web'; + }); + afterEach(() => { + Platform.OS = original; + }); + + it('rings on keyboard focus, in the theme secondary colour', async () => { + await renderRipple(); + + await focus(keyboardFocus); + + // colour matters: a ring the same colour as its surface is invisible + expect(screen.getByTestId('ripple')).toHaveStyle({ + outlineWidth: thickness, + outlineOffset: outerOffset, + outlineStyle: 'solid', + outlineColor: 'rgba(98, 91, 113, 1)', + }); + }); + + it('does not ring on a pointer focus', async () => { + await renderRipple(); + + await focus(pointerFocus); + + expect(screen.getByTestId('ripple')).not.toHaveStyle({ + outlineWidth: thickness, + }); + }); + + it('draws inward when asked', async () => { + await renderRipple({ focusRing: 'inward' }); + + await focus(keyboardFocus); + + expect(screen.getByTestId('ripple')).toHaveStyle({ + outlineOffset: -thickness, + }); + }); + + it('forwards onFocus and onBlur to the caller', async () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + await renderRipple({ onFocus, onBlur }); + + await focus(keyboardFocus); + await act(async () => { + await fireEvent(screen.getByTestId('ripple'), 'blur'); + }); + + expect(onFocus).toHaveBeenCalled(); + expect(onBlur).toHaveBeenCalled(); + }); +}); diff --git a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap index d87ad50074..8a59b9dd4a 100644 --- a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap @@ -247,11 +247,16 @@ exports[`renders chip with close button 1`] = ` onStartShouldSetResponder={[Function]} role="button" style={ - { - "height": "100%", - "justifyContent": "center", - "width": "100%", - } + [ + { + "height": "100%", + "justifyContent": "center", + "width": "100%", + }, + { + "borderRadius": 8, + }, + ] } > - `; @@ -321,28 +299,6 @@ exports[`renders FAB medium size 1`] = ` - `; @@ -494,28 +450,6 @@ exports[`renders FAB transitioning to not visible 1`] = ` - `; @@ -667,28 +601,6 @@ exports[`renders FAB transitioning to visible 1`] = ` - `; @@ -841,28 +753,6 @@ exports[`renders FAB with aria-label 1`] = ` - `; @@ -1014,28 +904,6 @@ exports[`renders FAB with containerColor and contentColor overrides 1`] = ` - `; @@ -1187,28 +1055,6 @@ exports[`renders FAB with containerColor override 1`] = ` - `; @@ -1360,28 +1206,6 @@ exports[`renders FAB with default props 1`] = ` - `; @@ -1533,28 +1357,6 @@ exports[`renders FAB with primary variant 1`] = ` - `; @@ -1706,28 +1508,6 @@ exports[`renders FAB with secondary variant 1`] = ` - `; @@ -1879,28 +1659,6 @@ exports[`renders FAB with tertiary variant 1`] = ` - `; @@ -2052,28 +1810,6 @@ exports[`renders FAB with tonalSecondary variant 1`] = ` - `; @@ -2225,27 +1961,5 @@ exports[`renders FAB with tonalTertiary variant 1`] = ` - `; diff --git a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap index 2d4d5d7002..cfbc27b02e 100644 --- a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap @@ -183,28 +183,6 @@ exports[`renders extended FAB collapsed 1`] = ` - - - - - - - - @@ -660,28 +616,6 @@ exports[`renders FAB.Menu closed 1`] = ` - @@ -884,28 +818,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` - - @@ -1348,28 +1238,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` - @@ -1572,28 +1440,6 @@ exports[`renders FAB.Menu open 1`] = ` - - @@ -2036,28 +1860,6 @@ exports[`renders FAB.Menu open 1`] = ` - @@ -2260,28 +2062,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` - - - - - - @@ -3388,28 +3058,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` - @@ -3613,28 +3261,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` - - @@ -4077,28 +3681,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` - @@ -4330,28 +3912,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` - - @@ -4823,28 +4361,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` - @@ -5047,28 +4563,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` - - @@ -5511,28 +4983,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` - diff --git a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap index 3589a26ee9..67e804891e 100644 --- a/src/components/__tests__/__snapshots__/Switch.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Switch.test.tsx.snap @@ -186,28 +186,6 @@ exports[`Switch render renders disabled off 1`] = ` } /> - `; @@ -377,28 +355,6 @@ exports[`Switch render renders disabled on 1`] = ` } /> - `; @@ -571,28 +527,6 @@ exports[`Switch render renders off 1`] = ` } /> - `; @@ -745,28 +679,6 @@ exports[`Switch render renders on 1`] = ` } /> - `; @@ -989,28 +901,6 @@ exports[`Switch render renders with checked icon 1`] = ` - `; @@ -1233,27 +1123,5 @@ exports[`Switch render renders with per-state icons 1`] = ` - `; diff --git a/src/components/__tests__/focusRingWiring.test.tsx b/src/components/__tests__/focusRingWiring.test.tsx new file mode 100644 index 0000000000..97b416b560 --- /dev/null +++ b/src/components/__tests__/focusRingWiring.test.tsx @@ -0,0 +1,103 @@ +/* eslint-disable testing-library/no-node-access, @typescript-eslint/no-unsafe-type-assertion, no-restricted-syntax -- + The node carrying the ring is an unnamed internal view (Card's Pressable, + Switch's track, FAB's clip view). There is no testID to query it by, and + which node it is IS the thing under test, so the tree has to be walked. */ +import { StyleSheet, Text } from 'react-native'; +import type { ViewStyle } from 'react-native'; + +import { describe, expect, it } from '@jest/globals'; +import { act, fireEvent } from '@testing-library/react-native'; + +import { render, screen } from '../../test-utils'; +import { tokens } from '../../theme/tokens'; +import Card from '../Card/Card'; +import Chip from '../Chip/Chip'; +import FAB from '../FAB/FAB'; +import ListItem from '../List/ListItem'; +import Switch from '../Switch/Switch'; + +const { thickness, outerOffset } = tokens.md.sys.state.focusIndicator; +const OUTWARD = outerOffset; +const INWARD = -thickness; + +type Node = { props?: Record; children?: unknown[] }; + +const walk = (node: Node | undefined, hit: (n: Node) => boolean): Node[] => { + if (!node || typeof node !== 'object') return []; + const here = hit(node) ? [node] : []; + const kids = (node.children ?? []).flatMap((c) => walk(c as Node, hit)); + return [...here, ...kids]; +}; + +const style = (n: Node) => + StyleSheet.flatten(n.props?.style as ViewStyle) ?? {}; + +/** The node carrying the ring is often not the one that took focus. */ +const ringOffset = () => { + const root = (screen as unknown as { root: Node }).root; + const ringed = walk(root, (n) => style(n).outlineStyle === 'solid'); + return ringed.length ? style(ringed[0]).outlineOffset : undefined; +}; + +/** Focus whichever node actually has the handler wired. */ +const focusFirstFocusable = async () => { + const root = (screen as unknown as { root: Node }).root; + const target = walk(root, (n) => typeof n.props?.onFocus === 'function')[0]; + expect(target).toBeDefined(); + await act(async () => { + await fireEvent(target as never, 'focus'); + }); +}; + +/** + * Each component decides where its ring goes, and getting it backwards is + * invisible to a snapshot because the ring only exists while focused. Pin the + * placement per component so a wiring change cannot pass silently. + */ +describe('focus ring wiring', () => { + it('List.Item rings inward, clear of the rows above and below', async () => { + await render( {}} />); + + await focusFirstFocusable(); + + expect(ringOffset()).toBe(INWARD); + }); + + it('Chip rings inward, so a scrolling chip row cannot trim it', async () => { + await render( {}}>chip); + + await focusFirstFocusable(); + + expect(ringOffset()).toBe(INWARD); + }); + + it('Card rings outward', async () => { + await render( + {}}> + card + + ); + + await focusFirstFocusable(); + + expect(ringOffset()).toBe(OUTWARD); + }); + + it('FAB rings outward on its clip view', async () => { + await render( {}} />); + + await focusFirstFocusable(); + + expect(ringOffset()).toBe(OUTWARD); + }); + + // Inward here lands on the filled track, where `secondary` is ~1:1 against + // `primary` and effectively invisible. + it('Switch rings outward on its track, not inside it', async () => { + await render( {}} />); + + await focusFirstFocusable(); + + expect(ringOffset()).toBe(OUTWARD); + }); +}); diff --git a/src/utils/__tests__/focusRingContrast.test.ts b/src/utils/__tests__/focusRingContrast.test.ts new file mode 100644 index 0000000000..5ff9e9cf6f --- /dev/null +++ b/src/utils/__tests__/focusRingContrast.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from '@jest/globals'; + +import { DarkTheme, LightTheme } from '../../theme/schemes'; + +/** + * The MD3 tonal palette is luminance-matched by tone, so a `secondary` ring on + * any other role at the same tone is ~1:1 and vanishes in greyscale. The ring + * is drawn outward onto the page background for exactly this reason; these + * tests pin the surfaces it is allowed to land on. + * + * WCAG 1.4.11 Non-text Contrast wants 3:1. + */ +const MIN_RATIO = 3; + +const luminance = (rgb: string) => { + const [r, g, b] = (rgb.match(/\d+/g) ?? []).slice(0, 3).map(Number); + const channel = (c: number) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +}; + +const contrastRatio = (a: string, b: string) => { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +}; + +describe.each([ + ['light', LightTheme], + ['dark', DarkTheme], +])('focus ring contrast (%s)', (_name, theme) => { + const ring = String(theme.colors.secondary); + + // Surfaces an outward ring actually lands on. + const landsOn: (keyof typeof theme.colors)[] = [ + 'background', + 'surface', + 'surfaceVariant', + 'secondaryContainer', + ]; + it.each(landsOn)('has 3:1 against %s', (role) => { + expect( + contrastRatio(ring, String(theme.colors[role])) + ).toBeGreaterThanOrEqual(MIN_RATIO); + }); + + // Guards the reason the ring is outward rather than inward: these are the + // fills it would sit on top of, and it is invisible against them. + const wouldVanishOn: (keyof typeof theme.colors)[] = [ + 'primary', + 'tertiary', + 'error', + ]; + it.each(wouldVanishOn)( + 'is documented as unusable inward on the %s fill', + (role) => { + expect(contrastRatio(ring, String(theme.colors[role]))).toBeLessThan( + MIN_RATIO + ); + } + ); +}); diff --git a/src/utils/__tests__/useFocusRing.test.tsx b/src/utils/__tests__/useFocusRing.test.tsx new file mode 100644 index 0000000000..f7a400edd3 --- /dev/null +++ b/src/utils/__tests__/useFocusRing.test.tsx @@ -0,0 +1,136 @@ +import { Platform, Pressable, Text } from 'react-native'; + +import { describe, expect, it, jest } from '@jest/globals'; +import { act, fireEvent } from '@testing-library/react-native'; + +import { render, screen } from '../../test-utils'; +import { tokens } from '../../theme/tokens'; +import { getFocusRingStyle, useFocusRing } from '../useFocusRing'; + +const focus = async (data?: unknown) => { + await act(async () => { + await fireEvent(screen.getByTestId('probe'), 'focus', data); + }); +}; + +const blur = async () => { + await act(async () => { + await fireEvent(screen.getByTestId('probe'), 'blur'); + }); +}; + +const Probe = ({ + disabled, + onRender, +}: { + disabled?: boolean; + onRender?: () => void; +}) => { + const { focused, onFocus, onBlur } = useFocusRing(disabled); + onRender?.(); + return ( + {}} + onFocus={onFocus} + onBlur={onBlur} + style={getFocusRingStyle(focused, 'rebeccapurple')} + > + probe + + ); +}; + +describe('getFocusRingStyle', () => { + it('returns nothing when not focused', () => { + expect(getFocusRingStyle(false, 'rebeccapurple')).toBeNull(); + }); + + // Values must come from the design tokens, not be hardcoded here, or the + // token is decorative. Derive the expectation from the token itself. + it('takes its thickness and offset from the focusIndicator tokens', () => { + const { thickness, outerOffset } = tokens.md.sys.state.focusIndicator; + + expect(getFocusRingStyle(true, 'rebeccapurple')).toEqual({ + outlineWidth: thickness, + outlineColor: 'rebeccapurple', + outlineStyle: 'solid', + outlineOffset: outerOffset, + }); + expect(getFocusRingStyle(true, 'rebeccapurple', 'inward')).toEqual({ + outlineWidth: thickness, + outlineColor: 'rebeccapurple', + outlineStyle: 'solid', + outlineOffset: -thickness, + }); + }); +}); + +describe('useFocusRing', () => { + it('applies the outline on focus and removes it on blur', async () => { + await render(); + expect(screen.getByTestId('probe')).not.toHaveStyle({ outlineWidth: 3 }); + + await focus(); + expect(screen.getByTestId('probe')).toHaveStyle({ + outlineWidth: 3, + outlineColor: 'rebeccapurple', + outlineOffset: 2, + }); + + await blur(); + expect(screen.getByTestId('probe')).not.toHaveStyle({ outlineWidth: 3 }); + }); + + // `disabled` goes to the hook only, never to the Pressable: RNTL will not + // dispatch to a disabled element, so that would pass for free. + it('never rings a disabled control, even if a focus event arrives', async () => { + await render(); + + await focus(); + + expect(screen.getByTestId('probe')).not.toHaveStyle({ outlineWidth: 3 }); + }); + + it('ignores a non-keyboard focus event on web, so a mouse click does not ring', async () => { + const original = Platform.OS; + Platform.OS = 'web'; + try { + await render(); + + await focus({ currentTarget: { matches: () => false } }); + + expect(screen.getByTestId('probe')).not.toHaveStyle({ outlineWidth: 3 }); + } finally { + Platform.OS = original; + } + }); + + // The gate has to skip the state update, not just mask the result, or a + // suppressed ring still costs a render on the library's hottest primitive. + it('costs no re-render when the ring is suppressed', async () => { + const onRender = jest.fn(); + await render(); + const before = onRender.mock.calls.length; + + await focus(); + + expect(onRender.mock.calls.length).toBe(before); + }); + + it('does not restore the ring when a control is re-enabled', async () => { + const { rerender } = await render(); + await focus(); + expect(screen.getByTestId('probe')).toHaveStyle({ outlineWidth: 3 }); + + await act(async () => { + await rerender(); + }); + await act(async () => { + await rerender(); + }); + + // no focus event happened in between, so nothing should be ringed + expect(screen.getByTestId('probe')).not.toHaveStyle({ outlineWidth: 3 }); + }); +}); diff --git a/src/utils/useFocusRing.ts b/src/utils/useFocusRing.ts new file mode 100644 index 0000000000..776776f642 --- /dev/null +++ b/src/utils/useFocusRing.ts @@ -0,0 +1,91 @@ +import * as React from 'react'; +import type { + ColorValue, + NativeSyntheticEvent, + TargetedEvent, + ViewStyle, +} from 'react-native'; + +import { isKeyboardFocusEvent } from './isKeyboardFocusEvent'; +import { tokens } from '../theme/tokens'; + +const { thickness, outerOffset } = tokens.md.sys.state.focusIndicator; + +export type FocusRingPlacement = 'outward' | 'inward' | 'none'; + +export type FocusRingState = { + focused: boolean; + onFocus: (e: NativeSyntheticEvent) => void; + onBlur: () => void; +}; + +/** + * Tracks keyboard focus for an MD3 focus indicator. + * + * Fires on web and Android only. iOS never dispatches `onFocus` for a View or + * Pressable unless the `enableImperativeFocus` flag is on, and it defaults off. + */ +export function useFocusRing(disabled?: boolean): FocusRingState { + const [focused, setFocused] = React.useState(false); + + const onFocus = React.useCallback( + (e: NativeSyntheticEvent) => { + // Symmetric, and skipped entirely when there is nothing to show, so a + // suppressed ring costs no render. + if (!disabled) { + setFocused(isKeyboardFocusEvent(e)); + } + }, + [disabled] + ); + + const onBlur = React.useCallback(() => setFocused(false), []); + + // The focusable node can unmount while this hook stays mounted, and neither + // the DOM nor React fires blur for that, so clear rather than only masking. + React.useEffect(() => { + if (disabled) { + setFocused(false); + } + }, [disabled]); + + return { focused: focused && !disabled, onFocus, onBlur }; +} + +/** + * MD3 focus indicator, drawn with the platform's own `outline`. Costs no + * layout, takes its radius from the view it sits on, and `borderless` does not + * clip it. + * + * Outward by default, per MD3 (hence the `outerOffset` token): the ring lands + * on the page background, where it has a predictable contrast ratio. Pass + * `inward` only where an outward ring is measurably clipped or would land on a + * neighbour - list rows, flush segments, chips in a scrolling row. + * + * Never pair this with `outline: 'none'`. Ours overrides the browser's, so if + * it never runs the user still gets the browser ring instead of nothing. + */ +export const getFocusRingStyle = ( + focused: boolean, + color: ColorValue, + placement: FocusRingPlacement = 'outward' +): ViewStyle | null => + focused && placement !== 'none' + ? { + outlineWidth: thickness, + outlineColor: color, + outlineStyle: 'solid', + outlineOffset: placement === 'inward' ? -thickness : outerOffset, + } + : null; + +/** Spread into a style array so an absent ring adds no entry. */ +export const toStyleList = (s: ViewStyle | null): ViewStyle[] => (s ? [s] : []); + +/** + * Suppresses the browser's own focus ring. Only for controls that draw the MD3 + * ring on a different element, where the browser's would land on the wrong box + * or be clipped. Anywhere else, leaving it alone is the safer default. + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +export const webNoOutline = { outline: 'none' } as unknown as ViewStyle;