From 6833c90ca4bb9942dd77b9f955cf5465a7ba911f Mon Sep 17 00:00:00 2001 From: Adrian Cotfas Date: Mon, 18 May 2026 16:34:26 +0300 Subject: [PATCH 1/7] refactor: improve structure of reference and system tokens * add focusIndicator tokens --- src/components/Button/utils.tsx | 2 +- src/components/Checkbox/CheckboxItem.tsx | 8 +- src/components/Checkbox/utils.ts | 2 +- src/components/Chip/helpers.tsx | 2 +- src/components/HelperText/utils.ts | 23 +- src/components/IconButton/utils.ts | 2 +- src/components/Menu/utils.ts | 2 +- src/components/Modal.tsx | 2 +- .../RadioButton/RadioButtonItem.tsx | 8 +- src/components/SegmentedButtons/utils.ts | 2 +- src/components/TextInput/Adornment/utils.ts | 12 +- src/components/TextInput/helpers.tsx | 2 +- src/components/__tests__/Button.test.tsx | 2 +- .../__tests__/Checkbox/utils.test.tsx | 2 +- src/components/__tests__/Chip.test.tsx | 2 +- src/components/__tests__/IconButton.test.tsx | 2 +- src/components/__tests__/MenuItem.test.tsx | 2 +- src/components/__tests__/Modal.test.tsx | 2 +- .../__tests__/SegmentedButton.test.tsx | 2 +- src/components/__tests__/TextInput.test.tsx | 2 +- src/theme/schemes/DarkTheme.tsx | 4 +- src/theme/schemes/LightTheme.tsx | 4 +- src/theme/tokens/index.ts | 362 +----------------- src/theme/tokens/ref/palette.ts | 97 +++++ src/theme/tokens/ref/typeface.ts | 22 ++ .../tokens/sys/{color/roles.ts => color.ts} | 11 +- src/theme/tokens/sys/state.ts | 18 + src/theme/tokens/sys/typography.ts | 216 ++++++++++- src/theme/types/color.ts | 4 + src/theme/types/index.ts | 2 +- src/theme/types/state.ts | 10 + src/theme/types/utils.ts | 9 - src/theme/utils/state.ts | 23 ++ src/types.tsx | 10 + 34 files changed, 456 insertions(+), 419 deletions(-) create mode 100644 src/theme/tokens/ref/palette.ts create mode 100644 src/theme/tokens/ref/typeface.ts rename src/theme/tokens/sys/{color/roles.ts => color.ts} (95%) create mode 100644 src/theme/tokens/sys/state.ts create mode 100644 src/theme/types/state.ts delete mode 100644 src/theme/types/utils.ts create mode 100644 src/theme/utils/state.ts diff --git a/src/components/Button/utils.tsx b/src/components/Button/utils.tsx index 03ce9513ea..a2ff870c46 100644 --- a/src/components/Button/utils.tsx +++ b/src/components/Button/utils.tsx @@ -5,7 +5,7 @@ import { tokens } from '../../theme/tokens'; import type { InternalTheme, Theme } from '../../types'; import { splitStyles } from '../../utils/splitStyles'; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; export type ButtonMode = | 'text' diff --git a/src/components/Checkbox/CheckboxItem.tsx b/src/components/Checkbox/CheckboxItem.tsx index c0935ffb7a..f74d8dc9d5 100644 --- a/src/components/Checkbox/CheckboxItem.tsx +++ b/src/components/Checkbox/CheckboxItem.tsx @@ -11,7 +11,7 @@ import { import Checkbox from './Checkbox'; import { useInternalTheme } from '../../core/theming'; -import { tokens } from '../../theme/tokens'; +import { getStateLayer } from '../../theme/utils/state'; import type { ThemeProp, TypescaleKey } from '../../types'; import TouchableRipple, { Props as TouchableRippleProps, @@ -145,14 +145,10 @@ const CheckboxItem = ({ const isLeading = position === 'leading'; const checkbox = ; - const textColor = theme.colors.onSurface; const textAlign = isLeading ? 'right' : 'left'; const computedStyle = { - color: textColor, - opacity: disabled - ? tokens.md.ref.stateOpacity.disabled - : tokens.md.ref.stateOpacity.enabled, + ...getStateLayer(theme, 'onSurface', disabled ? 'disabled' : 'enabled'), textAlign, } as TextStyle; diff --git a/src/components/Checkbox/utils.ts b/src/components/Checkbox/utils.ts index 1314193174..10f6d4eb1e 100644 --- a/src/components/Checkbox/utils.ts +++ b/src/components/Checkbox/utils.ts @@ -3,7 +3,7 @@ import type { ColorValue } from 'react-native'; import { tokens } from '../../theme/tokens'; import type { InternalTheme } from '../../types'; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; const getCheckedColor = ({ theme, diff --git a/src/components/Chip/helpers.tsx b/src/components/Chip/helpers.tsx index 6090ed45bb..4b0fdf9e06 100644 --- a/src/components/Chip/helpers.tsx +++ b/src/components/Chip/helpers.tsx @@ -7,7 +7,7 @@ import type { InternalTheme, Theme } from '../../types'; const md3 = (theme: InternalTheme) => theme as Theme; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; export type ChipAvatarProps = { style?: StyleProp; diff --git a/src/components/HelperText/utils.ts b/src/components/HelperText/utils.ts index 6c7fe16a58..86c39c2e45 100644 --- a/src/components/HelperText/utils.ts +++ b/src/components/HelperText/utils.ts @@ -1,8 +1,6 @@ -import { tokens } from '../../theme/tokens'; +import { getStateLayer } from '../../theme/utils/state'; import type { InternalTheme } from '../../types'; -const { stateOpacity } = tokens.md.ref; - type BaseProps = { theme: InternalTheme; disabled?: boolean; @@ -11,18 +9,11 @@ type BaseProps = { export function getTextColor({ theme, disabled, type }: BaseProps) { if (type === 'error') { - return { color: theme.colors.error, opacity: stateOpacity.enabled }; - } - - if (disabled) { - return { - color: theme.colors.onSurfaceVariant, - opacity: stateOpacity.disabled, - }; + return getStateLayer(theme, 'error', 'enabled'); } - - return { - color: theme.colors.onSurfaceVariant, - opacity: stateOpacity.enabled, - }; + return getStateLayer( + theme, + 'onSurfaceVariant', + disabled ? 'disabled' : 'enabled' + ); } diff --git a/src/components/IconButton/utils.ts b/src/components/IconButton/utils.ts index 284e9efb2d..cbcf5f1051 100644 --- a/src/components/IconButton/utils.ts +++ b/src/components/IconButton/utils.ts @@ -3,7 +3,7 @@ import type { ColorValue } from 'react-native'; import { tokens } from '../../theme/tokens'; import type { InternalTheme } from '../../types'; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; type IconButtonMode = 'outlined' | 'contained' | 'contained-tonal'; diff --git a/src/components/Menu/utils.ts b/src/components/Menu/utils.ts index 9d17a863e6..2aae40d2e4 100644 --- a/src/components/Menu/utils.ts +++ b/src/components/Menu/utils.ts @@ -2,7 +2,7 @@ import { tokens } from '../../theme/tokens'; import type { InternalTheme } from '../../types'; import type { IconSource } from '../Icon'; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; export const MIN_WIDTH = 112; export const MAX_WIDTH = 280; diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 7cce27eb3c..d6590881a3 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -20,7 +20,7 @@ import { addEventListener } from '../utils/addEventListener'; import { BackHandler } from '../utils/BackHandler/BackHandler'; import useAnimatedValue from '../utils/useAnimatedValue'; -const { scrimAlpha } = tokens.md.ref; +const scrimAlpha = tokens.md.sys.scrim.alpha; export type Props = { /** diff --git a/src/components/RadioButton/RadioButtonItem.tsx b/src/components/RadioButton/RadioButtonItem.tsx index 936a2cdf3d..4c6bcb824d 100644 --- a/src/components/RadioButton/RadioButtonItem.tsx +++ b/src/components/RadioButton/RadioButtonItem.tsx @@ -15,7 +15,7 @@ import { RadioButtonContext, RadioButtonContextType } from './RadioButtonGroup'; import RadioButtonIOS from './RadioButtonIOS'; import { handlePress, isChecked } from './utils'; import { useInternalTheme } from '../../core/theming'; -import { tokens } from '../../theme/tokens'; +import { getStateLayer } from '../../theme/utils/state'; import type { ThemeProp, TypescaleKey } from '../../types'; import TouchableRipple, { Props as TouchableRippleProps, @@ -179,14 +179,10 @@ const RadioButtonItem = ({ radioButton = ; } - const textColor = theme.colors.onSurface; const textAlign = isLeading ? 'right' : 'left'; const computedStyle = { - color: textColor, - opacity: disabled - ? tokens.md.ref.stateOpacity.disabled - : tokens.md.ref.stateOpacity.enabled, + ...getStateLayer(theme, 'onSurface', disabled ? 'disabled' : 'enabled'), textAlign, } as TextStyle; diff --git a/src/components/SegmentedButtons/utils.ts b/src/components/SegmentedButtons/utils.ts index af12651971..bf56e25139 100644 --- a/src/components/SegmentedButtons/utils.ts +++ b/src/components/SegmentedButtons/utils.ts @@ -3,7 +3,7 @@ import { ViewStyle } from 'react-native'; import { tokens } from '../../theme/tokens'; import type { InternalTheme } from '../../types'; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; type BaseProps = { theme: InternalTheme; diff --git a/src/components/TextInput/Adornment/utils.ts b/src/components/TextInput/Adornment/utils.ts index 64e5462b74..3a6e819963 100644 --- a/src/components/TextInput/Adornment/utils.ts +++ b/src/components/TextInput/Adornment/utils.ts @@ -1,9 +1,10 @@ import type { ColorValue } from 'react-native'; import { tokens } from '../../../theme/tokens'; +import { getStateLayer } from '../../../theme/utils/state'; import type { InternalTheme } from '../../../types'; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; type BaseProps = { theme: InternalTheme; @@ -11,10 +12,11 @@ type BaseProps = { }; export function getTextColor({ theme, disabled }: BaseProps) { - return { - color: theme.colors.onSurfaceVariant, - opacity: disabled ? stateOpacity.disabled : stateOpacity.enabled, - }; + return getStateLayer( + theme, + 'onSurfaceVariant', + disabled ? 'disabled' : 'enabled' + ); } export function getIconColor({ diff --git a/src/components/TextInput/helpers.tsx b/src/components/TextInput/helpers.tsx index 0dd1aad7e6..c633314991 100644 --- a/src/components/TextInput/helpers.tsx +++ b/src/components/TextInput/helpers.tsx @@ -19,7 +19,7 @@ import type { TextInputLabelProp } from './types'; import { tokens } from '../../theme/tokens'; import type { InternalTheme } from '../../types'; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; type PaddingProps = { height: number | null; diff --git a/src/components/__tests__/Button.test.tsx b/src/components/__tests__/Button.test.tsx index 71107089e7..7f631d5dcb 100644 --- a/src/components/__tests__/Button.test.tsx +++ b/src/components/__tests__/Button.test.tsx @@ -10,7 +10,7 @@ import { tokens } from '../../theme/tokens'; import Button from '../Button/Button'; import { getButtonColors } from '../Button/utils'; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; const styles = StyleSheet.create({ flexing: { diff --git a/src/components/__tests__/Checkbox/utils.test.tsx b/src/components/__tests__/Checkbox/utils.test.tsx index 118fe1690f..78af32e8ea 100644 --- a/src/components/__tests__/Checkbox/utils.test.tsx +++ b/src/components/__tests__/Checkbox/utils.test.tsx @@ -1,7 +1,7 @@ import { getTheme } from '../../../core/theming'; import { tokens } from '../../../theme/tokens'; import { getSelectionControlColor } from '../../Checkbox/utils'; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; describe('getSelectionControlColor - checkbox color', () => { it('should return correct disabled color, for theme version 3', () => { diff --git a/src/components/__tests__/Chip.test.tsx b/src/components/__tests__/Chip.test.tsx index cf0f89ddba..7b724dd4ac 100644 --- a/src/components/__tests__/Chip.test.tsx +++ b/src/components/__tests__/Chip.test.tsx @@ -10,7 +10,7 @@ import { tokens } from '../../theme/tokens'; import Chip from '../Chip/Chip'; import { getChipColors } from '../Chip/helpers'; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; it('renders chip with onPress', () => { const tree = render( {}}>Example Chip).toJSON(); diff --git a/src/components/__tests__/IconButton.test.tsx b/src/components/__tests__/IconButton.test.tsx index 9702fe499c..0f34aaec38 100644 --- a/src/components/__tests__/IconButton.test.tsx +++ b/src/components/__tests__/IconButton.test.tsx @@ -10,7 +10,7 @@ import { tokens } from '../../theme/tokens'; import IconButton from '../IconButton/IconButton'; import { getIconButtonColor } from '../IconButton/utils'; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; const styles = StyleSheet.create({ square: { diff --git a/src/components/__tests__/MenuItem.test.tsx b/src/components/__tests__/MenuItem.test.tsx index 8b525a5844..2fa3f809bd 100644 --- a/src/components/__tests__/MenuItem.test.tsx +++ b/src/components/__tests__/MenuItem.test.tsx @@ -6,7 +6,7 @@ import { tokens } from '../../theme/tokens'; import Menu from '../Menu/Menu'; import { getMenuItemColor } from '../Menu/utils'; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; describe('Menu Item', () => { it('renders menu item', () => { diff --git a/src/components/__tests__/Modal.test.tsx b/src/components/__tests__/Modal.test.tsx index f4b37bc564..77f3fe609f 100644 --- a/src/components/__tests__/Modal.test.tsx +++ b/src/components/__tests__/Modal.test.tsx @@ -13,7 +13,7 @@ import { LightTheme } from '../../theme/schemes'; import { tokens } from '../../theme/tokens'; import Modal from '../Modal'; -const { scrimAlpha } = tokens.md.ref; +const scrimAlpha = tokens.md.sys.scrim.alpha; jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ bottom: 44, left: 0, right: 0, top: 37 }), diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx index 94ab9828b1..0904d02829 100644 --- a/src/components/__tests__/SegmentedButton.test.tsx +++ b/src/components/__tests__/SegmentedButton.test.tsx @@ -9,7 +9,7 @@ import { getSegmentedButtonColors, } from '../SegmentedButtons/utils'; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; it('renders segmented button', () => { const tree = render( diff --git a/src/components/__tests__/TextInput.test.tsx b/src/components/__tests__/TextInput.test.tsx index 7890ce3253..97f77833aa 100644 --- a/src/components/__tests__/TextInput.test.tsx +++ b/src/components/__tests__/TextInput.test.tsx @@ -16,7 +16,7 @@ import { } from '../TextInput/helpers'; import TextInput, { Props } from '../TextInput/TextInput'; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; const style = StyleSheet.create({ inputStyle: { diff --git a/src/theme/schemes/DarkTheme.tsx b/src/theme/schemes/DarkTheme.tsx index dfc8e6b16b..9b7ff60ef4 100644 --- a/src/theme/schemes/DarkTheme.tsx +++ b/src/theme/schemes/DarkTheme.tsx @@ -1,12 +1,12 @@ import { themeDefaults } from './base'; import { tokens } from '../tokens'; -import { buildScheme } from '../tokens/sys/color/roles'; +import { buildScheme } from '../tokens/sys/color'; import { defaultShapes } from '../tokens/sys/shape'; import type { Theme } from '../types'; export const DarkTheme: Theme = { ...themeDefaults, dark: true, - colors: buildScheme(tokens.md.ref.palette, tokens.md.ref, { mode: 'dark' }), + colors: buildScheme(tokens.md.ref.palette, { mode: 'dark' }), shapes: defaultShapes, }; diff --git a/src/theme/schemes/LightTheme.tsx b/src/theme/schemes/LightTheme.tsx index 35926afde9..42593d5d42 100644 --- a/src/theme/schemes/LightTheme.tsx +++ b/src/theme/schemes/LightTheme.tsx @@ -1,12 +1,12 @@ import { themeDefaults } from './base'; import { tokens } from '../tokens'; -import { buildScheme } from '../tokens/sys/color/roles'; +import { buildScheme } from '../tokens/sys/color'; import { defaultShapes } from '../tokens/sys/shape'; import type { Theme } from '../types'; export const LightTheme: Theme = { ...themeDefaults, dark: false, - colors: buildScheme(tokens.md.ref.palette, tokens.md.ref, { mode: 'light' }), + colors: buildScheme(tokens.md.ref.palette, { mode: 'light' }), shapes: defaultShapes, }; diff --git a/src/theme/tokens/index.ts b/src/theme/tokens/index.ts index b0b160b080..3f41b7fec5 100644 --- a/src/theme/tokens/index.ts +++ b/src/theme/tokens/index.ts @@ -1,358 +1,22 @@ -import { Platform } from 'react-native'; - -import type { Font } from '../types'; - -const ref = { - palette: { - primary100: 'rgba(255, 255, 255, 1)', - primary99: 'rgba(255, 251, 254, 1)', - primary98: 'rgba(254, 247, 255, 1)', - primary95: 'rgba(246, 237, 255, 1)', - primary90: 'rgba(234, 221, 255, 1)', - primary80: 'rgba(208, 188, 255, 1)', - primary70: 'rgba(182, 157, 248, 1)', - primary60: 'rgba(154, 130, 219, 1)', - primary50: 'rgba(127, 103, 190, 1)', - primary40: 'rgba(103, 80, 164, 1)', - primary30: 'rgba(79, 55, 139, 1)', - primary20: 'rgba(56, 30, 114, 1)', - primary10: 'rgba(33, 0, 93, 1)', - primary0: 'rgba(0, 0, 0, 1)', - secondary100: 'rgba(255, 255, 255, 1)', - secondary99: 'rgba(255, 251, 254, 1)', - secondary98: 'rgba(254, 247, 255, 1)', - secondary95: 'rgba(246, 237, 255, 1)', - secondary90: 'rgba(232, 222, 248, 1)', - secondary80: 'rgba(204, 194, 220, 1)', - secondary70: 'rgba(176, 167, 192, 1)', - secondary60: 'rgba(149, 141, 165, 1)', - secondary50: 'rgba(122, 114, 137, 1)', - secondary40: 'rgba(98, 91, 113, 1)', - secondary30: 'rgba(74, 68, 88, 1)', - secondary20: 'rgba(51, 45, 65, 1)', - secondary10: 'rgba(29, 25, 43, 1)', - secondary0: 'rgba(0, 0, 0, 1)', - tertiary100: 'rgba(255, 255, 255, 1)', - tertiary99: 'rgba(255, 251, 250, 1)', - tertiary98: 'rgba(255, 248, 248, 1)', - tertiary95: 'rgba(255, 236, 241, 1)', - tertiary90: 'rgba(255, 216, 228, 1)', - tertiary80: 'rgba(239, 184, 200, 1)', - tertiary70: 'rgba(210, 157, 172, 1)', - tertiary60: 'rgba(181, 131, 146, 1)', - tertiary50: 'rgba(152, 105, 119, 1)', - tertiary40: 'rgba(125, 82, 96, 1)', - tertiary30: 'rgba(99, 59, 72, 1)', - tertiary20: 'rgba(73, 37, 50, 1)', - tertiary10: 'rgba(49, 17, 29, 1)', - tertiary0: 'rgba(0, 0, 0, 1)', - neutral100: 'rgba(255, 255, 255, 1)', - neutral99: 'rgba(255, 251, 255, 1)', - neutral98: 'rgba(254, 247, 255, 1)', - neutral96: 'rgba(247, 242, 250, 1)', - neutral95: 'rgba(245, 239, 247, 1)', - neutral94: 'rgba(243, 237, 247, 1)', - neutral92: 'rgba(236, 230, 240, 1)', - neutral90: 'rgba(230, 224, 233, 1)', - neutral87: 'rgba(222, 216, 225, 1)', - neutral80: 'rgba(202, 197, 205, 1)', - neutral70: 'rgba(174, 169, 177, 1)', - neutral60: 'rgba(147, 143, 150, 1)', - neutral50: 'rgba(121, 118, 125, 1)', - neutral40: 'rgba(96, 93, 100, 1)', - neutral30: 'rgba(72, 70, 76, 1)', - neutral24: 'rgba(59, 56, 62, 1)', - neutral22: 'rgba(54, 52, 59, 1)', - neutral20: 'rgba(50, 47, 53, 1)', - neutral17: 'rgba(43, 41, 48, 1)', - neutral12: 'rgba(33, 31, 38, 1)', - neutral10: 'rgba(29, 27, 32, 1)', - neutral6: 'rgba(20, 18, 24, 1)', - neutral4: 'rgba(15, 13, 19, 1)', - neutral0: 'rgba(0, 0, 0, 1)', - neutralVariant100: 'rgba(255, 255, 255, 1)', - neutralVariant99: 'rgba(255, 251, 254, 1)', - neutralVariant98: 'rgba(253, 247, 255, 1)', - neutralVariant95: 'rgba(245, 238, 250, 1)', - neutralVariant90: 'rgba(231, 224, 236, 1)', - neutralVariant80: 'rgba(202, 196, 208, 1)', - neutralVariant70: 'rgba(174, 169, 180, 1)', - neutralVariant60: 'rgba(147, 143, 153, 1)', - neutralVariant50: 'rgba(121, 116, 126, 1)', - neutralVariant40: 'rgba(96, 93, 102, 1)', - neutralVariant30: 'rgba(73, 69, 79, 1)', - neutralVariant20: 'rgba(50, 47, 55, 1)', - neutralVariant10: 'rgba(29, 26, 34, 1)', - neutralVariant0: 'rgba(0, 0, 0, 1)', - error100: 'rgba(255, 255, 255, 1)', - error99: 'rgba(255, 251, 249, 1)', - error98: 'rgba(255, 248, 247, 1)', - error95: 'rgba(252, 238, 238, 1)', - error90: 'rgba(249, 222, 220, 1)', - error80: 'rgba(242, 184, 181, 1)', - error70: 'rgba(236, 146, 142, 1)', - error60: 'rgba(228, 105, 98, 1)', - error50: 'rgba(220, 54, 46, 1)', - error40: 'rgba(179, 38, 30, 1)', - error30: 'rgba(140, 29, 24, 1)', - error20: 'rgba(96, 20, 16, 1)', - error10: 'rgba(65, 14, 11, 1)', - error0: 'rgba(0, 0, 0, 1)', - }, - - typeface: { - brandRegular: Platform.select({ - web: 'Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif', - ios: 'System', - default: 'sans-serif', - }), - weightRegular: '400' as Font['fontWeight'], - - plainMedium: Platform.select({ - web: 'Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif', - ios: 'System', - default: 'sans-serif-medium', - }), - weightMedium: '500' as Font['fontWeight'], - - weightBold: '700' as Font['fontWeight'], - }, - - /** State layers opacity - * @see https://m3.material.io/foundations/interaction/states/state-layers - */ - stateOpacity: { - dragged: 0.16, - pressed: 0.1, - focused: 0.1, - hovered: 0.08, - disabled: 0.38, - enabled: 1.0, - }, - - scrimAlpha: 0.32, -}; - -const regularType = { - fontFamily: ref.typeface.brandRegular, - letterSpacing: 0, - fontWeight: ref.typeface.weightRegular, -}; - -const mediumType = { - fontFamily: ref.typeface.plainMedium, - letterSpacing: 0.15, - fontWeight: ref.typeface.weightMedium, -}; - -const emphasizedMediumType = { - fontFamily: ref.typeface.plainMedium, - letterSpacing: 0, - fontWeight: ref.typeface.weightMedium, -}; - -const emphasizedBoldType = { - fontFamily: ref.typeface.plainMedium, - letterSpacing: 0, - fontWeight: ref.typeface.weightBold, -}; - -export const typescale = { - displayLarge: { - ...regularType, - letterSpacing: -0.25, - lineHeight: 64, - fontSize: 57, - }, - displayMedium: { - ...regularType, - lineHeight: 52, - fontSize: 45, - }, - displaySmall: { - ...regularType, - lineHeight: 44, - fontSize: 36, - }, - - headlineLarge: { - ...regularType, - lineHeight: 40, - fontSize: 32, - }, - headlineMedium: { - ...regularType, - lineHeight: 36, - fontSize: 28, - }, - headlineSmall: { - ...regularType, - lineHeight: 32, - fontSize: 24, - }, - - titleLarge: { - ...regularType, - lineHeight: 28, - fontSize: 22, - }, - titleMedium: { - ...mediumType, - lineHeight: 24, - fontSize: 16, - }, - titleSmall: { - ...mediumType, - letterSpacing: 0.1, - lineHeight: 20, - fontSize: 14, - }, - - labelLarge: { - ...mediumType, - letterSpacing: 0.1, - lineHeight: 20, - fontSize: 14, - }, - labelMedium: { - ...mediumType, - letterSpacing: 0.5, - lineHeight: 16, - fontSize: 12, - }, - labelSmall: { - ...mediumType, - letterSpacing: 0.5, - lineHeight: 16, - fontSize: 11, - }, - - bodyLarge: { - ...mediumType, - fontWeight: ref.typeface.weightRegular, - fontFamily: ref.typeface.brandRegular, - letterSpacing: 0.5, - lineHeight: 24, - fontSize: 16, - }, - bodyMedium: { - ...mediumType, - fontWeight: ref.typeface.weightRegular, - fontFamily: ref.typeface.brandRegular, - letterSpacing: 0.25, - lineHeight: 20, - fontSize: 14, - }, - bodySmall: { - ...mediumType, - fontWeight: ref.typeface.weightRegular, - fontFamily: ref.typeface.brandRegular, - letterSpacing: 0.4, - lineHeight: 16, - fontSize: 12, - }, - - displayLargeEmphasized: { - ...emphasizedMediumType, - letterSpacing: -0.25, - lineHeight: 64, - fontSize: 57, - }, - displayMediumEmphasized: { - ...emphasizedMediumType, - lineHeight: 52, - fontSize: 45, - }, - displaySmallEmphasized: { - ...emphasizedMediumType, - lineHeight: 44, - fontSize: 36, - }, - - headlineLargeEmphasized: { - ...emphasizedMediumType, - lineHeight: 40, - fontSize: 32, - }, - headlineMediumEmphasized: { - ...emphasizedMediumType, - lineHeight: 36, - fontSize: 28, - }, - headlineSmallEmphasized: { - ...emphasizedMediumType, - lineHeight: 32, - fontSize: 24, - }, - - titleLargeEmphasized: { - ...emphasizedMediumType, - lineHeight: 28, - fontSize: 22, - }, - titleMediumEmphasized: { - ...emphasizedBoldType, - letterSpacing: 0.15, - lineHeight: 24, - fontSize: 16, - }, - titleSmallEmphasized: { - ...emphasizedBoldType, - letterSpacing: 0.1, - lineHeight: 20, - fontSize: 14, - }, - - labelLargeEmphasized: { - ...emphasizedBoldType, - letterSpacing: 0.1, - lineHeight: 20, - fontSize: 14, - }, - labelMediumEmphasized: { - ...emphasizedBoldType, - letterSpacing: 0.5, - lineHeight: 16, - fontSize: 12, - }, - labelSmallEmphasized: { - ...emphasizedBoldType, - letterSpacing: 0.5, - lineHeight: 16, - fontSize: 11, - }, - - bodyLargeEmphasized: { - ...emphasizedMediumType, - letterSpacing: 0.5, - lineHeight: 24, - fontSize: 16, - }, - bodyMediumEmphasized: { - ...emphasizedMediumType, - letterSpacing: 0.25, - lineHeight: 20, - fontSize: 14, - }, - bodySmallEmphasized: { - ...emphasizedMediumType, - letterSpacing: 0.4, - lineHeight: 16, - fontSize: 12, - }, - - default: { - ...regularType, - }, -}; +import { palette } from './ref/palette'; +import { typeface } from './ref/typeface'; +import { state } from './sys/state'; +import { typescale } from './sys/typography'; +/** Material Design token tree: md.ref.* (raw values) and md.sys.* (semantic decisions). */ export const tokens = { md: { - ref, + ref: { + palette, + typeface, + }, sys: { typescale, + state: state, + scrim: { alpha: 0.32 }, }, }, }; -export const Palette = ref.palette; +export { typescale }; +export const Palette = palette; diff --git a/src/theme/tokens/ref/palette.ts b/src/theme/tokens/ref/palette.ts new file mode 100644 index 0000000000..f16b793f66 --- /dev/null +++ b/src/theme/tokens/ref/palette.ts @@ -0,0 +1,97 @@ +/** md.ref.palette.* — tonal palette reference tokens. */ +export const palette = { + primary100: 'rgba(255, 255, 255, 1)', + primary99: 'rgba(255, 251, 254, 1)', + primary98: 'rgba(254, 247, 255, 1)', + primary95: 'rgba(246, 237, 255, 1)', + primary90: 'rgba(234, 221, 255, 1)', + primary80: 'rgba(208, 188, 255, 1)', + primary70: 'rgba(182, 157, 248, 1)', + primary60: 'rgba(154, 130, 219, 1)', + primary50: 'rgba(127, 103, 190, 1)', + primary40: 'rgba(103, 80, 164, 1)', + primary30: 'rgba(79, 55, 139, 1)', + primary20: 'rgba(56, 30, 114, 1)', + primary10: 'rgba(33, 0, 93, 1)', + primary0: 'rgba(0, 0, 0, 1)', + secondary100: 'rgba(255, 255, 255, 1)', + secondary99: 'rgba(255, 251, 254, 1)', + secondary98: 'rgba(254, 247, 255, 1)', + secondary95: 'rgba(246, 237, 255, 1)', + secondary90: 'rgba(232, 222, 248, 1)', + secondary80: 'rgba(204, 194, 220, 1)', + secondary70: 'rgba(176, 167, 192, 1)', + secondary60: 'rgba(149, 141, 165, 1)', + secondary50: 'rgba(122, 114, 137, 1)', + secondary40: 'rgba(98, 91, 113, 1)', + secondary30: 'rgba(74, 68, 88, 1)', + secondary20: 'rgba(51, 45, 65, 1)', + secondary10: 'rgba(29, 25, 43, 1)', + secondary0: 'rgba(0, 0, 0, 1)', + tertiary100: 'rgba(255, 255, 255, 1)', + tertiary99: 'rgba(255, 251, 250, 1)', + tertiary98: 'rgba(255, 248, 248, 1)', + tertiary95: 'rgba(255, 236, 241, 1)', + tertiary90: 'rgba(255, 216, 228, 1)', + tertiary80: 'rgba(239, 184, 200, 1)', + tertiary70: 'rgba(210, 157, 172, 1)', + tertiary60: 'rgba(181, 131, 146, 1)', + tertiary50: 'rgba(152, 105, 119, 1)', + tertiary40: 'rgba(125, 82, 96, 1)', + tertiary30: 'rgba(99, 59, 72, 1)', + tertiary20: 'rgba(73, 37, 50, 1)', + tertiary10: 'rgba(49, 17, 29, 1)', + tertiary0: 'rgba(0, 0, 0, 1)', + neutral100: 'rgba(255, 255, 255, 1)', + neutral99: 'rgba(255, 251, 255, 1)', + neutral98: 'rgba(254, 247, 255, 1)', + neutral96: 'rgba(247, 242, 250, 1)', + neutral95: 'rgba(245, 239, 247, 1)', + neutral94: 'rgba(243, 237, 247, 1)', + neutral92: 'rgba(236, 230, 240, 1)', + neutral90: 'rgba(230, 224, 233, 1)', + neutral87: 'rgba(222, 216, 225, 1)', + neutral80: 'rgba(202, 197, 205, 1)', + neutral70: 'rgba(174, 169, 177, 1)', + neutral60: 'rgba(147, 143, 150, 1)', + neutral50: 'rgba(121, 118, 125, 1)', + neutral40: 'rgba(96, 93, 100, 1)', + neutral30: 'rgba(72, 70, 76, 1)', + neutral24: 'rgba(59, 56, 62, 1)', + neutral22: 'rgba(54, 52, 59, 1)', + neutral20: 'rgba(50, 47, 53, 1)', + neutral17: 'rgba(43, 41, 48, 1)', + neutral12: 'rgba(33, 31, 38, 1)', + neutral10: 'rgba(29, 27, 32, 1)', + neutral6: 'rgba(20, 18, 24, 1)', + neutral4: 'rgba(15, 13, 19, 1)', + neutral0: 'rgba(0, 0, 0, 1)', + neutralVariant100: 'rgba(255, 255, 255, 1)', + neutralVariant99: 'rgba(255, 251, 254, 1)', + neutralVariant98: 'rgba(253, 247, 255, 1)', + neutralVariant95: 'rgba(245, 238, 250, 1)', + neutralVariant90: 'rgba(231, 224, 236, 1)', + neutralVariant80: 'rgba(202, 196, 208, 1)', + neutralVariant70: 'rgba(174, 169, 180, 1)', + neutralVariant60: 'rgba(147, 143, 153, 1)', + neutralVariant50: 'rgba(121, 116, 126, 1)', + neutralVariant40: 'rgba(96, 93, 102, 1)', + neutralVariant30: 'rgba(73, 69, 79, 1)', + neutralVariant20: 'rgba(50, 47, 55, 1)', + neutralVariant10: 'rgba(29, 26, 34, 1)', + neutralVariant0: 'rgba(0, 0, 0, 1)', + error100: 'rgba(255, 255, 255, 1)', + error99: 'rgba(255, 251, 249, 1)', + error98: 'rgba(255, 248, 247, 1)', + error95: 'rgba(252, 238, 238, 1)', + error90: 'rgba(249, 222, 220, 1)', + error80: 'rgba(242, 184, 181, 1)', + error70: 'rgba(236, 146, 142, 1)', + error60: 'rgba(228, 105, 98, 1)', + error50: 'rgba(220, 54, 46, 1)', + error40: 'rgba(179, 38, 30, 1)', + error30: 'rgba(140, 29, 24, 1)', + error20: 'rgba(96, 20, 16, 1)', + error10: 'rgba(65, 14, 11, 1)', + error0: 'rgba(0, 0, 0, 1)', +} as const; diff --git a/src/theme/tokens/ref/typeface.ts b/src/theme/tokens/ref/typeface.ts new file mode 100644 index 0000000000..e4b92318ec --- /dev/null +++ b/src/theme/tokens/ref/typeface.ts @@ -0,0 +1,22 @@ +import { Platform } from 'react-native'; + +import type { Font } from '../../types'; + +/** md.ref.typeface.* — font families and weights. */ +export const typeface = { + brandRegular: Platform.select({ + web: 'Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif', + ios: 'System', + default: 'sans-serif', + }), + weightRegular: '400' as Font['fontWeight'], + + plainMedium: Platform.select({ + web: 'Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif', + ios: 'System', + default: 'sans-serif-medium', + }), + weightMedium: '500' as Font['fontWeight'], + + weightBold: '700' as Font['fontWeight'], +}; diff --git a/src/theme/tokens/sys/color/roles.ts b/src/theme/tokens/sys/color.ts similarity index 95% rename from src/theme/tokens/sys/color/roles.ts rename to src/theme/tokens/sys/color.ts index a3b3a49695..efdb08cbba 100644 --- a/src/theme/tokens/sys/color/roles.ts +++ b/src/theme/tokens/sys/color.ts @@ -1,11 +1,11 @@ import color from 'color'; -import type { ElevationColors, ThemeColors } from '../../../types'; -import { tokens } from '../../index'; +import { state } from './state'; +import type { ElevationColors, ThemeColors } from '../../types'; +import { palette as defaultPalette } from '../ref/palette'; -type Palette = typeof tokens.md.ref.palette; +type Palette = typeof defaultPalette; type PaletteKey = keyof Palette; -type Ref = typeof tokens.md.ref; /** Roles that map 1:1 to a palette key. Excludes the computed fields. */ type MappedRoles = Omit; @@ -148,7 +148,6 @@ const elevationToTone: Record< export function buildScheme( palette: Palette, - ref: Ref, opts: { mode: 'light' | 'dark'; contrast?: Contrast } ): ThemeColors { const contrast = opts.contrast ?? 'standard'; @@ -165,7 +164,7 @@ export function buildScheme( return { ...mapped, stateLayerPressed: color(palette[tones.onSurface]) - .alpha(ref.stateOpacity.pressed) + .alpha(state.opacity.pressed) .rgb() .string(), elevation: { diff --git a/src/theme/tokens/sys/state.ts b/src/theme/tokens/sys/state.ts new file mode 100644 index 0000000000..d0742351bf --- /dev/null +++ b/src/theme/tokens/sys/state.ts @@ -0,0 +1,18 @@ +/** + * md.sys.state.* — interaction-state system tokens. + * @see https://m3.material.io/foundations/interaction/states/state-layers + */ +export const state = { + opacity: { + dragged: 0.16, + pressed: 0.1, + focused: 0.1, + hovered: 0.08, + disabled: 0.38, + enabled: 1.0, + }, + focusIndicator: { + thickness: 3, + outerOffset: 2, + }, +} as const; diff --git a/src/theme/tokens/sys/typography.ts b/src/theme/tokens/sys/typography.ts index 2ea3e08c6c..289cbdedef 100644 --- a/src/theme/tokens/sys/typography.ts +++ b/src/theme/tokens/sys/typography.ts @@ -1,4 +1,218 @@ import type { Typescale } from '../../types'; -import { typescale } from '../index'; +import { typeface } from '../ref/typeface'; + +const regularType = { + fontFamily: typeface.brandRegular, + letterSpacing: 0, + fontWeight: typeface.weightRegular, +}; + +const mediumType = { + fontFamily: typeface.plainMedium, + letterSpacing: 0.15, + fontWeight: typeface.weightMedium, +}; + +const emphasizedMediumType = { + fontFamily: typeface.plainMedium, + letterSpacing: 0, + fontWeight: typeface.weightMedium, +}; + +const emphasizedBoldType = { + fontFamily: typeface.plainMedium, + letterSpacing: 0, + fontWeight: typeface.weightBold, +}; + +/** md.sys.typescale.* */ +export const typescale = { + displayLarge: { + ...regularType, + letterSpacing: -0.25, + lineHeight: 64, + fontSize: 57, + }, + displayMedium: { + ...regularType, + lineHeight: 52, + fontSize: 45, + }, + displaySmall: { + ...regularType, + lineHeight: 44, + fontSize: 36, + }, + + headlineLarge: { + ...regularType, + lineHeight: 40, + fontSize: 32, + }, + headlineMedium: { + ...regularType, + lineHeight: 36, + fontSize: 28, + }, + headlineSmall: { + ...regularType, + lineHeight: 32, + fontSize: 24, + }, + + titleLarge: { + ...regularType, + lineHeight: 28, + fontSize: 22, + }, + titleMedium: { + ...mediumType, + lineHeight: 24, + fontSize: 16, + }, + titleSmall: { + ...mediumType, + letterSpacing: 0.1, + lineHeight: 20, + fontSize: 14, + }, + + labelLarge: { + ...mediumType, + letterSpacing: 0.1, + lineHeight: 20, + fontSize: 14, + }, + labelMedium: { + ...mediumType, + letterSpacing: 0.5, + lineHeight: 16, + fontSize: 12, + }, + labelSmall: { + ...mediumType, + letterSpacing: 0.5, + lineHeight: 16, + fontSize: 11, + }, + + bodyLarge: { + ...mediumType, + fontWeight: typeface.weightRegular, + fontFamily: typeface.brandRegular, + letterSpacing: 0.5, + lineHeight: 24, + fontSize: 16, + }, + bodyMedium: { + ...mediumType, + fontWeight: typeface.weightRegular, + fontFamily: typeface.brandRegular, + letterSpacing: 0.25, + lineHeight: 20, + fontSize: 14, + }, + bodySmall: { + ...mediumType, + fontWeight: typeface.weightRegular, + fontFamily: typeface.brandRegular, + letterSpacing: 0.4, + lineHeight: 16, + fontSize: 12, + }, + + displayLargeEmphasized: { + ...emphasizedMediumType, + letterSpacing: -0.25, + lineHeight: 64, + fontSize: 57, + }, + displayMediumEmphasized: { + ...emphasizedMediumType, + lineHeight: 52, + fontSize: 45, + }, + displaySmallEmphasized: { + ...emphasizedMediumType, + lineHeight: 44, + fontSize: 36, + }, + + headlineLargeEmphasized: { + ...emphasizedMediumType, + lineHeight: 40, + fontSize: 32, + }, + headlineMediumEmphasized: { + ...emphasizedMediumType, + lineHeight: 36, + fontSize: 28, + }, + headlineSmallEmphasized: { + ...emphasizedMediumType, + lineHeight: 32, + fontSize: 24, + }, + + titleLargeEmphasized: { + ...emphasizedMediumType, + lineHeight: 28, + fontSize: 22, + }, + titleMediumEmphasized: { + ...emphasizedBoldType, + letterSpacing: 0.15, + lineHeight: 24, + fontSize: 16, + }, + titleSmallEmphasized: { + ...emphasizedBoldType, + letterSpacing: 0.1, + lineHeight: 20, + fontSize: 14, + }, + + labelLargeEmphasized: { + ...emphasizedBoldType, + letterSpacing: 0.1, + lineHeight: 20, + fontSize: 14, + }, + labelMediumEmphasized: { + ...emphasizedBoldType, + letterSpacing: 0.5, + lineHeight: 16, + fontSize: 12, + }, + labelSmallEmphasized: { + ...emphasizedBoldType, + letterSpacing: 0.5, + lineHeight: 16, + fontSize: 11, + }, + + bodyLargeEmphasized: { + ...emphasizedMediumType, + letterSpacing: 0.5, + lineHeight: 24, + fontSize: 16, + }, + bodyMediumEmphasized: { + ...emphasizedMediumType, + letterSpacing: 0.25, + lineHeight: 20, + fontSize: 14, + }, + bodySmallEmphasized: { + ...emphasizedMediumType, + letterSpacing: 0.4, + lineHeight: 16, + fontSize: 12, + }, + + default: { + ...regularType, + }, +}; export const defaultFonts: Typescale = typescale; diff --git a/src/theme/types/color.ts b/src/theme/types/color.ts index 1e49724b3e..84879a36ec 100644 --- a/src/theme/types/color.ts +++ b/src/theme/types/color.ts @@ -2,6 +2,10 @@ import type { ColorValue } from 'react-native'; import type { ElevationColors } from './elevation'; +export type ColorRole = { + [K in keyof ThemeColors]: ThemeColors[K] extends ColorValue ? K : never; +}[keyof ThemeColors]; + export type ThemeColors = { primary: ColorValue; primaryContainer: ColorValue; diff --git a/src/theme/types/index.ts b/src/theme/types/index.ts index cc1ffe21ef..b45fea45d5 100644 --- a/src/theme/types/index.ts +++ b/src/theme/types/index.ts @@ -3,6 +3,6 @@ export * from './elevation'; export * from './motion'; export * from './navigation'; export * from './shape'; +export * from './state'; export * from './theme'; export * from './typography'; -export * from './utils'; diff --git a/src/theme/types/state.ts b/src/theme/types/state.ts new file mode 100644 index 0000000000..7577d21ce4 --- /dev/null +++ b/src/theme/types/state.ts @@ -0,0 +1,10 @@ +import type { ColorValue } from 'react-native'; + +import type { tokens } from '../tokens'; + +export type StateOpacityKey = keyof typeof tokens.md.sys.state.opacity; + +export type StateLayer = { + color: ColorValue; + opacity: number; +}; diff --git a/src/theme/types/utils.ts b/src/theme/types/utils.ts deleted file mode 100644 index 0ea6b262b9..0000000000 --- a/src/theme/types/utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type * as React from 'react'; - -export type $Omit = Pick>; -export type $RemoveChildren> = $Omit< - React.ComponentPropsWithoutRef, - 'children' ->; - -export type EllipsizeProp = 'head' | 'middle' | 'tail' | 'clip'; diff --git a/src/theme/utils/state.ts b/src/theme/utils/state.ts new file mode 100644 index 0000000000..8addc54876 --- /dev/null +++ b/src/theme/utils/state.ts @@ -0,0 +1,23 @@ +import { tokens } from '../tokens'; +import type { ColorRole, StateLayer, StateOpacityKey, Theme } from '../types'; + +const stateOpacity = tokens.md.sys.state.opacity; + +/** + * Resolve a `{ color, opacity }` state-layer for a color role + interaction + * state. Reads `theme.colors[role]` live, so it stays correct under deeply-merged theme overrides. + * + * @example + * const stateLayer = getStateLayer(theme, 'primary', 'hovered'); + * // { color: theme.colors.primary, opacity: 0.08 } + */ +export function getStateLayer( + theme: Theme, + role: ColorRole, + state: StateOpacityKey +): StateLayer { + return { + color: theme.colors[role], + opacity: stateOpacity[state], + }; +} diff --git a/src/types.tsx b/src/types.tsx index 261b55f0b3..c84e3915b4 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -1 +1,11 @@ +import type * as React from 'react'; + export * from './theme/types'; + +export type $Omit = Pick>; +export type $RemoveChildren> = $Omit< + React.ComponentPropsWithoutRef, + 'children' +>; + +export type EllipsizeProp = 'head' | 'middle' | 'tail' | 'clip'; From 471ec0a60836edffa9d1bf99134327e9dd21a0f9 Mon Sep 17 00:00:00 2001 From: Adrian Cotfas Date: Tue, 19 May 2026 12:34:46 +0300 Subject: [PATCH 2/7] refactor: add shadow overrides --- src/theme/tokens/sys/elevation.ts | 32 +++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/theme/tokens/sys/elevation.ts b/src/theme/tokens/sys/elevation.ts index f02be56f29..488208edd6 100644 --- a/src/theme/tokens/sys/elevation.ts +++ b/src/theme/tokens/sys/elevation.ts @@ -1,11 +1,26 @@ // M3 elevation tokens and shadow builder per spec: // https://m3.material.io/styles/elevation/tokens -import { Animated, type ColorValue } from 'react-native'; +import { + Animated, + type ColorValue, + type ViewStyle, + type Animated as AnimatedNS, +} from 'react-native'; import { isAnimatedValue } from '../../../utils/animations'; import type { Elevation, ThemeElevation } from '../../types'; +type AnimatedShadowStyle = { + shadowColor: ColorValue; + shadowOffset: { + width: AnimatedNS.Value; + height: AnimatedNS.AnimatedInterpolation; + }; + shadowOpacity: AnimatedNS.AnimatedInterpolation; + shadowRadius: AnimatedNS.AnimatedInterpolation; +}; + export const defaultElevation: ThemeElevation = { level0: 0, level1: 1, @@ -32,10 +47,23 @@ export const shadowLayers = [ }, ]; +// eslint-disable-next-line no-redeclare +export function shadow(elevation: number, shadowColor: ColorValue): ViewStyle; +// eslint-disable-next-line no-redeclare +export function shadow( + elevation: Animated.Value, + shadowColor: ColorValue +): AnimatedShadowStyle; +// eslint-disable-next-line no-redeclare +export function shadow( + elevation: number | Animated.Value, + shadowColor: ColorValue +): ViewStyle | AnimatedShadowStyle; +// eslint-disable-next-line no-redeclare export function shadow( elevation: number | Animated.Value = 0, shadowColor: ColorValue -) { +): ViewStyle | AnimatedShadowStyle { if (isAnimatedValue(elevation)) { return { shadowColor, From 594a4feda6870ed5c4a52e989d0bc7d69d5bbab7 Mon Sep 17 00:00:00 2001 From: Adrian Cotfas Date: Tue, 19 May 2026 14:21:33 +0300 Subject: [PATCH 3/7] feat: add useFocusVisible hook --- src/utils/useFocusVisible.ts | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/utils/useFocusVisible.ts diff --git a/src/utils/useFocusVisible.ts b/src/utils/useFocusVisible.ts new file mode 100644 index 0000000000..c8798d64be --- /dev/null +++ b/src/utils/useFocusVisible.ts @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { Platform } from 'react-native'; + +const isWeb = Platform.OS === 'web'; + +type FocusVisibleEvent = { + currentTarget: object; +}; + +/** + * Convenience hook for components that gate a focus indicator on + * `:focus-visible` semantics. Returns `{ focusVisible, onFocus, onBlur }`; + * wire `onFocus` / `onBlur` to a `Pressable` (or equivalent) and gate the + * indicator on `focusVisible`. + * + * On web, delegates to the browser's `:focus-visible` matcher. On native, + * `onFocus` only fires for keyboard-style focus (external keyboard, D-pad, + * a11y navigation, programmatic focus), so `focusVisible` is always `true` + * while focused. + */ +export function useFocusVisible() { + const [focusVisible, setFocusVisible] = React.useState(false); + const onFocus = React.useCallback((e: FocusVisibleEvent) => { + if (!isWeb) { + setFocusVisible(true); + return; + } + const target = e.currentTarget; + const matches = + 'matches' in target && typeof target.matches === 'function' + ? target.matches + : null; + setFocusVisible(!!matches?.call(target, ':focus-visible')); + }, []); + const onBlur = React.useCallback(() => setFocusVisible(false), []); + return { focusVisible, onFocus, onBlur }; +} From 80ade2f69bfb35da0b559c448fe2630c692749f3 Mon Sep 17 00:00:00 2001 From: Adrian Cotfas Date: Tue, 19 May 2026 16:37:28 +0300 Subject: [PATCH 4/7] feat(motion): include mass in toRawSpring --- src/theme/tokens/sys/motion.ts | 18 ++++++++++++++++-- src/theme/types/motion.ts | 6 ++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/theme/tokens/sys/motion.ts b/src/theme/tokens/sys/motion.ts index a7c96454f2..878d63e0e9 100644 --- a/src/theme/tokens/sys/motion.ts +++ b/src/theme/tokens/sys/motion.ts @@ -2,6 +2,7 @@ import type { MotionConfig, MotionDuration, MotionEasing, + RawSpring, SpringConfig, } from '../../types'; @@ -93,7 +94,20 @@ export const standardMotion: MotionConfig = { * ...toRawSpring(theme.motion.spring.fast.spatial), * useNativeDriver: true, * }); + * + * // Reanimated + * sharedValue.value = withSpring( + * target, + * toRawSpring(theme.motion.spring.fast.spatial) + * ); */ -export function toRawSpring({ stiffness, damping }: SpringConfig) { - return { stiffness, damping: damping * 2 * Math.sqrt(stiffness) }; +export function toRawSpring({ + stiffness, + damping: ratio, +}: SpringConfig): RawSpring { + return { + stiffness, + damping: ratio * 2 * Math.sqrt(stiffness), + mass: 1, // as per MD specs + }; } diff --git a/src/theme/types/motion.ts b/src/theme/types/motion.ts index a0e037f524..f744e93d08 100644 --- a/src/theme/types/motion.ts +++ b/src/theme/types/motion.ts @@ -45,3 +45,9 @@ export type MotionConfig = { easing: MotionEasing; duration: MotionDuration; }; + +export type RawSpring = { + stiffness: number; + damping: number; + mass: number; +}; From afdec9e264461665338ea4a46fae36e92ba03213 Mon Sep 17 00:00:00 2001 From: Adrian Cotfas Date: Fri, 22 May 2026 14:39:59 +0300 Subject: [PATCH 5/7] feat(theme): add contentColorFor helper --- src/theme/utils/color.ts | 66 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/theme/utils/color.ts diff --git a/src/theme/utils/color.ts b/src/theme/utils/color.ts new file mode 100644 index 0000000000..c13e8797e3 --- /dev/null +++ b/src/theme/utils/color.ts @@ -0,0 +1,66 @@ +import type { ColorValue } from 'react-native'; + +import type { Theme } from '../types'; + +/** + * Resolve the on-color (content color) that pairs with a given background + * role from the theme's color scheme. + * + * If `background` does not match any known role color (e.g. an arbitrary hex + * value), falls back to `onSurface`. + * + * @example + * const fg = contentColorFor(theme, theme.colors.primaryContainer); + * // theme.colors.onPrimaryContainer + */ +export function contentColorFor( + theme: Theme, + background: ColorValue +): ColorValue { + const c = theme.colors; + + switch (background) { + case c.primary: + return c.onPrimary; + case c.secondary: + return c.onSecondary; + case c.tertiary: + return c.onTertiary; + case c.background: + return c.onBackground; + case c.error: + return c.onError; + case c.primaryContainer: + return c.onPrimaryContainer; + case c.secondaryContainer: + return c.onSecondaryContainer; + case c.tertiaryContainer: + return c.onTertiaryContainer; + case c.errorContainer: + return c.onErrorContainer; + case c.inverseSurface: + return c.inverseOnSurface; + case c.surfaceVariant: + return c.onSurfaceVariant; + case c.surface: + case c.surfaceBright: + case c.surfaceDim: + case c.surfaceContainerLowest: + case c.surfaceContainerLow: + case c.surfaceContainer: + case c.surfaceContainerHigh: + case c.surfaceContainerHighest: + return c.onSurface; + case c.primaryFixed: + case c.primaryFixedDim: + return c.onPrimaryFixed; + case c.secondaryFixed: + case c.secondaryFixedDim: + return c.onSecondaryFixed; + case c.tertiaryFixed: + case c.tertiaryFixedDim: + return c.onTertiaryFixed; + default: + return c.onSurface; + } +} From b41006a3034221db96f63d1ae2978139bfa934bc Mon Sep 17 00:00:00 2001 From: Adrian Cotfas Date: Mon, 25 May 2026 09:19:58 +0300 Subject: [PATCH 6/7] refactor: remove useFocusVisible.ts --- src/components/RadioButton/utils.ts | 2 +- .../__tests__/RadioButton/utils.test.tsx | 2 +- src/utils/useFocusVisible.ts | 37 ------------------- 3 files changed, 2 insertions(+), 39 deletions(-) delete mode 100644 src/utils/useFocusVisible.ts diff --git a/src/components/RadioButton/utils.ts b/src/components/RadioButton/utils.ts index 9cd776ad8d..96adafaf47 100644 --- a/src/components/RadioButton/utils.ts +++ b/src/components/RadioButton/utils.ts @@ -3,7 +3,7 @@ import type { ColorValue, GestureResponderEvent } from 'react-native'; import { tokens } from '../../theme/tokens'; import type { InternalTheme } from '../../types'; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; export const handlePress = ({ onPress, diff --git a/src/components/__tests__/RadioButton/utils.test.tsx b/src/components/__tests__/RadioButton/utils.test.tsx index 929d0f7a2c..12486a9c3b 100644 --- a/src/components/__tests__/RadioButton/utils.test.tsx +++ b/src/components/__tests__/RadioButton/utils.test.tsx @@ -2,7 +2,7 @@ import { getTheme } from '../../../core/theming'; import { tokens } from '../../../theme/tokens'; import { getSelectionControlIOSColor } from '../../RadioButton/utils'; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; describe('getSelectionControlIOSColor - checked color', () => { it('should return correct disabled color, for theme version 3', () => { diff --git a/src/utils/useFocusVisible.ts b/src/utils/useFocusVisible.ts deleted file mode 100644 index c8798d64be..0000000000 --- a/src/utils/useFocusVisible.ts +++ /dev/null @@ -1,37 +0,0 @@ -import * as React from 'react'; -import { Platform } from 'react-native'; - -const isWeb = Platform.OS === 'web'; - -type FocusVisibleEvent = { - currentTarget: object; -}; - -/** - * Convenience hook for components that gate a focus indicator on - * `:focus-visible` semantics. Returns `{ focusVisible, onFocus, onBlur }`; - * wire `onFocus` / `onBlur` to a `Pressable` (or equivalent) and gate the - * indicator on `focusVisible`. - * - * On web, delegates to the browser's `:focus-visible` matcher. On native, - * `onFocus` only fires for keyboard-style focus (external keyboard, D-pad, - * a11y navigation, programmatic focus), so `focusVisible` is always `true` - * while focused. - */ -export function useFocusVisible() { - const [focusVisible, setFocusVisible] = React.useState(false); - const onFocus = React.useCallback((e: FocusVisibleEvent) => { - if (!isWeb) { - setFocusVisible(true); - return; - } - const target = e.currentTarget; - const matches = - 'matches' in target && typeof target.matches === 'function' - ? target.matches - : null; - setFocusVisible(!!matches?.call(target, ':focus-visible')); - }, []); - const onBlur = React.useCallback(() => setFocusVisible(false), []); - return { focusVisible, onFocus, onBlur }; -} From cee73173542643df2987cb61347b2c5a3a797247 Mon Sep 17 00:00:00 2001 From: Fabrizio Cucci Date: Fri, 22 May 2026 08:43:13 +0100 Subject: [PATCH 7/7] feat(checkbox): modernize for md3 spec compliance Rewrites the Checkbox renderer to match the Material Design 3 spec (https://m3.material.io/components/checkbox/specs): - 18dp container with 2dp outline (unselected) / 0dp outline + theme primary fill (selected), inside a 40dp state-layer tap target. - State-layer overlay renders hover (8%), focus (10%) and pressed (10%) layers in the color the spec defines for each (selected pressed flips to onSurface; error always wins). - Focus indicator: 3dp ring at theme.colors.secondary with the 2dp outer-offset from md.sys.state.focusIndicator. Gated on :focus-visible via the useFocusVisible hook added in #4952. - Animations approximate Compose Material3 Checkbox.kt: 100ms fill transition and 150ms checkmark draw, sequenced short-leg then long-leg to suggest the stroke fraction. Indeterminate uses a scaleX-animated dash. - No new peer-deps: the checkmark is built from two rotated rectangles (View-based), not an SVG path. utils.ts: - New getSelectionVisualState helper returns the full color + opacity + outline-width picture for a given state combo. - Legacy getSelectionControlColor kept as a compatibility export for RadioButtonAndroid (radio button modernization is out of scope for this PR). 9 snapshots auto-updated to reflect the new render tree. --- src/components/Checkbox/Checkbox.tsx | 304 ++-- src/components/Checkbox/tokens.ts | 41 + src/components/Checkbox/utils.ts | 219 ++- .../__tests__/Checkbox/CheckboxItem.test.tsx | 9 +- .../__snapshots__/Checkbox.test.tsx.snap | 1236 ++++++++++------- .../__snapshots__/CheckboxItem.test.tsx.snap | 358 +++-- src/utils/useFocusVisible.ts | 37 + 7 files changed, 1433 insertions(+), 771 deletions(-) create mode 100644 src/components/Checkbox/tokens.ts create mode 100644 src/utils/useFocusVisible.ts diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 69087a861e..efe2719038 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -3,17 +3,20 @@ import { Animated, ColorValue, GestureResponderEvent, + Pressable, StyleSheet, View, } from 'react-native'; -import { getSelectionControlColor } from './utils'; +import { CheckboxTokens } from './tokens'; +import { getSelectionVisualState } from './utils'; import { useInternalTheme } from '../../core/theming'; -import type { $RemoveChildren, ThemeProp } from '../../types'; -import MaterialCommunityIcon from '../MaterialCommunityIcon'; -import TouchableRipple from '../TouchableRipple/TouchableRipple'; +import { tokens } from '../../theme/tokens'; +import type { ThemeProp } from '../../types'; +import useAnimatedValue from '../../utils/useAnimatedValue'; +import { useFocusVisible } from '../../utils/useFocusVisible'; -export type Props = $RemoveChildren & { +export type Props = { /** * Status of checkbox. */ @@ -36,9 +39,9 @@ export type Props = $RemoveChildren & { color?: ColorValue; /** * Whether the checkbox is in an error state. When true, the outline - * (unchecked) and container (checked / indeterminate) use - * `theme.colors.error`. `disabled` and explicit `color`/`uncheckedColor` - * overrides take precedence. + * (unchecked) and container (selected) use `theme.colors.error`. + * `disabled` and explicit `color`/`uncheckedColor` overrides take + * precedence. */ error?: boolean; /** @@ -51,7 +54,7 @@ export type Props = $RemoveChildren & { testID?: string; }; -const ANIMATION_DURATION = 100; +const { focusIndicator } = tokens.md.sys.state; /** * Checkboxes allow the selection of multiple options from a set. @@ -84,121 +87,238 @@ const Checkbox = ({ onPress, testID, error, - ...rest + color, + uncheckedColor, }: Props) => { const theme = useInternalTheme(themeOverrides); - const { current: scaleAnim } = React.useRef( - new Animated.Value(1) - ); - const isFirstRendering = React.useRef(true); + const { focusVisible, onFocus, onBlur } = useFocusVisible(); + const [hovered, setHovered] = React.useState(false); + const [pressed, setPressed] = React.useState(false); + + const selected = status === 'checked' || status === 'indeterminate'; const { animation: { scale }, } = theme; + // 0 = unselected (outline only), 1 = selected (filled + drawn icon). + const fillAnim = useAnimatedValue(selected ? 1 : 0); + const checkAnim = useAnimatedValue(selected ? 1 : 0); + const firstRender = React.useRef(true); + React.useEffect(() => { - // Do not run animation on very first rendering - if (isFirstRendering.current) { - isFirstRendering.current = false; + if (firstRender.current) { + firstRender.current = false; return; } + Animated.timing(fillAnim, { + toValue: selected ? 1 : 0, + duration: CheckboxTokens.fillDuration * scale, + useNativeDriver: true, + }).start(); + Animated.timing(checkAnim, { + toValue: selected ? 1 : 0, + duration: CheckboxTokens.checkDuration * scale, + useNativeDriver: false, + }).start(); + }, [selected, fillAnim, checkAnim, scale]); - const checked = status === 'checked'; - - Animated.sequence([ - Animated.timing(scaleAnim, { - toValue: 0.85, - duration: checked ? ANIMATION_DURATION * scale : 0, - useNativeDriver: false, - }), - Animated.timing(scaleAnim, { - toValue: 1, - duration: checked - ? ANIMATION_DURATION * scale - : ANIMATION_DURATION * scale * 1.75, - useNativeDriver: false, - }), - ]).start(); - }, [status, scaleAnim, scale]); - - const checked = status === 'checked'; - const indeterminate = status === 'indeterminate'; - - const { selectionControlColor, selectionControlOpacity } = - getSelectionControlColor({ - theme, - disabled, - checked, - customColor: rest.color, - customUncheckedColor: rest.uncheckedColor, - error, - }); - - const borderWidth = scaleAnim.interpolate({ - inputRange: [0.8, 1], - outputRange: [7, 0], + const visual = getSelectionVisualState({ + theme, + selected, + disabled, + hovered, + pressed, + error, + customColor: color, + customUncheckedColor: uncheckedColor, }); - const icon = indeterminate - ? 'minus-box' - : checked - ? 'checkbox-marked' - : 'checkbox-blank-outline'; + // Outline fades out as fill fades in (and vice versa). + const outlineOpacity = fillAnim.interpolate({ + inputRange: [0, 1], + outputRange: [1, 0], + }); + + // Remember which glyph to render so the reveal-mask can still collapse + // when transitioning back to 'unchecked' (selected becomes false, but + // we keep showing the previous glyph until checkAnim hits 0). + const lastGlyph = React.useRef<'check' | 'indeterminate'>('check'); + if (status === 'checked') lastGlyph.current = 'check'; + else if (status === 'indeterminate') lastGlyph.current = 'indeterminate'; + const showIndeterminate = lastGlyph.current === 'indeterminate'; return ( - setHovered(true)} + onHoverOut={() => setHovered(false)} + onPressIn={() => setPressed(true)} + onPressOut={() => setPressed(false)} disabled={disabled} accessibilityRole="checkbox" - accessibilityState={{ disabled, checked }} + accessibilityState={{ + disabled, + checked: status === 'indeterminate' ? 'mixed' : status === 'checked', + }} accessibilityLiveRegion="polite" - style={styles.container} testID={testID} - theme={theme} + style={styles.tapTarget} > - + {focusVisible && !disabled ? ( + + ) : null} + - + - - - - - + + {showIndeterminate ? ( + + ) : ( + + )} + + + + ); +}; + +/** + * Reveal-mask wrapper: animates its width from 0 -> containerSize so the + * child glyph "draws in" left-to-right, approximating Compose Material3's + * stroke-fraction animation without an SVG dependency. + */ +const RevealMask = ({ + progress, + children, +}: { + progress: Animated.Value; + children: React.ReactNode; +}) => { + const maskWidth = progress.interpolate({ + inputRange: [0, 1], + outputRange: [0, CheckboxTokens.containerSize], + }); + return ( + + {children} + ); }; const styles = StyleSheet.create({ - container: { - borderRadius: 18, - width: 36, - height: 36, - padding: 6, + tapTarget: { + width: CheckboxTokens.stateLayerSize, + height: CheckboxTokens.stateLayerSize, + alignItems: 'center', + justifyContent: 'center', }, - fillContainer: { + stateLayer: { + position: 'absolute', + top: 0, + left: 0, + width: CheckboxTokens.stateLayerSize, + height: CheckboxTokens.stateLayerSize, + borderRadius: CheckboxTokens.stateLayerSize / 2, + }, + focusRing: { + position: 'absolute', + top: -focusIndicator.outerOffset, + left: -focusIndicator.outerOffset, + width: CheckboxTokens.stateLayerSize + focusIndicator.outerOffset * 2, + height: CheckboxTokens.stateLayerSize + focusIndicator.outerOffset * 2, + borderRadius: + (CheckboxTokens.stateLayerSize + focusIndicator.outerOffset * 2) / 2, + }, + container: { + width: CheckboxTokens.containerSize, + height: CheckboxTokens.containerSize, + borderRadius: CheckboxTokens.containerRadius, alignItems: 'center', justifyContent: 'center', + overflow: 'hidden', }, fill: { - height: 14, - width: 14, + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: CheckboxTokens.containerRadius, + }, + outline: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderWidth: CheckboxTokens.outlineWidth, + borderRadius: CheckboxTokens.containerRadius, + }, + dash: { + width: CheckboxTokens.indeterminateWidth, + height: CheckboxTokens.indeterminateHeight, + borderRadius: CheckboxTokens.indeterminateRadius, + }, + checkmarkMask: { + position: 'absolute', + left: 0, + top: 0, + height: CheckboxTokens.containerSize, + overflow: 'hidden', + }, + checkmarkContent: { + width: CheckboxTokens.containerSize, + height: CheckboxTokens.containerSize, + alignItems: 'center', + justifyContent: 'center', + }, + checkmarkGlyph: { + width: CheckboxTokens.checkmarkWidth, + height: CheckboxTokens.checkmarkHeight, + borderLeftWidth: CheckboxTokens.checkmarkStrokeWidth, + borderBottomWidth: CheckboxTokens.checkmarkStrokeWidth, + transform: [{ rotate: '-45deg' }, { translateY: -1 }, { translateX: 1 }], }, }); diff --git a/src/components/Checkbox/tokens.ts b/src/components/Checkbox/tokens.ts new file mode 100644 index 0000000000..944caf98ce --- /dev/null +++ b/src/components/Checkbox/tokens.ts @@ -0,0 +1,41 @@ +import type { ColorRole } from '../../theme/types'; + +// MD3 Checkbox spec: https://m3.material.io/components/checkbox/specs +const sizes = { + containerSize: 18, + containerRadius: 2, + outlineWidth: 2, + stateLayerSize: 40, + + checkmarkWidth: 11, + checkmarkHeight: 6, + checkmarkStrokeWidth: 2, + indeterminateWidth: 10, + indeterminateHeight: 2, + indeterminateRadius: 1, + + fillDuration: 100, + checkDuration: 150, +} as const; + +const colors = { + containerColor: 'primary', + iconColor: 'onPrimary', + outlineColor: 'onSurfaceVariant', + + errorContainerColor: 'error', + errorIconColor: 'onError', + errorOutlineColor: 'error', + + disabledContainerColor: 'onSurface', + disabledIconColor: 'surface', + disabledOutlineColor: 'onSurface', + + selectedStateLayerColor: 'primary', + unselectedStateLayerColor: 'onSurface', + errorStateLayerColor: 'error', + + focusIndicatorColor: 'secondary', +} as const satisfies Record; + +export const CheckboxTokens = { ...sizes, ...colors }; diff --git a/src/components/Checkbox/utils.ts b/src/components/Checkbox/utils.ts index 10f6d4eb1e..3dcbb32589 100644 --- a/src/components/Checkbox/utils.ts +++ b/src/components/Checkbox/utils.ts @@ -1,73 +1,177 @@ import type { ColorValue } from 'react-native'; +import { CheckboxTokens } from './tokens'; import { tokens } from '../../theme/tokens'; +import type { ColorRole } from '../../theme/types'; +import { getStateLayer } from '../../theme/utils/state'; import type { InternalTheme } from '../../types'; +// MD3 Checkbox spec: https://m3.material.io/components/checkbox/specs + const stateOpacity = tokens.md.sys.state.opacity; -const getCheckedColor = ({ - theme, - customColor, - error, -}: { +type StaticState = { theme: InternalTheme; - customColor?: ColorValue; + selected: boolean; + disabled?: boolean; error?: boolean; -}) => { + customColor?: ColorValue; + customUncheckedColor?: ColorValue; +}; + +type SelectionState = StaticState & { + hovered?: boolean; + pressed?: boolean; +}; + +type SelectionVisualState = { + containerColor: ColorValue; + outlineColor: ColorValue; + containerOpacity: number; + iconColor: ColorValue; + stateLayerColor: ColorValue; + stateLayerOpacity: number; +}; + +const getContainerColor = ({ + theme, + disabled, + error, + customColor, +}: StaticState): ColorValue => { + if (disabled) { + return theme.colors[CheckboxTokens.disabledContainerColor]; + } if (customColor) { return customColor; } - if (error) { - return theme.colors.error; + return theme.colors[CheckboxTokens.errorContainerColor]; } - - return theme.colors.primary; + return theme.colors[CheckboxTokens.containerColor]; }; -const getUncheckedColor = ({ +const getOutlineColor = ({ theme, - customUncheckedColor, + disabled, error, -}: { - theme: InternalTheme; - customUncheckedColor?: ColorValue; - error?: boolean; -}) => { + customUncheckedColor, +}: StaticState): ColorValue => { + if (disabled) { + return theme.colors[CheckboxTokens.disabledOutlineColor]; + } if (customUncheckedColor) { return customUncheckedColor; } - if (error) { - return theme.colors.error; + return theme.colors[CheckboxTokens.errorOutlineColor]; } - - return theme.colors.onSurfaceVariant; + return theme.colors[CheckboxTokens.outlineColor]; }; -const getControlColor = ({ +const getIconColor = ({ theme, - checked, + selected, disabled, - checkedColor, - uncheckedColor, -}: { - theme: InternalTheme; - checked: boolean; - checkedColor: ColorValue; - uncheckedColor: ColorValue; - disabled?: boolean; -}) => { + error, +}: StaticState): ColorValue => { + if (!selected) { + return 'transparent'; + } if (disabled) { - return theme.colors.onSurface; + return theme.colors[CheckboxTokens.disabledIconColor]; } - - if (checked) { - return checkedColor; + if (error) { + return theme.colors[CheckboxTokens.errorIconColor]; } - return uncheckedColor; + return theme.colors[CheckboxTokens.iconColor]; +}; + +// The MD3 spec renders the focused state as an outline ring only (no +// state-layer fill), so `focused` is intentionally not handled here. +const resolveStateLayer = ({ + theme, + selected, + hovered, + pressed, + error, +}: Omit): { + color: ColorValue; + opacity: number; +} => { + const state = pressed ? 'pressed' : hovered ? 'hovered' : null; + if (!state) return { color: 'transparent', opacity: 0 }; + + // Pressed flips selected/unselected colors per the MD3 spec. + const role: ColorRole = error + ? CheckboxTokens.errorStateLayerColor + : (pressed ? !selected : selected) + ? CheckboxTokens.selectedStateLayerColor + : CheckboxTokens.unselectedStateLayerColor; + return getStateLayer(theme, role, state); }; +/** + * Resolve the full color + opacity picture for the Checkbox renderer. + * + * Returns flat values so the renderer can pass them straight to its + * `Animated.View` styles without re-deriving anything. + */ +export const getSelectionVisualState = ({ + theme, + selected, + disabled, + hovered, + pressed, + error, + customColor, + customUncheckedColor, +}: SelectionState): SelectionVisualState => { + const containerColor = getContainerColor({ + theme, + selected, + disabled, + error, + customColor, + customUncheckedColor, + }); + const outlineColor = getOutlineColor({ + theme, + selected, + disabled, + error, + customColor, + customUncheckedColor, + }); + const iconColor = getIconColor({ + theme, + selected, + disabled, + error, + customColor, + customUncheckedColor, + }); + const stateLayer = resolveStateLayer({ + theme, + selected, + hovered: hovered && !disabled, + pressed: pressed && !disabled, + error, + }); + return { + containerColor, + outlineColor, + containerOpacity: disabled ? stateOpacity.disabled : stateOpacity.enabled, + iconColor, + stateLayerColor: stateLayer.color, + stateLayerOpacity: stateLayer.opacity, + }; +}; + +// Legacy helper consumed by `RadioButtonAndroid` (its color logic was +// historically shared with the pre-MD3 Checkbox). Kept here until +// RadioButton is modernized the same way Checkbox now is; at that point +// the radio variant will own its colors and this export can be deleted. export const getSelectionControlColor = ({ theme, disabled, @@ -82,25 +186,22 @@ export const getSelectionControlColor = ({ customColor?: ColorValue; customUncheckedColor?: ColorValue; error?: boolean; -}) => { - const checkedColor = getCheckedColor({ theme, customColor, error }); - const uncheckedColor = getUncheckedColor({ - theme, - customUncheckedColor, - error, - }); - const selectionControlOpacity = disabled - ? stateOpacity.disabled - : stateOpacity.enabled; - - return { - selectionControlColor: getControlColor({ - theme, - disabled, - checked, - checkedColor, - uncheckedColor, - }), - selectionControlOpacity, - }; +}): { selectionControlColor: ColorValue; selectionControlOpacity: number } => { + const opacity = disabled ? stateOpacity.disabled : stateOpacity.enabled; + const checkedColor = customColor + ? customColor + : error + ? theme.colors.error + : theme.colors.primary; + const uncheckedColor = customUncheckedColor + ? customUncheckedColor + : error + ? theme.colors.error + : theme.colors.onSurfaceVariant; + const color = disabled + ? theme.colors.onSurface + : checked + ? checkedColor + : uncheckedColor; + return { selectionControlColor: color, selectionControlOpacity: opacity }; }; diff --git a/src/components/__tests__/Checkbox/CheckboxItem.test.tsx b/src/components/__tests__/Checkbox/CheckboxItem.test.tsx index 7018d01c38..fb161dbbe9 100644 --- a/src/components/__tests__/Checkbox/CheckboxItem.test.tsx +++ b/src/components/__tests__/Checkbox/CheckboxItem.test.tsx @@ -42,13 +42,16 @@ it('should have `accessibilityState={ checked: false }` when `status="unchecked" expect(elements).toHaveLength(2); }); -it('should have `accessibilityState={ checked: false }` when `status="indeterminate"', () => { +it('should have `accessibilityState={ checked: "mixed" }` when `status="indeterminate"`', () => { const { getAllByA11yState } = render( ); - const elements = getAllByA11yState({ checked: false }); - expect(elements).toHaveLength(2); + // The inner Checkbox exposes `checked: "mixed"` (per W3C ARIA spec for + // tri-state controls), while the outer row Pressable exposes + // `checked: false`. + expect(getAllByA11yState({ checked: 'mixed' })).toHaveLength(1); + expect(getAllByA11yState({ checked: false })).toHaveLength(1); }); it('disables the row when the prop disabled is true', () => { diff --git a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap index f1a7834ac6..fc675ce63d 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -8,7 +8,7 @@ exports[`renders Checkbox with custom testID 1`] = ` { "busy": undefined, "checked": true, - "disabled": true, + "disabled": undefined, "expanded": undefined, "selected": undefined, } @@ -34,91 +34,134 @@ exports[`renders Checkbox with custom testID 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, - }, - ] + { + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, + } } testID="custom:testID" > + - - checkbox-marked - + /> + + > + + @@ -132,7 +175,7 @@ exports[`renders checked Checkbox with color 1`] = ` { "busy": undefined, "checked": true, - "disabled": true, + "disabled": undefined, "expanded": undefined, "selected": undefined, } @@ -147,7 +190,6 @@ exports[`renders checked Checkbox with color 1`] = ` } accessible={true} collapsable={false} - color="red" focusable={true} onBlur={[Function]} onClick={[Function]} @@ -159,90 +201,133 @@ exports[`renders checked Checkbox with color 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, - }, - ] + { + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, + } } > + - - checkbox-marked - + /> + + > + + @@ -256,7 +341,7 @@ exports[`renders checked Checkbox with onPress 1`] = ` { "busy": undefined, "checked": true, - "disabled": false, + "disabled": undefined, "expanded": undefined, "selected": undefined, } @@ -282,90 +367,133 @@ exports[`renders checked Checkbox with onPress 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, - }, - ] + { + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, + } } > + - - checkbox-marked - + /> + + > + + @@ -378,8 +506,8 @@ exports[`renders indeterminate Checkbox 1`] = ` accessibilityState={ { "busy": undefined, - "checked": false, - "disabled": false, + "checked": "mixed", + "disabled": undefined, "expanded": undefined, "selected": undefined, } @@ -405,90 +533,121 @@ exports[`renders indeterminate Checkbox 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, - }, - ] + { + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, + } } > + - - minus-box - + /> + + > + + @@ -501,8 +660,8 @@ exports[`renders indeterminate Checkbox with color 1`] = ` accessibilityState={ { "busy": undefined, - "checked": false, - "disabled": true, + "checked": "mixed", + "disabled": undefined, "expanded": undefined, "selected": undefined, } @@ -517,7 +676,6 @@ exports[`renders indeterminate Checkbox with color 1`] = ` } accessible={true} collapsable={false} - color="red" focusable={true} onBlur={[Function]} onClick={[Function]} @@ -529,90 +687,121 @@ exports[`renders indeterminate Checkbox with color 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, - }, - ] + { + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, + } } > + - - minus-box - + /> + + > + + @@ -626,7 +815,7 @@ exports[`renders unchecked Checkbox with color 1`] = ` { "busy": undefined, "checked": true, - "disabled": true, + "disabled": undefined, "expanded": undefined, "selected": undefined, } @@ -641,7 +830,6 @@ exports[`renders unchecked Checkbox with color 1`] = ` } accessible={true} collapsable={false} - color="red" focusable={true} onBlur={[Function]} onClick={[Function]} @@ -653,90 +841,133 @@ exports[`renders unchecked Checkbox with color 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, - }, - ] + { + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, + } } > + - - checkbox-marked - + /> + + > + + @@ -750,7 +981,7 @@ exports[`renders unchecked Checkbox with onPress 1`] = ` { "busy": undefined, "checked": false, - "disabled": false, + "disabled": undefined, "expanded": undefined, "selected": undefined, } @@ -776,90 +1007,133 @@ exports[`renders unchecked Checkbox with onPress 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, - }, - ] + { + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, + } } > + - - checkbox-blank-outline - + /> + + > + + diff --git a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap index 8f8faef29b..720eb9142f 100644 --- a/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap +++ b/src/components/__tests__/Checkbox/__snapshots__/CheckboxItem.test.tsx.snap @@ -63,7 +63,7 @@ exports[`can render leading checkbox control 1`] = ` { "busy": undefined, "checked": false, - "disabled": true, + "disabled": undefined, "expanded": undefined, "selected": undefined, } @@ -89,90 +89,133 @@ exports[`can render leading checkbox control 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, - }, - ] + { + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, + } } > + - - checkbox-blank-outline - + /> + + > + + @@ -319,7 +362,7 @@ exports[`renders unchecked 1`] = ` { "busy": undefined, "checked": false, - "disabled": true, + "disabled": undefined, "expanded": undefined, "selected": undefined, } @@ -345,90 +388,133 @@ exports[`renders unchecked 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "overflow": "hidden", - }, - { - "borderRadius": 18, - "height": 36, - "padding": 6, - "width": 36, - }, - ] + { + "alignItems": "center", + "height": 40, + "justifyContent": "center", + "width": 40, + } } > + - - checkbox-blank-outline - + /> + + > + + diff --git a/src/utils/useFocusVisible.ts b/src/utils/useFocusVisible.ts new file mode 100644 index 0000000000..c8798d64be --- /dev/null +++ b/src/utils/useFocusVisible.ts @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { Platform } from 'react-native'; + +const isWeb = Platform.OS === 'web'; + +type FocusVisibleEvent = { + currentTarget: object; +}; + +/** + * Convenience hook for components that gate a focus indicator on + * `:focus-visible` semantics. Returns `{ focusVisible, onFocus, onBlur }`; + * wire `onFocus` / `onBlur` to a `Pressable` (or equivalent) and gate the + * indicator on `focusVisible`. + * + * On web, delegates to the browser's `:focus-visible` matcher. On native, + * `onFocus` only fires for keyboard-style focus (external keyboard, D-pad, + * a11y navigation, programmatic focus), so `focusVisible` is always `true` + * while focused. + */ +export function useFocusVisible() { + const [focusVisible, setFocusVisible] = React.useState(false); + const onFocus = React.useCallback((e: FocusVisibleEvent) => { + if (!isWeb) { + setFocusVisible(true); + return; + } + const target = e.currentTarget; + const matches = + 'matches' in target && typeof target.matches === 'function' + ? target.matches + : null; + setFocusVisible(!!matches?.call(target, ':focus-visible')); + }, []); + const onBlur = React.useCallback(() => setFocusVisible(false), []); + return { focusVisible, onFocus, onBlur }; +}