diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index e51de006aa..96eaafc2c2 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -209,6 +209,38 @@ The misspelled `traileringIcon` props have been renamed: /> ``` +### Chip + +The close button (`onClose`) now fills the entire trailing 34dp column reserved for it, matching Material Design 3's touch target guidance, instead of only its 24x18 icon. Taps near the top or bottom of that column, which used to fall through to the chip's own `onPress`, now activate `onClose` instead. + +### TouchableRipple + +- `borderless` no longer clips the touchable's own content on web; it only clips the ripple itself, in its own container. A child that needs a clipped or rounded shape should carry that shape itself. +- Corner radius and border width set through `style` no longer shape the highlight underlay (native) or the ripple's self-clipping container (web). Pass them as dedicated props instead: + - `borderRadius` + - `borderTopLeftRadius` + - `borderTopRightRadius` + - `borderBottomLeftRadius` + - `borderBottomRightRadius` + - `borderTopStartRadius` + - `borderTopEndRadius` + - `borderBottomStartRadius` + - `borderBottomEndRadius` + - `borderWidth` (web only) + +e.g.: + +```diff + {}} +> + Content + +``` + ### TextInput The Paper 6.x `TextInput` is a complete rewrite with a new API. Import the component the same way, but note that the props and behavior have changed significantly. diff --git a/eslint.config.mjs b/eslint.config.mjs index ac6e65951f..776d90544b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -221,6 +221,7 @@ export default defineConfig( 'src/components/__tests__/Appbar/Appbar.test.tsx', 'src/components/__tests__/Dialog.test.tsx', 'src/components/__tests__/Searchbar.test.tsx', + 'src/components/__tests__/TouchableRippleWeb.test.tsx', ], rules: { 'testing-library/no-node-access': 'off', diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 6f07113572..4b12466879 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -262,6 +262,10 @@ const Button = ({ }); const touchableStyle = { borderRadius }; + const touchableRippleStyle = getButtonTouchableRippleStyle( + touchableStyle, + borderWidth + ); const { color: customLabelColor, fontSize: customLabelSize } = StyleSheet.flatten(labelStyle) || {}; @@ -334,7 +338,8 @@ const Button = ({ accessible={accessible} hitSlop={hitSlop} disabled={disabled} - style={getButtonTouchableRippleStyle(touchableStyle, borderWidth)} + style={touchableRippleStyle} + {...touchableRippleStyle} testID={testID} theme={theme} ref={touchableRef} diff --git a/src/components/Button/utils.tsx b/src/components/Button/utils.tsx index ec3503c82b..bbebfd7791 100644 --- a/src/components/Button/utils.tsx +++ b/src/components/Button/utils.tsx @@ -4,6 +4,7 @@ import { black, white } from '../../theme/colors'; import { tokens } from '../../theme/tokens'; import type { InternalTheme } from '../../theme/types'; import { splitStyles } from '../../utils/splitStyles'; +import type { BorderRadiusStyle } from '../TouchableRipple/utils'; const stateOpacity = tokens.md.sys.state.opacity; @@ -191,20 +192,7 @@ export const getButtonColors = ({ }; }; -type ViewStyleBorderRadiusStyles = Partial< - Pick< - ViewStyle, - | 'borderBottomEndRadius' - | 'borderBottomLeftRadius' - | 'borderBottomRightRadius' - | 'borderBottomStartRadius' - | 'borderTopEndRadius' - | 'borderTopLeftRadius' - | 'borderTopRightRadius' - | 'borderTopStartRadius' - | 'borderRadius' - > ->; +type ViewStyleBorderRadiusStyles = Partial; export const getButtonTouchableRippleStyle = ( style?: ViewStyle, borderWidth: number = 0 diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index 712110883b..f1d1daed89 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -17,6 +17,7 @@ import { getCardColors } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { Elevation, ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; +import { useFocusRing } from '../../utils/useFocusRing'; import Surface from '../Surface'; import type { SurfaceStyle } from '../Surface'; @@ -148,7 +149,10 @@ const Card = ({ ...rest }: (OutlinedCardProps | ElevatedCardProps | ContainedCardProps) & Props) => { const theme = useInternalTheme(themeOverrides); - + const { target: focusTarget, ring: focusRing } = useFocusRing( + disabled, + theme.colors.secondary + ); const isMode = React.useCallback( (modeToCompare: Mode) => { return cardMode === modeToCompare; @@ -252,6 +256,10 @@ const Card = ({ onPressIn={handlePressIn} onPressOut={handlePressOut} testID={testID} + onFocus={focusTarget.onFocus} + onBlur={focusTarget.onBlur} + {...focusRing.dataSetProps} + style={[{ borderRadius }, ...focusRing.style]} > {content} diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 3bccccf33b..b4216a71a6 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'; @@ -16,9 +14,8 @@ 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 { ThemeProp } from '../../theme/types'; -import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; +import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; @@ -76,13 +73,12 @@ 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` here because the surrounding -// `TouchableRipple borderless` clips overflow to the tap-target shape, -// so a ring drawn outside the 40dp circle would be cropped. -const FOCUS_RING_SIZE = STATE_LAYER_SIZE; -const FOCUS_RING_RADIUS = STATE_LAYER_SIZE / 2; +// The state layer is fixed, so the slop to reach the 48dp minimum +// interactive target is a constant rather than something to measure. +const CHECKBOX_HIT_SLOP = getMinInteractiveSizeHitSlop({ + width: STATE_LAYER_SIZE, + height: STATE_LAYER_SIZE, +}); /** * Checkboxes allow the selection of multiple options from a set. @@ -128,7 +124,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'; @@ -202,19 +197,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'; @@ -238,24 +220,20 @@ const Checkbox = ({ borderless centered onPress={onPress} - onFocus={handleFocus} - onBlur={handleBlur} disabled={disabled} {...accessibilityProps} testID={testID} - style={[ - styles.tapTarget, - Platform.OS === 'web' ? webNoOutline : undefined, - style, - ]} + hitSlop={ + rest.hitSlop !== undefined + ? rest.hitSlop + : disabled + ? undefined + : CHECKBOX_HIT_SLOP + } + borderRadius={STATE_LAYER_SIZE / 2} + style={[styles.tapTarget, style]} > - {focused && !disabled ? ( - - ) : null} & { ref?: React.Ref; }; +/** + * 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. + * + * Matches material-web's own remove button, which expands to a 48px touch + * target the same way; the 24x24 dimensions in its `_trailing-icon.scss` are + * for the ripple and focus ring, not the touch target. + * @see https://github.com/material-components/material-web/blob/main/chips/internal/_shared.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; + +/** + * The container height is fixed by spec, so the slop to reach the 48dp minimum + * is a constant rather than something to measure. Width grows with the label + * and the whole pill is already the target, so only the vertical axis needs it. + */ +const { containerHeight: CHIP_BODY_HEIGHT } = ChipTokens; +const CHIP_BODY_HIT_SLOP = getMinInteractiveSizeHitSlop({ + height: CHIP_BODY_HEIGHT, +}); +// The close button's own box is the same fixed height as the body, so it +// needs the same vertical slop to reach 48dp. +const CLOSE_BUTTON_WEB_TOUCH_TARGET_INSET = CHIP_BODY_HIT_SLOP?.top ?? 0; + /** * Chips are compact elements that can represent inputs, attributes, or actions. * They can have an icon or avatar on the left, and a close button icon on the right. @@ -212,6 +246,14 @@ const Chip = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); + // The close affordance is a plain `Pressable`, not a `TouchableRipple` + // (see below), so it calls `useFocusRing` directly instead of going + // through `TouchableRipple`'s `focusRing` prop like the body does. + const { target: closeFocusTarget, ring: closeFocusRing } = useFocusRing( + disabled, + theme.colors.secondary, + 'inward' + ); const [pressed, setPressed] = React.useState(false); const elevation = elevated ? (pressed ? 2 : 1) : 0; @@ -270,7 +312,7 @@ const Chip = ({ }; const contentSpacings = { - paddingRight: onClose ? 34 : 0, + paddingRight: onClose ? CLOSE_AFFORDANCE_WIDTH : 0, }; const labelTextStyle = { @@ -290,8 +332,10 @@ const Chip = ({ > - + {/* react-native-web removed `hitSlop` in 0.13.0, + so web needs a real element the browser can + hit-test instead of a native responder inset. */} + {Platform.OS === 'web' && !disabled && ( + + )} + {closeIcon ? ( ) : ( @@ -428,6 +496,7 @@ const styles = StyleSheet.create({ }, md3Content: { paddingLeft: 0, + minHeight: CHIP_BODY_HEIGHT, }, icon: { padding: 4, @@ -443,6 +512,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', @@ -473,9 +546,27 @@ 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', + ...(Platform.OS === 'web' && { position: 'relative' }), + }, + closeButtonWebTouchTarget: { + position: 'absolute', + top: -CLOSE_BUTTON_WEB_TOUCH_TARGET_INSET, + bottom: -CLOSE_BUTTON_WEB_TOUCH_TARGET_INSET, + left: 0, + right: 0, }, touchable: { width: '100%', diff --git a/src/components/Chip/tokens.ts b/src/components/Chip/tokens.ts new file mode 100644 index 0000000000..772ef9d0e0 --- /dev/null +++ b/src/components/Chip/tokens.ts @@ -0,0 +1,7 @@ +/** + * MD3 Chip spec dimensions. + * @see https://m3.material.io/components/chips/specs + */ +export const ChipTokens = { + containerHeight: 32, +} as const; diff --git a/src/components/Drawer/DrawerItem.tsx b/src/components/Drawer/DrawerItem.tsx index 50086bbe71..3c97b585bd 100644 --- a/src/components/Drawer/DrawerItem.tsx +++ b/src/components/Drawer/DrawerItem.tsx @@ -129,6 +129,7 @@ const DrawerItem = ({ { backgroundColor, borderRadius }, style, ]} + borderRadius={borderRadius} role="button" aria-selected={active} aria-label={ariaLabel} diff --git a/src/components/FAB/Menu.tsx b/src/components/FAB/Menu.tsx index e4884647d6..eba7d6a969 100644 --- a/src/components/FAB/Menu.tsx +++ b/src/components/FAB/Menu.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Platform, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent } from 'react-native'; import Animated, { @@ -14,15 +14,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Content from './Content'; import Shell from './Shell'; -import { - MenuTokens, - Tokens, - FOCUS_RING_INSET, - FOCUS_RING_THICKNESS, - webNoOutline, -} from './tokens'; +import { MenuTokens, Tokens } from './tokens'; import type { Size, Variant } from './tokens'; -import { useFocusRing } from './useFocusRing'; import { resolveColors } from './utils'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; @@ -30,6 +23,7 @@ import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { toRawSpring } from '../../theme/tokens/sys/motion'; import type { InternalTheme, ThemeProp } from '../../theme/types'; import { resolveCornerRadius } from '../../theme/utils/shape'; +import { useFocusRing } from '../../utils/useFocusRing'; import Icon from '../Icon'; import type { IconSource } from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -249,10 +243,17 @@ const MenuItem = ({ MenuTokens.listItem; const borderRadius = resolveCornerRadius(theme, shape); - const { focusedSV, onFocus, onBlur } = useFocusRing(); - const focusRingStyle = useAnimatedStyle(() => ({ - opacity: focusedSV.value ? 1 : 0, - })); + // `scope: 'within'`: the ring belongs on the pill below, not the inner + // `TouchableRipple` that actually receives focus. + // + // `undefined` disabled: menu items have no `disabled` prop today. Wire the + // real value through here if that ever changes. + const { target: focusTarget, ring: focusRing } = useFocusRing( + undefined, + theme.colors.secondary, + 'outward', + 'within' + ); return ( @@ -260,19 +261,20 @@ const MenuItem = ({ style={[ styles.menuItem, { height, borderRadius, backgroundColor: colors.container }, + ...focusRing.style, ]} + {...focusRing.dataSetProps} > - ); }; @@ -686,15 +678,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 83a71f72ed..749ae59e15 100644 --- a/src/components/FAB/Shell.tsx +++ b/src/components/FAB/Shell.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Platform, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, @@ -17,20 +17,15 @@ 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 { getDimensions, resolveColors } from './utils'; import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { toRawSpring } from '../../theme/tokens/sys/motion'; import type { Elevation, ThemeProp } from '../../theme/types'; import type { ShapeToken } from '../../theme/utils/shape'; +import { useFocusRing } from '../../utils/useFocusRing'; import type { IconSource } from '../Icon'; import Surface from '../Surface'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -297,14 +292,18 @@ const Shell = ({ [borderRadius, containerBg] ); - const { focusedSV, onFocus, onBlur } = useFocusRing(); - - const focusRingStyle = useAnimatedStyle( - () => ({ - opacity: focusedSV.value ? 1 : 0, - borderRadius: borderRadius.value + FOCUS_RING_INSET, - }), - [borderRadius] + // `scope: 'within'`: the ring is drawn on the clip view below, not the + // inner `TouchableRipple` that actually receives focus - `target.style` + // suppresses the browser's own outline there on web, so only the clip + // view's ring shows. + // + // `undefined` disabled: FAB has no `disabled` prop today. Wire the real + // value through here if that ever changes. + const { target: focusTarget, ring: focusRing } = useFocusRing( + undefined, + theme.colors.secondary, + 'outward', + 'within' ); return ( @@ -321,14 +320,18 @@ const Shell = ({ elevation={elevation} theme={theme} > - + {overlay} {children ?? ( - ); }; @@ -389,15 +382,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/IconButton/IconButton.tsx b/src/components/IconButton/IconButton.tsx index 1d673a3d27..4263654f95 100644 --- a/src/components/IconButton/IconButton.tsx +++ b/src/components/IconButton/IconButton.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import type { ColorValue, GestureResponderEvent, @@ -12,6 +12,7 @@ import Animated, { type AnimatedStyle } from 'react-native-reanimated'; import { getIconButtonColor } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; +import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop'; import ActivityIndicator from '../ActivityIndicator'; import CrossFadeIcon from '../CrossFadeIcon'; import Icon from '../Icon'; @@ -67,13 +68,35 @@ export type Props = Omit< 'aria-label'?: string; /** * Style of button's inner content. - * Use this prop to apply custom height and width or to set a custom padding`. + * Use this prop to set a custom padding. For a custom height and width, + * use the `width` and `height` props instead. */ contentStyle?: StyleProp; /** * Function to execute on press. */ onPress?: (e: GestureResponderEvent) => void; + /** + * Width of the button's inner content. Defaults to the button's size. + */ + width?: number; + /** + * Height of the button's inner content. Defaults to the button's size. + */ + height?: number; + /** + * Radius of every corner of the button. Defaults to a circle (half of the + * button's size). + */ + borderRadius?: number; + borderTopLeftRadius?: number; + borderTopRightRadius?: number; + borderBottomLeftRadius?: number; + borderBottomRightRadius?: number; + borderTopStartRadius?: number; + borderTopEndRadius?: number; + borderBottomStartRadius?: number; + borderBottomEndRadius?: number; style?: StyleProp>; ref?: React.Ref; /** @@ -128,6 +151,17 @@ const IconButton = ({ testID, loading = false, contentStyle, + width, + height, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, ref, ...rest }: Props) => { @@ -151,13 +185,36 @@ const IconButton = ({ }); const buttonSize = size + 2 * PADDING; + const borderWidth = mode === 'outlined' && !selected ? 1 : 0; + + const shapeStyles = { + borderRadius: borderRadius ?? buttonSize / 2, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, + }; const borderStyles = { - borderWidth: mode === 'outlined' && !selected ? 1 : 0, - borderRadius: buttonSize / 2, + borderWidth, borderColor, + ...shapeStyles, }; + const touchableWidth = width ?? buttonSize - 2 * borderWidth; + const touchableHeight = height ?? buttonSize - 2 * borderWidth; + + const hitSlop = disabled + ? undefined + : getMinInteractiveSizeHitSlop({ + width: touchableWidth, + height: touchableHeight, + }); + return ( )} @@ -186,16 +244,22 @@ const IconButton = ({ centered onPress={onPress} aria-label={ariaLabel} - style={[styles.touchable, contentStyle]} + style={[ + styles.touchable, + 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. + Platform.OS !== 'web' && styles.clipToShape, + { width, height }, + contentStyle, + ]} + {...shapeStyles} 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} + hitSlop={hitSlop} {...rest} > @@ -213,13 +277,15 @@ const IconButton = ({ const styles = StyleSheet.create({ container: { margin: 6, - overflow: 'hidden', }, touchable: { flexGrow: 1, justifyContent: 'center', alignItems: 'center', }, + clipToShape: { + overflow: 'hidden', + }, }); export default IconButton; diff --git a/src/components/List/ListItem.tsx b/src/components/List/ListItem.tsx index feb0f19f12..73d4861e97 100644 --- a/src/components/List/ListItem.tsx +++ b/src/components/List/ListItem.tsx @@ -233,6 +233,7 @@ const ListItem = ({ , 'children' @@ -146,6 +157,14 @@ const RadioButtonAndroid = ({ style={styles.container} testID={testID} theme={theme} + hitSlop={ + rest.hitSlop !== undefined + ? rest.hitSlop + : disabled + ? undefined + : RADIO_BUTTON_HIT_SLOP + } + borderRadius={STATE_LAYER_SIZE / 2} > , 'children' @@ -104,12 +116,20 @@ const RadioButtonIOS = ({ style={styles.container} testID={testID} theme={theme} + hitSlop={ + rest.hitSlop !== undefined + ? rest.hitSlop + : disabled + ? undefined + : RADIO_BUTTON_HIT_SLOP + } + borderRadius={STATE_LAYER_SIZE / 2} > @@ -125,8 +145,8 @@ RadioButtonIOS.displayName = 'RadioButton.IOS'; const styles = StyleSheet.create({ container: { - borderRadius: 18, - padding: 6, + borderRadius: STATE_LAYER_SIZE / 2, + padding: (STATE_LAYER_SIZE - CHECKMARK_SIZE) / 2, }, }); diff --git a/src/components/RadioButton/RadioButtonItem.tsx b/src/components/RadioButton/RadioButtonItem.tsx index b987f7f0e7..f561dc17ae 100644 --- a/src/components/RadioButton/RadioButtonItem.tsx +++ b/src/components/RadioButton/RadioButtonItem.tsx @@ -195,6 +195,7 @@ const RadioButtonItem = ({ }) === 'checked'; return ( handlePress({ onPress: onPress, diff --git a/src/components/RadioButton/tokens.ts b/src/components/RadioButton/tokens.ts new file mode 100644 index 0000000000..a3abdfe3e0 --- /dev/null +++ b/src/components/RadioButton/tokens.ts @@ -0,0 +1,7 @@ +/** + * MD3 Radio button spec dimensions. + * @see https://m3.material.io/components/radio-button/specs + */ +export const RadioButtonTokens = { + stateLayerSize: 40, +} as const; diff --git a/src/components/SegmentedButtons/SegmentedButtonItem.tsx b/src/components/SegmentedButtons/SegmentedButtonItem.tsx index 3b9962a457..0f5bada625 100644 --- a/src/components/SegmentedButtons/SegmentedButtonItem.tsx +++ b/src/components/SegmentedButtons/SegmentedButtonItem.tsx @@ -20,9 +20,11 @@ import { getSegmentedButtonColors, getSegmentedButtonDensityPadding, } from './utils'; +import type { SegmentBorderRadiusStyle } from './utils'; import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import type { ThemeProp } from '../../theme/types'; +import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop'; import type { IconSource } from '../Icon'; import Icon from '../Icon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -172,6 +174,7 @@ const SegmentedButtonItem = ({ const segmentBorderRadius = getSegmentedButtonBorderRadius({ theme, segment, + borderRadius, }); const showIcon = !icon ? false : label && checked ? !showSelectedCheck : true; @@ -187,16 +190,19 @@ const SegmentedButtonItem = ({ backgroundColor, borderColor, borderWidth, - borderRadius, ...segmentBorderRadius, }; const paddingVertical = getSegmentedButtonDensityPadding({ density }); + const contentHeight = 2 * paddingVertical + iconSize; + const defaultHitSlop = disabled + ? undefined + : getMinInteractiveSizeHitSlop({ height: contentHeight }); - const rippleStyle: ViewStyle = { - borderRadius, + const rippleStyle: SegmentBorderRadiusStyle = { ...segmentBorderRadius, }; + const { borderEndWidth: _borderEndWidth, ...touchableShape } = rippleStyle; const labelTextStyle: TextStyle = { ...theme.fonts.labelLarge, @@ -207,6 +213,7 @@ const SegmentedButtonItem = ({ & { + borderEndWidth?: number; +}; + export const getSegmentedButtonBorderRadius = ({ segment, + borderRadius, }: { theme: InternalTheme; segment?: 'first' | 'last'; -}): ViewStyle => { + borderRadius: number; +}): SegmentBorderRadiusStyle => { if (segment === 'first') { return { + borderRadius, borderTopRightRadius: 0, borderBottomRightRadius: 0, borderEndWidth: 0, }; } else if (segment === 'last') { return { + borderRadius, borderTopLeftRadius: 0, borderBottomLeftRadius: 0, }; diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx index 2c86f8d963..bcc59cad29 100644 --- a/src/components/Switch/Switch.tsx +++ b/src/components/Switch/Switch.tsx @@ -30,7 +30,9 @@ import { tokens } from '../../theme/tokens'; import { toRawSpring } from '../../theme/tokens/sys/motion'; import { cornerFull } from '../../theme/tokens/sys/shape'; import type { StateOpacityKey, ThemeProp } from '../../theme/types'; +import getMinInteractiveSizeHitSlop from '../../utils/getMinInteractiveSizeHitSlop'; import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; +import { useFocusRing } from '../../utils/useFocusRing'; import Icon, { type IconSource } from '../Icon'; export type Props = { @@ -83,10 +85,13 @@ const { const { state: stateTokens } = tokens.md.sys; const stateOpacity = stateTokens.opacity; -const { thickness: FOCUS_THICKNESS, outerOffset: FOCUS_OUTER_OFFSET } = - stateTokens.focusIndicator; -const FOCUS_RING_INSET = -(FOCUS_OUTER_OFFSET + FOCUS_THICKNESS); -const OVERLAY_TOP = (STATE_LAYER_SIZE - TRACK_HEIGHT) / 2; + +// The state layer is fixed size, so the slop to reach the 48dp minimum +// interactive target is a constant rather than something to measure. +const SWITCH_HIT_SLOP = getMinInteractiveSizeHitSlop({ + height: STATE_LAYER_SIZE, +}); +const SWITCH_HIT_SLOP_INSET = SWITCH_HIT_SLOP?.top ?? 0; // Hold-then-grow: a brief delay before snapping to PRESSED_HANDLE so a quick // tap doesn't flash the press-grow visual. @@ -154,6 +159,12 @@ const Switch = ({ const pressedSV = useSharedValue(0); const hoveredSV = useSharedValue(0); const focusedSV = useSharedValue(0); + + React.useEffect(() => { + 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); @@ -166,6 +177,16 @@ const Switch = ({ const colors = React.useMemo(() => getDefaultSwitchColors(theme), [theme]); + // `scope: 'within'`: the ring is drawn on the track below, not this + // Pressable - it also suppresses the browser's own outline here on web, so + // only the track's ring shows. + const { target: focusTarget, ring: focusRing } = useFocusRing( + isDisabled, + colors.focusIndicatorColor, + 'outward', + 'within' + ); + const reanimatedReduceMotion = reduceMotion ? ReduceMotion.Always : ReduceMotion.Never; @@ -323,10 +344,6 @@ const Switch = ({ ], })); - const focusRingAnimatedStyle = useAnimatedStyle(() => ({ - opacity: focusedSV.value, - })); - const paint = resolveSwitchPaint(colors, isEnabled, checked); const stateLayerColor = checked ? colors.checkedStateLayerColor @@ -363,10 +380,14 @@ const Switch = ({ hoveredSV.value = 0; }} onFocus={(e) => { - if (!isKeyboardFocusEvent(e)) return; - focusedSV.value = 1; + // Not the ring - it's real CSS on web now. This drives the + // handle's own separate focused-visual animation, native and web + // alike, so it keeps its own keyboard-vs-pointer check. + focusTarget.onFocus?.(e); + if (!isDisabled && isKeyboardFocusEvent(e)) focusedSV.value = 1; }} onBlur={() => { + focusTarget.onBlur?.(); focusedSV.value = 0; }} android_ripple={{ color: 'transparent' }} @@ -375,16 +396,26 @@ const Switch = ({ aria-checked={checked} aria-label={ariaLabel} testID={testID} - style={[ - styles.touchable, - Platform.OS === 'web' ? webNoOutline : undefined, - ]} + hitSlop={isDisabled ? undefined : SWITCH_HIT_SLOP} + style={[styles.touchable, ...focusTarget.style]} > + {/* react-native-web removed `hitSlop` in 0.13.0 (same as + TouchableRipple), so web needs a real element the browser can + hit-test instead of a native responder inset. */} + {Platform.OS === 'web' && !isDisabled && ( + + )} {showOutline ? ( @@ -446,22 +477,6 @@ const Switch = ({ ) : null} - - ); }; @@ -479,6 +494,14 @@ const styles = StyleSheet.create({ height: STATE_LAYER_SIZE, alignItems: 'center', justifyContent: 'center', + ...(Platform.OS === 'web' && { position: 'relative' }), + }, + webTouchTarget: { + position: 'absolute', + top: -SWITCH_HIT_SLOP_INSET, + bottom: -SWITCH_HIT_SLOP_INSET, + left: 0, + right: 0, }, track: { width: TRACK_WIDTH, @@ -525,10 +548,6 @@ const styles = StyleSheet.create({ height: SELECTED_ICON, pointerEvents: 'none', }, - focusRing: { - position: 'absolute', - pointerEvents: 'none', - }, absoluteFill: { position: 'absolute', top: 0, @@ -538,8 +557,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 ec7b13dd91..4671119a15 100644 --- a/src/components/TouchableRipple/TouchableRipple.native.tsx +++ b/src/components/TouchableRipple/TouchableRipple.native.tsx @@ -6,6 +6,8 @@ import type { ViewStyle, GestureResponderEvent, ColorValue, + NativeSyntheticEvent, + TargetedEvent, } from 'react-native'; import type { PressableProps } from './Pressable'; @@ -16,6 +18,8 @@ import type { Settings } from '../../core/settings'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; +import type { FocusRingPlacement } from '../../utils/useFocusRing'; +import { useFocusRing } from '../../utils/useFocusRing'; const ANDROID_VERSION_LOLLIPOP = 21; const ANDROID_VERSION_PIE = 28; @@ -25,6 +29,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 today - see `useFocusRing`'s doc comment for why + * (`enableImperativeFocus`, off by default). + */ + focusRing?: FocusRingPlacement; onPress?: (e: GestureResponderEvent) => void | null; onLongPress?: (e: GestureResponderEvent) => void; onPressIn?: (e: GestureResponderEvent) => void; @@ -35,6 +51,21 @@ export type Props = PressableProps & { style?: StyleProp; ref?: React.Ref; theme?: ThemeProp; + borderRadius?: number; + borderTopLeftRadius?: number; + borderTopRightRadius?: number; + borderBottomLeftRadius?: number; + borderBottomRightRadius?: number; + borderTopStartRadius?: number; + borderTopEndRadius?: number; + borderBottomStartRadius?: number; + borderBottomEndRadius?: number; + /** + * Web-only: widens the touch target back out past the touchable's own + * border. Accepted here too so both platforms share one `Props` type; has + * no effect on native, where `hitSlop` isn't offset from inside the border. + */ + borderWidth?: number; }; const TouchableRipple = ({ @@ -46,9 +77,36 @@ const TouchableRipple = ({ underlayColor, children, theme: themeOverrides, + hitSlop, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, + // consumed so it does not reach the underlying Pressable; web-only, no + // native effect + borderWidth: _borderWidth, + focusRing = 'outward', + onFocus, + onBlur, ref, ...rest }: Props) => { + const underlayShape: ViewStyle = { + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, + }; const theme = useInternalTheme(themeOverrides); const { rippleEffectEnabled } = React.useContext(SettingsContext); @@ -63,6 +121,23 @@ const TouchableRipple = ({ const disabled = disabledProp || !hasPassedTouchHandler; + // Keyed off `disabledProp`, not `disabled`: the latter also folds in + // "no press handler passed", which is a non-interactivity signal, not a + // disabled one - the ring should only react to real disablement. + const { target, ring } = useFocusRing( + disabledProp, + theme.colors.secondary, + focusRing + ); + const handleFocus = (e: NativeSyntheticEvent) => { + onFocus?.(e); + target.onFocus?.(e); + }; + const handleBlur = (e: NativeSyntheticEvent) => { + onBlur?.(e); + target.onBlur?.(); + }; + const { calculatedRippleColor, calculatedUnderlayColor } = getTouchableRippleColors({ theme, @@ -92,7 +167,10 @@ const TouchableRipple = ({ {...rest} ref={ref} disabled={disabled} - style={[useForeground && styles.overflowHidden, style]} + hitSlop={hitSlop} + onFocus={handleFocus} + onBlur={handleBlur} + style={[useForeground && styles.overflowHidden, style, ...ring.style]} android_ripple={androidRipple} > {React.Children.only(children)} @@ -105,7 +183,10 @@ const TouchableRipple = ({ {...rest} ref={ref} disabled={disabled} - style={[borderless && styles.overflowHidden, style]} + hitSlop={hitSlop} + onFocus={handleFocus} + onBlur={handleBlur} + style={[borderless && styles.overflowHidden, style, ...ring.style]} > {({ pressed }) => ( <> @@ -113,6 +194,7 @@ const TouchableRipple = ({ diff --git a/src/components/TouchableRipple/TouchableRipple.tsx b/src/components/TouchableRipple/TouchableRipple.tsx index 3b98d252c7..f0ca666f2f 100644 --- a/src/components/TouchableRipple/TouchableRipple.tsx +++ b/src/components/TouchableRipple/TouchableRipple.tsx @@ -17,10 +17,17 @@ import type { Settings } from '../../core/settings'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; import hasTouchHandler from '../../utils/hasTouchHandler'; +import type { FocusRingPlacement } from '../../utils/useFocusRing'; +import { useFocusRing } from '../../utils/useFocusRing'; export type Props = PressableProps & { /** * Whether to render the ripple outside the view bounds. + * + * On web the ripple is bounded by its own container regardless of this prop. + * The touchable never clips its content, since clipping would also clip the + * touch target, so children needing a rounded shape must carry the radius + * themselves. */ borderless?: boolean; /** @@ -36,6 +43,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 today - see `useFocusRing`'s doc comment for why + * (`enableImperativeFocus`, off by default). + */ + focusRing?: FocusRingPlacement; /** * Function to execute on press. If not set, will cause the touchable to be disabled. */ @@ -75,6 +94,26 @@ export type Props = PressableProps & { * @optional */ theme?: ThemeProp; + /** + * Radius of every corner of the touchable. Native-only: it shapes the + * highlight underlay there. On web the ripple container clips itself + * regardless, so this has no effect. + */ + borderRadius?: number; + borderTopLeftRadius?: number; + borderTopRightRadius?: number; + borderBottomLeftRadius?: number; + borderBottomRightRadius?: number; + borderTopStartRadius?: number; + borderTopEndRadius?: number; + borderBottomStartRadius?: number; + borderBottomEndRadius?: number; + /** + * Width of the touchable's own border, if it draws one. Web-only: the touch + * target's absolute offsets start inside the border, not the visible outer + * edge, so this widens the target back out to it. + */ + borderWidth?: number; }; /** @@ -105,12 +144,27 @@ 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, + // consumed so they do not reach the DOM; native-only, the ripple container + // clips itself regardless of shape on web + borderRadius: _borderRadius, + borderTopLeftRadius: _borderTopLeftRadius, + borderTopRightRadius: _borderTopRightRadius, + borderBottomLeftRadius: _borderBottomLeftRadius, + borderBottomRightRadius: _borderBottomRightRadius, + borderTopStartRadius: _borderTopStartRadius, + borderTopEndRadius: _borderTopEndRadius, + borderBottomStartRadius: _borderBottomStartRadius, + borderBottomEndRadius: _borderBottomEndRadius, + borderWidth, + focusRing = 'outward', ref, ...rest }: Props) => { @@ -178,7 +232,7 @@ const TouchableRipple = ({ borderTopRightRadius: style.borderTopRightRadius, borderBottomRightRadius: style.borderBottomRightRadius, borderBottomLeftRadius: style.borderBottomLeftRadius, - overflow: centered ? 'visible' : 'hidden', + overflow: 'hidden', }); // Create span to show the ripple effect @@ -273,6 +327,20 @@ const TouchableRipple = ({ const disabled = disabledProp || !hasPassedTouchHandler; + // No JS focus tracking here: the ring is real CSS, driven by the browser's + // own `:focus-visible`, keyed off the `data-focus-ring` attribute spread + // below. `onFocus`/`onBlur` reach the caller unmodified via `rest`, nothing + // to intercept. + // + // Keyed off `disabledProp`, not `disabled`: the latter also folds in + // "no press handler passed", which is a non-interactivity signal, not a + // disabled one - the ring should only react to real disablement. + const { ring } = useFocusRing( + disabledProp, + theme.colors.secondary, + focusRing + ); + return ( [ styles.touchable, - borderless && styles.borderless, - // 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, which is + // exactly the distinction `:focus-visible` exists to make. + // https://github.com/necolas/react-native-web/issues/1849 state.hovered && { backgroundColor: hoverColor }, disabled && styles.disabled, typeof style === 'function' ? style(state) : style, + ...ring.style, ]} > - {(state) => - React.Children.only( - typeof children === 'function' ? children(state) : children - ) - } + {(state) => { + const border = borderWidth ?? 0; + const inset = (value: number | undefined) => -((value ?? 0) + border); + + const touchTargetStyle: ViewStyle | undefined = + hitSlop == null + ? undefined + : 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), + }; + + return ( + <> + {!disabled && touchTargetStyle && ( + + )} + {React.Children.only( + typeof children === 'function' ? children(state) : children + )} + + ); + }} ); }; @@ -317,9 +416,6 @@ const styles = StyleSheet.create({ cursor: 'auto', }), }, - borderless: { - overflow: 'hidden', - }, }); export default TouchableRipple; diff --git a/src/components/TouchableRipple/utils.ts b/src/components/TouchableRipple/utils.ts index 2874eafc86..e81a5036f5 100644 --- a/src/components/TouchableRipple/utils.ts +++ b/src/components/TouchableRipple/utils.ts @@ -2,6 +2,18 @@ import type { ColorValue } from 'react-native'; import type { InternalTheme } from '../../theme/types'; +export type BorderRadiusStyle = { + borderRadius?: number; + borderTopLeftRadius?: number; + borderTopRightRadius?: number; + borderBottomLeftRadius?: number; + borderBottomRightRadius?: number; + borderTopStartRadius?: number; + borderTopEndRadius?: number; + borderBottomStartRadius?: number; + borderBottomEndRadius?: number; +}; + const getUnderlayColor = ({ calculatedRippleColor, underlayColor, diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index faeb89916b..532ee99c82 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -194,7 +194,6 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -202,8 +201,16 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -235,10 +242,10 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -262,6 +269,24 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -360,7 +385,6 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -368,8 +392,16 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -401,10 +433,10 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -428,6 +460,24 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -582,7 +632,6 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -590,8 +639,16 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -623,10 +680,10 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -650,6 +707,24 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -805,7 +880,6 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -813,8 +887,16 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -845,10 +927,10 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -872,6 +954,24 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1062,7 +1162,6 @@ exports[`AppbarAction should be rendered with custom color 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1070,8 +1169,16 @@ exports[`AppbarAction should be rendered with custom color 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1102,10 +1209,10 @@ exports[`AppbarAction should be rendered with custom color 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -1129,6 +1236,24 @@ exports[`AppbarAction should be rendered with custom color 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1320,7 +1445,6 @@ exports[`AppbarAction should be rendered with default theme color 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1328,8 +1452,16 @@ exports[`AppbarAction should be rendered with default theme color 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1360,10 +1492,10 @@ exports[`AppbarAction should be rendered with default theme color 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -1387,6 +1519,24 @@ exports[`AppbarAction should be rendered with default theme color 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1578,7 +1728,6 @@ exports[`AppbarAction should be rendered with specific theme color if is leading [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1586,8 +1735,16 @@ exports[`AppbarAction should be rendered with specific theme color if is leading "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1618,10 +1775,10 @@ exports[`AppbarAction should be rendered with specific theme color if is leading focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -1645,6 +1802,24 @@ exports[`AppbarAction should be rendered with specific theme color if is leading "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1836,7 +2011,6 @@ exports[`AppbarAction should render AppbarBackAction with custom color 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1844,8 +2018,16 @@ exports[`AppbarAction should render AppbarBackAction with custom color 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, undefined, @@ -1877,10 +2059,10 @@ exports[`AppbarAction should render AppbarBackAction with custom color 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } onBlur={[Function]} @@ -1904,6 +2086,24 @@ exports[`AppbarAction should render AppbarBackAction with custom color 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] diff --git a/src/components/__tests__/Checkbox/Checkbox.test.tsx b/src/components/__tests__/Checkbox/Checkbox.test.tsx index a72200bb4e..cf14dda3ab 100644 --- a/src/components/__tests__/Checkbox/Checkbox.test.tsx +++ b/src/components/__tests__/Checkbox/Checkbox.test.tsx @@ -58,3 +58,11 @@ it('renders Checkbox with custom testID', async () => { expect(tree).toMatchSnapshot(); }); + +it('renders disabled Checkbox', async () => { + const tree = ( + await render() + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index 202a95467a..3ff396ce0c 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -24,6 +24,14 @@ exports[`renders Checkbox with custom testID 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -48,7 +56,6 @@ exports[`renders Checkbox with custom testID 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -209,6 +216,14 @@ exports[`renders checked Checkbox with color 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -233,7 +248,6 @@ exports[`renders checked Checkbox with color 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -393,6 +407,14 @@ exports[`renders checked Checkbox with onPress 1`] = ` centered={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -417,7 +439,6 @@ exports[`renders checked Checkbox with onPress 1`] = ` "width": 40, }, undefined, - undefined, ], ] } @@ -553,6 +574,189 @@ exports[`renders checked Checkbox with onPress 1`] = ` `; +exports[`renders disabled Checkbox 1`] = ` + + + + + + + + + + + + + +`; + exports[`renders indeterminate Checkbox 1`] = ` { }); }); }); + +describe('close affordance', () => { + 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 + + ); + + 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__/IconButton.test.tsx b/src/components/__tests__/IconButton.test.tsx index 914f1e2c55..d735ae65aa 100644 --- a/src/components/__tests__/IconButton.test.tsx +++ b/src/components/__tests__/IconButton.test.tsx @@ -2,7 +2,7 @@ import { StyleSheet } from 'react-native'; import { describe, expect, it } from '@jest/globals'; -import { render } from '../../test-utils'; +import { render, screen } from '../../test-utils'; import { pink500 } from '../../theme/colors'; import { LightTheme } from '../../theme/schemes'; import { tokens } from '../../theme/tokens'; @@ -46,6 +46,30 @@ it('renders disabled icon button', async () => { expect(tree).toMatchSnapshot(); }); +it('lets a caller-supplied hitSlop win even while disabled', async () => { + const tree = ( + await render() + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('computes hitSlop from explicit width/height rather than the button size', async () => { + const tree = ( + await render() + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('drops hitSlop when explicit width/height already meet the 48dp minimum', async () => { + const tree = ( + await render() + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + it('renders icon change animated', async () => { const tree = (await render()).toJSON(); @@ -80,6 +104,24 @@ it('renders icon button with small border radius', async () => { expect(toJSON()).toMatchSnapshot(); }); +it('clips to a custom corner radius', async () => { + await render( + {}} + borderTopLeftRadius={0} + /> + ); + + // 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__/RadioButton/RadioButton.test.tsx b/src/components/__tests__/RadioButton/RadioButton.test.tsx index da8a04375d..c4f46d5234 100644 --- a/src/components/__tests__/RadioButton/RadioButton.test.tsx +++ b/src/components/__tests__/RadioButton/RadioButton.test.tsx @@ -82,4 +82,12 @@ describe('RadioButton', () => { expect(tree).toMatchSnapshot(); }); }); + + it('renders disabled RadioButton', async () => { + const tree = ( + await render() + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); }); diff --git a/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap b/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap index c20910f20e..c0514eea95 100644 --- a/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap +++ b/src/components/__tests__/RadioButton/__snapshots__/RadioButton.test.tsx.snap @@ -23,6 +23,14 @@ exports[`RadioButton RadioButton with custom testID renders properly 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -39,8 +47,8 @@ exports[`RadioButton RadioButton with custom testID renders properly 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } @@ -110,6 +118,14 @@ exports[`RadioButton on default platform renders properly 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -126,8 +142,8 @@ exports[`RadioButton on default platform renders properly 1`] = ` "overflow": "hidden", }, { - "borderRadius": 18, - "padding": 6, + "borderRadius": 20, + "padding": 8, }, ] } @@ -196,6 +212,100 @@ exports[`RadioButton on ios platform renders properly 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + role="radio" + style={ + [ + { + "overflow": "hidden", + }, + { + "borderRadius": 20, + "padding": 8, + }, + ] + } +> + + + check + + + +`; + +exports[`RadioButton renders disabled RadioButton 1`] = ` + { expect(toJSON()).toMatchSnapshot(); }); + + it('takes the shape of the touchable so it does not square off the corners', async () => { + const { toJSON } = await render( + + Press me! + + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('takes per-corner radii too', async () => { + const { toJSON } = await render( + + Press me! + + ); + + expect(toJSON()).toMatchSnapshot(); + }); + }); + + describe('hitSlop', () => { + it('does not add its own hitSlop when none is supplied', async () => { + const tree = ( + await render( + {}}> + Button + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('passes a caller-supplied hitSlop straight through', async () => { + const tree = ( + await render( + {}} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + Button + + ) + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('still calls a caller-supplied onLayout', async () => { + const onLayout = jest.fn(); + await render( + {}} + onLayout={onLayout} + > + Button + + ); + + await act(async () => { + await fireEvent(screen.getByTestId('touchable'), 'layout', { + nativeEvent: { layout: { width: 32, height: 32, x: 0, y: 0 } }, + }); + }); + + expect(onLayout).toHaveBeenCalledTimes(1); + }); + }); +}); + +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..99c5a7d107 --- /dev/null +++ b/src/components/__tests__/TouchableRippleFocusWeb.test.tsx @@ -0,0 +1,97 @@ +import { Platform, Text } from 'react-native'; +import type { ViewStyle } 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'; +// 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'; + +// There is no DOM in this repo's Jest, so there is no real `:focus-visible` to +// fire - the mechanism is now the browser's, not this library's. These prove +// the wiring instead: the right `data-focus-ring[-within]` attribute and CSS +// colour variable land on the rendered element for a given `focusRing` prop. +// Real focus behaviour is a manual, browser-only check (see the PR +// description). +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('emits data-focus-ring="outward" and the secondary colour by default', async () => { + await renderRipple(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('ripple').props.dataSet).toEqual({ + focusRing: 'outward', + }); + expect(screen.getByTestId('ripple')).toHaveStyle( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + { + ['--rnp-focus-ring-color']: 'rgba(98, 91, 113, 1)', + } as unknown as ViewStyle + ); + }); + + it('emits data-focus-ring="inward" when asked', async () => { + await renderRipple({ focusRing: 'inward' }); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('ripple').props.dataSet).toEqual({ + focusRing: 'inward', + }); + }); + + it('emits no attribute when the ring is turned off', async () => { + await renderRipple({ focusRing: 'none' }); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('ripple').props.dataSet).toBeUndefined(); + }); + + it('emits no attribute when disabled', async () => { + await renderRipple({ disabled: true }); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('ripple').props.dataSet).toBeUndefined(); + }); + + // Not reference equality: RN's own `Pressable` wraps the handler it is + // given internally, on every platform, ring or no ring. What matters here + // is that this library stops doing its own extra wrapping around it. + it('still calls a caller onFocus and onBlur', async () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + await renderRipple({ onFocus, onBlur }); + + await act(async () => { + await fireEvent(screen.getByTestId('ripple'), 'focus'); + }); + await act(async () => { + await fireEvent(screen.getByTestId('ripple'), 'blur'); + }); + + expect(onFocus).toHaveBeenCalled(); + expect(onBlur).toHaveBeenCalled(); + }); +}); diff --git a/src/components/__tests__/TouchableRippleWeb.test.tsx b/src/components/__tests__/TouchableRippleWeb.test.tsx new file mode 100644 index 0000000000..c62251909f --- /dev/null +++ b/src/components/__tests__/TouchableRippleWeb.test.tsx @@ -0,0 +1,149 @@ +import { Text } from 'react-native'; + +import { describe, expect, it } from '@jest/globals'; +import type { TestInstance } from 'test-renderer'; + +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 TOUCHABLE = 'touchable'; + +const asElement = (node: TestInstance | string): TestInstance => { + if (typeof node === 'string') { + throw new Error('Expected an element, not a text node'); + } + return node; +}; + +const getTarget = () => { + const children = screen.getByTestId(TOUCHABLE).children; + return ( + children.find( + (child): child is TestInstance => + typeof child !== 'string' && + // eslint-disable-next-line no-restricted-syntax + !!child.props['aria-hidden'] + ) ?? null + ); +}; + +const requireTarget = () => { + const target = getTarget(); + if (target === null) { + throw new Error('Expected a touch target to be rendered'); + } + return target; +}; + +const styleOf = (node: TestInstance) => { + // eslint-disable-next-line no-restricted-syntax + const { style } = node.props; + return Array.isArray(style) ? Object.assign({}, ...style.flat()) : style; +}; + +describe('TouchableRipple (web)', () => { + it.each([ + { when: 'there is no hitSlop', props: { onPress: () => {} } }, + { when: 'there are no touch handlers', props: { hitSlop: 4 } }, + { + when: 'it is disabled', + props: { hitSlop: 4, onPress: () => {}, disabled: true }, + }, + ])('does not render a touch target when $when', async ({ props }) => { + await render( + + Button + + ); + + expect(getTarget()).toBeNull(); + }); + + it('renders the touch target before the children so it cannot cover them', async () => { + await render( + {}} testID={TOUCHABLE}> + child-marker + + ); + + const [first, second] = screen + .getByTestId(TOUCHABLE) + .children.map(asElement); + + // eslint-disable-next-line no-restricted-syntax + expect(first.props['aria-hidden']).toBe(true); + // eslint-disable-next-line no-restricted-syntax + expect(second.props.children).toBe('child-marker'); + }); + + it.each([ + { + name: 'sizes the target from a numeric hitSlop', + hitSlop: 6 as const, + expected: { top: -6, bottom: -6, left: -6, right: -6 }, + }, + { + name: 'sizes the target from a per-edge hitSlop, defaulting unset edges to zero', + hitSlop: { top: 4, left: 8 }, + expected: { top: -4, bottom: -0, left: -8, right: -0 }, + }, + ])('$name', async ({ hitSlop, expected }) => { + await render( + {}} testID={TOUCHABLE}> + Button + + ); + + expect(styleOf(requireTarget())).toEqual({ + position: 'absolute', + ...expected, + }); + }); + + it('extends the target past a border, since absolute offsets start inside it', async () => { + await render( + {}} + testID={TOUCHABLE} + > + Button + + ); + + expect(styleOf(requireTarget())).toEqual({ + position: 'absolute', + top: -6, + bottom: -6, + left: -6, + right: -6, + }); + }); + + it('no longer clips the touchable itself, which would clip the target', async () => { + await render( + {}} testID={TOUCHABLE}> + Button + + ); + + const style = styleOf(screen.getByTestId(TOUCHABLE)); + + expect(style).toMatchObject({ position: 'relative' }); + expect(style.overflow).toBeUndefined(); + }); +}); diff --git a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap index 04687e301f..439e667b0c 100644 --- a/src/components/__tests__/__snapshots__/Chip.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Chip.test.tsx.snap @@ -118,6 +118,14 @@ exports[`renders chip with close button 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 8, + "left": 0, + "right": 0, + "top": 8, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -154,6 +162,7 @@ exports[`renders chip with close button 1`] = ` "position": "relative", }, { + "minHeight": 32, "paddingLeft": 0, }, { @@ -260,11 +269,12 @@ exports[`renders chip with close button 1`] = ` @@ -290,6 +300,14 @@ exports[`renders chip with close button 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 8, + "left": 0, + "right": 0, + "top": 8, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -300,6 +318,18 @@ exports[`renders chip with close button 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} role="button" + style={ + [ + { + "height": "100%", + "justifyContent": "center", + "width": "100%", + }, + { + "borderRadius": 8, + }, + ] + } > @@ -643,6 +684,14 @@ exports[`renders chip with custom close button 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 8, + "left": 0, + "right": 0, + "top": 8, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -653,6 +702,18 @@ exports[`renders chip with custom close button 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} role="button" + style={ + [ + { + "height": "100%", + "justifyContent": "center", + "width": "100%", + }, + { + "borderRadius": 8, + }, + ] + } > - `; @@ -395,7 +371,6 @@ exports[`renders FAB medium size 1`] = ` }, [ null, - null, ], ] } @@ -452,29 +427,6 @@ exports[`renders FAB medium size 1`] = ` - `; @@ -634,7 +586,6 @@ exports[`renders FAB transitioning to not visible 1`] = ` }, [ null, - null, ], ] } @@ -691,29 +642,6 @@ exports[`renders FAB transitioning to not visible 1`] = ` - `; @@ -873,7 +801,6 @@ exports[`renders FAB transitioning to visible 1`] = ` }, [ null, - null, ], ] } @@ -930,29 +857,6 @@ exports[`renders FAB transitioning to visible 1`] = ` - `; @@ -1113,7 +1017,6 @@ exports[`renders FAB with aria-label 1`] = ` }, [ null, - null, ], ] } @@ -1170,29 +1073,6 @@ exports[`renders FAB with aria-label 1`] = ` - `; @@ -1352,7 +1232,6 @@ exports[`renders FAB with containerColor and contentColor overrides 1`] = ` }, [ null, - null, ], ] } @@ -1409,29 +1288,6 @@ exports[`renders FAB with containerColor and contentColor overrides 1`] = ` - `; @@ -1591,7 +1447,6 @@ exports[`renders FAB with containerColor override 1`] = ` }, [ null, - null, ], ] } @@ -1648,29 +1503,6 @@ exports[`renders FAB with containerColor override 1`] = ` - `; @@ -1830,7 +1662,6 @@ exports[`renders FAB with default props 1`] = ` }, [ null, - null, ], ] } @@ -1887,29 +1718,6 @@ exports[`renders FAB with default props 1`] = ` - `; @@ -2069,7 +1877,6 @@ exports[`renders FAB with primary variant 1`] = ` }, [ null, - null, ], ] } @@ -2126,29 +1933,6 @@ exports[`renders FAB with primary variant 1`] = ` - `; @@ -2308,7 +2092,6 @@ exports[`renders FAB with secondary variant 1`] = ` }, [ null, - null, ], ] } @@ -2365,29 +2148,6 @@ exports[`renders FAB with secondary variant 1`] = ` - `; @@ -2547,7 +2307,6 @@ exports[`renders FAB with tertiary variant 1`] = ` }, [ null, - null, ], ] } @@ -2604,29 +2363,6 @@ exports[`renders FAB with tertiary variant 1`] = ` - `; @@ -2786,7 +2522,6 @@ exports[`renders FAB with tonalSecondary variant 1`] = ` }, [ null, - null, ], ] } @@ -2843,29 +2578,6 @@ exports[`renders FAB with tonalSecondary variant 1`] = ` - `; @@ -3025,7 +2737,6 @@ exports[`renders FAB with tonalTertiary variant 1`] = ` }, [ null, - null, ], ] } @@ -3082,28 +2793,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 644a640c5a..8cf2e644d1 100644 --- a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap @@ -158,7 +158,6 @@ exports[`renders extended FAB collapsed 1`] = ` }, [ null, - null, ], ] } @@ -257,29 +256,6 @@ exports[`renders extended FAB collapsed 1`] = ` - - - - - - - - @@ -622,7 +574,6 @@ exports[`renders FAB.Menu closed 1`] = ` { "flex": 1, }, - null, ], ] } @@ -748,29 +699,6 @@ exports[`renders FAB.Menu closed 1`] = ` - @@ -916,7 +844,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -981,29 +908,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` - - @@ -1399,7 +1279,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` { "flex": 1, }, - null, ], ] } @@ -1525,29 +1404,6 @@ exports[`renders FAB.Menu not expanded when trigger is not visible 1`] = ` - @@ -1693,7 +1549,6 @@ exports[`renders FAB.Menu open 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -1758,29 +1613,6 @@ exports[`renders FAB.Menu open 1`] = ` - - @@ -2176,7 +1984,6 @@ exports[`renders FAB.Menu open 1`] = ` { "flex": 1, }, - null, ], ] } @@ -2302,29 +2109,6 @@ exports[`renders FAB.Menu open 1`] = ` - @@ -2470,7 +2254,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -2535,29 +2318,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` - - - - - - @@ -3657,7 +3297,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` { "flex": 1, }, - null, ], ] } @@ -3783,29 +3422,6 @@ exports[`renders FAB.Menu with 6 items 1`] = ` - @@ -3952,7 +3568,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -4017,29 +3632,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` - - @@ -4435,7 +4003,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` { "flex": 1, }, - null, ], ] } @@ -4561,29 +4128,6 @@ exports[`renders FAB.Menu with center alignment 1`] = ` - @@ -4729,7 +4273,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -4824,29 +4367,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` - - @@ -5272,7 +4768,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` { "flex": 1, }, - null, ], ] } @@ -5398,29 +4893,6 @@ exports[`renders FAB.Menu with items having icons 1`] = ` - @@ -5566,7 +5038,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` { "borderRadius": 9999, }, - null, ], ] } @@ -5631,29 +5102,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` - - @@ -6049,7 +5473,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` { "flex": 1, }, - null, ], ] } @@ -6175,29 +5598,6 @@ exports[`renders FAB.Menu with start alignment 1`] = ` - diff --git a/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap b/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap index 7534e6f77e..f1fd3dfe15 100644 --- a/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/IconButton.test.tsx.snap @@ -1,13 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`renders disabled icon button 1`] = ` +exports[`computes hitSlop from explicit width/height rather than the button size 1`] = ` + + + camera + + + + +`; + +exports[`drops hitSlop when explicit width/height already meet the 48dp minimum 1`] = ` + + + + + camera + + + + +`; + +exports[`lets a caller-supplied hitSlop win even while disabled 1`] = ` + + + + + camera + + + + +`; + +exports[`renders disabled icon button 1`] = ` + + @@ -189,29 +188,6 @@ exports[`Switch render renders disabled off 1`] = ` } /> - `; @@ -269,7 +245,6 @@ exports[`Switch render renders disabled on 1`] = ` "justifyContent": "center", "width": 52, }, - undefined, ] } > @@ -384,29 +359,6 @@ exports[`Switch render renders disabled on 1`] = ` } /> - `; @@ -446,6 +398,14 @@ exports[`Switch render renders off 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 0, + "right": 0, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -464,7 +424,6 @@ exports[`Switch render renders off 1`] = ` "justifyContent": "center", "width": 52, }, - undefined, ] } > @@ -582,29 +541,6 @@ exports[`Switch render renders off 1`] = ` } /> - `; @@ -644,6 +580,14 @@ exports[`Switch render renders on 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 0, + "right": 0, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -662,7 +606,6 @@ exports[`Switch render renders on 1`] = ` "justifyContent": "center", "width": 52, }, - undefined, ] } > @@ -760,29 +703,6 @@ exports[`Switch render renders on 1`] = ` } /> - `; @@ -822,6 +742,14 @@ exports[`Switch render renders with checked icon 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 0, + "right": 0, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -840,7 +768,6 @@ exports[`Switch render renders with checked icon 1`] = ` "justifyContent": "center", "width": 52, }, - undefined, ] } > @@ -1009,29 +936,6 @@ exports[`Switch render renders with checked icon 1`] = ` - `; @@ -1071,6 +975,14 @@ exports[`Switch render renders with per-state icons 1`] = ` accessible={true} collapsable={false} focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 0, + "right": 0, + "top": 4, + } + } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -1089,7 +1001,6 @@ exports[`Switch render renders with per-state icons 1`] = ` "justifyContent": "center", "width": 52, }, - undefined, ] } > @@ -1258,28 +1169,5 @@ exports[`Switch render renders with per-state icons 1`] = ` - `; diff --git a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap index 6efc467066..ba5d86731d 100644 --- a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap @@ -150,7 +150,6 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -158,8 +157,16 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -201,10 +208,10 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } multiline={false} @@ -229,6 +236,24 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -339,7 +364,6 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -347,8 +371,16 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -390,10 +422,10 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } multiline={false} @@ -418,6 +450,24 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -624,7 +674,6 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -632,8 +681,16 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -675,10 +732,10 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } multiline={false} @@ -703,6 +760,24 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -813,7 +888,6 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -821,8 +895,16 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -864,10 +946,10 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } multiline={false} @@ -892,6 +974,24 @@ exports[`renders filled TextInput with TextInput.Icon accessories when error is "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1276,7 +1376,6 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1284,8 +1383,16 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -1327,10 +1434,10 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } multiline={false} @@ -1355,6 +1462,24 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1465,7 +1590,6 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1473,8 +1597,16 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -1516,10 +1648,10 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } multiline={false} @@ -1544,6 +1676,24 @@ exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1730,7 +1880,6 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1738,8 +1887,16 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -1781,10 +1938,10 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } multiline={false} @@ -1809,6 +1966,24 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] @@ -1919,7 +2094,6 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i [ { "margin": 6, - "overflow": "hidden", }, { "backgroundColor": undefined, @@ -1927,8 +2101,16 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "width": 40, }, { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, "borderColor": "rgba(202, 196, 208, 1)", "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, "borderWidth": 0, }, { @@ -1970,10 +2152,10 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i focusable={true} hitSlop={ { - "bottom": 6, - "left": 6, - "right": 6, - "top": 6, + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, } } multiline={false} @@ -1998,6 +2180,24 @@ exports[`renders outlined TextInput with TextInput.Icon accessories when error i "flexGrow": 1, "justifyContent": "center", }, + { + "borderBottomEndRadius": undefined, + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderBottomStartRadius": undefined, + "borderRadius": 20, + "borderTopEndRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "borderTopStartRadius": undefined, + }, + { + "overflow": "hidden", + }, + { + "height": undefined, + "width": undefined, + }, undefined, ], ] diff --git a/src/components/__tests__/__snapshots__/TouchableRipple.test.tsx.snap b/src/components/__tests__/__snapshots__/TouchableRipple.test.tsx.snap index 00ac206260..76d6a41fdf 100644 --- a/src/components/__tests__/__snapshots__/TouchableRipple.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TouchableRipple.test.tsx.snap @@ -1,5 +1,101 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`TouchableRipple hitSlop does not add its own hitSlop when none is supplied 1`] = ` + + + Button + + +`; + +exports[`TouchableRipple hitSlop passes a caller-supplied hitSlop straight through 1`] = ` + + + Button + + +`; + exports[`TouchableRipple on iOS displays the underlay when pressed 1`] = ` `; + +exports[`TouchableRipple on iOS takes per-corner radii too 1`] = ` + + + + Press me! + + +`; + +exports[`TouchableRipple on iOS takes the shape of the touchable so it does not square off the corners 1`] = ` + + + + Press me! + + +`; 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..4a4f7e6d93 --- /dev/null +++ b/src/utils/__tests__/useFocusRing.test.tsx @@ -0,0 +1,226 @@ +import { Platform, Pressable, Text, View } from 'react-native'; +import type { ViewStyle } 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'; +import type { FocusRingPlacement, FocusRingScope } from '../useFocusRing'; +import { buildFocusRingStylesheet, 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 { target, ring } = useFocusRing(disabled, 'rebeccapurple'); + onRender?.(); + return ( + {}} + onFocus={target.onFocus} + onBlur={target.onBlur} + style={ring.style} + > + probe + + ); +}; + +describe('buildFocusRingStylesheet', () => { + const css = buildFocusRingStylesheet(); + + it('rings `self` via :focus-visible and `within` via :has(:focus-visible), for both placements', () => { + expect(css).toContain('[data-focus-ring="outward"]:focus-visible'); + expect(css).toContain('[data-focus-ring="inward"]:focus-visible'); + expect(css).toContain( + '[data-focus-ring-within="outward"]:has(:focus-visible)' + ); + expect(css).toContain( + '[data-focus-ring-within="inward"]:has(:focus-visible)' + ); + }); + + // 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 offsets from the focusIndicator tokens', () => { + const { thickness, outerOffset } = tokens.md.sys.state.focusIndicator; + + expect(css).toContain(`outline: ${thickness}px solid`); + expect(css).toContain(`outline-offset: ${outerOffset}px;`); + expect(css).toContain(`outline-offset: ${-thickness}px;`); + }); +}); + +describe('useFocusRing (native)', () => { + // Derived from the token, not hardcoded - see the note on the stylesheet + // test above. + const { thickness, outerOffset } = tokens.md.sys.state.focusIndicator; + + it('applies the outline on focus and removes it on blur', async () => { + await render(); + expect(screen.getByTestId('probe')).not.toHaveStyle({ + outlineWidth: thickness, + }); + + await focus(); + expect(screen.getByTestId('probe')).toHaveStyle({ + outlineWidth: thickness, + outlineColor: 'rebeccapurple', + outlineOffset: outerOffset, + }); + + await blur(); + expect(screen.getByTestId('probe')).not.toHaveStyle({ + outlineWidth: thickness, + }); + }); + + // `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: thickness, + }); + }); + + // 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: thickness, + }); + + 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: thickness, + }); + }); +}); + +// There is no DOM in this repo's Jest, so these can only prove the wiring - +// the right data attribute and CSS variable land on the right element. Real +// `:focus-visible`/`:has()` behaviour is a manual, browser-only check (see +// the PR description). +describe('useFocusRing (web)', () => { + const original = Platform.OS; + beforeEach(() => { + Platform.OS = 'web'; + }); + afterEach(() => { + Platform.OS = original; + }); + + const WebProbe = ({ + disabled, + placement, + scope, + }: { + disabled?: boolean; + placement?: FocusRingPlacement; + scope?: FocusRingScope; + }) => { + const { target, ring } = useFocusRing( + disabled, + 'rebeccapurple', + placement, + scope + ); + return ( + + + + ); + }; + + it('emits data-focus-ring and the colour variable for scope "self"', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('probe').props.dataSet).toEqual({ + focusRing: 'outward', + }); + expect(screen.getByTestId('probe')).toHaveStyle( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + { '--rnp-focus-ring-color': 'rebeccapurple' } as unknown as ViewStyle + ); + }); + + it('emits data-focus-ring-within for scope "within", and suppresses the target element\'s own outline', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('probe').props.dataSet).toEqual({ + focusRingWithin: 'inward', + }); + expect(screen.getByTestId('target')).toHaveStyle( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + { outline: 'none' } as unknown as ViewStyle + ); + }); + + it('does not suppress the target element\'s outline for scope "self"', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('target').props.style).toEqual([]); + }); + + it('emits nothing when disabled or when the ring is turned off', async () => { + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('probe').props.dataSet).toBeUndefined(); + + await render(); + + // eslint-disable-next-line no-restricted-syntax + expect(screen.getByTestId('probe').props.dataSet).toBeUndefined(); + }); +}); diff --git a/src/utils/getMinInteractiveSizeHitSlop.ts b/src/utils/getMinInteractiveSizeHitSlop.ts new file mode 100644 index 0000000000..cb6a017432 --- /dev/null +++ b/src/utils/getMinInteractiveSizeHitSlop.ts @@ -0,0 +1,40 @@ +import type { Insets } from 'react-native'; + +/** + * Minimum size of an interactive target. + * @see https://m3.material.io/foundations/designing/structure + */ +const MIN_INTERACTIVE_SIZE = 48; + +/** + * Hit slop needed to bring a fixed-size element up to the 48dp minimum + * interactive target, expanding outward rather than resizing. Returns + * `undefined` when there is nothing to add, so that case does not create a new + * object on every call. + * @see https://developer.android.com/develop/ui/compose/accessibility/api-defaults + */ +const getMinInteractiveSizeHitSlop = ({ + width, + height, +}: { + width?: number; + height?: number; +}): Insets | undefined => { + const horizontal = + width === undefined ? 0 : Math.max(0, (MIN_INTERACTIVE_SIZE - width) / 2); + const vertical = + height === undefined ? 0 : Math.max(0, (MIN_INTERACTIVE_SIZE - height) / 2); + + if (horizontal === 0 && vertical === 0) { + return undefined; + } + + return { + top: vertical, + bottom: vertical, + left: horizontal, + right: horizontal, + }; +}; + +export default getMinInteractiveSizeHitSlop; diff --git a/src/utils/useFocusRing.ts b/src/utils/useFocusRing.ts new file mode 100644 index 0000000000..a13fb75b06 --- /dev/null +++ b/src/utils/useFocusRing.ts @@ -0,0 +1,203 @@ +import * as React from 'react'; +import { + Platform, + type ColorValue, + type NativeSyntheticEvent, + type TargetedEvent, + type 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'; + +/** + * `'self'` - the ring is drawn on the element that receives focus. + * `'within'` - the ring is drawn on an ancestor (a track/clip view), because + * the focusable element's own box is the wrong shape or size for it. + */ +export type FocusRingScope = 'self' | 'within'; + +export type FocusRingResult = { + /** Spread onto the element that receives focus. */ + target: { + onFocus?: (e: NativeSyntheticEvent) => void; + onBlur?: () => void; + style: ViewStyle[]; + }; + /** Spread onto the element that draws the ring. */ + ring: { + style: ViewStyle[]; + /** + * Spread onto the ring element, e.g. ``. + * `dataSet` isn't in RN's core view prop types, though react-native-web + * renders it as real `data-*` attributes - the mechanism the shared + * stylesheet below keys off. Typed as `object` so it spreads onto any + * host component without a prop-type mismatch; empty (a no-op spread) + * on native and when the ring is suppressed. + */ + dataSetProps: object; + }; +}; + +const toDataSetProps = (dataSet: Record | undefined): object => + dataSet ? { dataSet } : {}; + +const EMPTY: FocusRingResult = { + target: { style: [] }, + ring: { style: [], dataSetProps: {} }, +}; + +/** Suppresses the browser's own focus ring, for `scope: 'within'` on web. */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const webNoOutlineStyle = { outline: 'none' } as unknown as ViewStyle; + +/** + * MD3 keyboard focus indicator, from one hook shared by every component built + * on it. + * + * On native there is no CSS, so this tracks focus in state and computes the + * ring as an `outline*` style, live. On web the ring is real CSS: a shared + * stylesheet keyed off a `data-focus-ring[-within]` attribute and + * `:focus-visible`/`:has(:focus-visible)`, with only the (theme-dependent) + * color passed through as a CSS custom property. Nothing here tracks focus in + * JS on web - the browser drives it. + * + * `scope: 'within'` is for a control whose ring belongs on an ancestor of the + * element that actually receives focus (Switch's track, FAB's clip view): + * `target` also suppresses the browser's default outline on the focused + * element itself on web, so only the ring shows. + * + * iOS: `onFocus`/`onBlur` never reach here today. Fabric's view does call + * `-becomeFirstResponder`/`-resignFirstResponder` for hardware-keyboard/Full + * Keyboard Access navigation, but only emits the JS event when the + * `enableImperativeFocus` feature flag is on, and it defaults off (old + * architecture has no equivalent path at all). Pre-existing, not something + * this hook introduces - the library's older FAB, Checkbox, and Switch rings + * were equally inert on iOS. + */ +export function useFocusRing( + disabled: boolean | undefined, + color: ColorValue, + placement: FocusRingPlacement = 'outward', + scope: FocusRingScope = 'self' +): FocusRingResult { + const suppressed = disabled || placement === 'none'; + + // Rules of hooks: called unconditionally regardless of platform. Cheap - + // native's is a no-op until focus/blur actually fires, web's is stateless. + const [focused, setFocused] = React.useState(false); + + const onFocus = React.useCallback( + (e: NativeSyntheticEvent) => { + if (!suppressed) { + setFocused(isKeyboardFocusEvent(e)); + } + }, + [suppressed] + ); + + 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 (suppressed) { + setFocused(false); + } + }, [suppressed]); + + const isNativeFocused = focused && !suppressed; + + return React.useMemo(() => { + if (suppressed) { + return EMPTY; + } + + if (Platform.OS === 'web') { + const dataKey = scope === 'within' ? 'focusRingWithin' : 'focusRing'; + return { + target: { style: scope === 'within' ? [webNoOutlineStyle] : [] }, + ring: { + style: [ + // A style key starting with `--` becomes a real CSS custom + // property on web (react-native-web's setValueForStyles), read + // by the shared stylesheet below. Not reactive to focus - the + // browser's own `:focus-visible`/`:has()` shows the ring. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + { ['--rnp-focus-ring-color']: color } as unknown as ViewStyle, + ], + dataSetProps: toDataSetProps({ [dataKey]: placement }), + }, + }; + } + + return { + target: { onFocus, onBlur, style: [] }, + ring: { + style: isNativeFocused + ? [ + { + outlineWidth: thickness, + outlineColor: color, + outlineStyle: 'solid' as const, + outlineOffset: + placement === 'inward' ? -thickness : outerOffset, + }, + ] + : [], + dataSetProps: {}, + }, + }; + }, [suppressed, scope, color, placement, isNativeFocused, onFocus, onBlur]); +} + +const STYLE_ELEMENT_ATTR = 'data-rnp-focus-ring-styles'; + +const focusRingRule = ( + selector: (placement: 'outward' | 'inward') => string +) => ` +${selector('outward')} { + outline: ${thickness}px solid var(--rnp-focus-ring-color); + outline-offset: ${outerOffset}px; +} +${selector('inward')} { + outline: ${thickness}px solid var(--rnp-focus-ring-color); + outline-offset: ${-thickness}px; +}`; + +/** + * The shared web stylesheet text, as a pure function of the design tokens - + * exported so its contents can be asserted without a DOM. + */ +export const buildFocusRingStylesheet = (): string => + [ + focusRingRule((p) => `[data-focus-ring="${p}"]:focus-visible`), + focusRingRule((p) => `[data-focus-ring-within="${p}"]:has(:focus-visible)`), + ].join('\n'); + +let injected = false; + +/** Idempotent: safe to call from every module that needs the ring on web. */ +export const injectFocusRingStylesheet = (): void => { + if (injected || Platform.OS !== 'web' || typeof document === 'undefined') { + return; + } + if (document.querySelector(`style[${STYLE_ELEMENT_ATTR}]`)) { + injected = true; + return; + } + + const style = document.createElement('style'); + style.setAttribute(STYLE_ELEMENT_ATTR, ''); + style.textContent = buildFocusRingStylesheet(); + document.head.appendChild(style); + injected = true; +}; + +if (Platform.OS === 'web') { + injectFocusRingStylesheet(); +}