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 9037137e7a..5f68d3ca92 100644 --- a/src/components/Checkbox/CheckboxItem.tsx +++ b/src/components/Checkbox/CheckboxItem.tsx @@ -13,7 +13,7 @@ import Checkbox from './Checkbox'; import CheckboxAndroid from './CheckboxAndroid'; import CheckboxIOS from './CheckboxIOS'; 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, @@ -161,14 +161,10 @@ const CheckboxItem = ({ 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 87362df00c..8a0bccfb85 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 getAndroidCheckedColor = ({ 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 2aa20fd688..f0c809f75e 100644 --- a/src/components/__tests__/Checkbox/utils.test.tsx +++ b/src/components/__tests__/Checkbox/utils.test.tsx @@ -4,7 +4,7 @@ import { getAndroidSelectionControlColor, getSelectionControlIOSColor, } from '../../Checkbox/utils'; -const { stateOpacity } = tokens.md.ref; +const stateOpacity = tokens.md.sys.state.opacity; describe('getAndroidSelectionControlColor - 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/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, 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/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/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; +}; 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'; 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 }; +}