diff --git a/docs/6.x/docs/guides/theming.mdx b/docs/6.x/docs/guides/theming.mdx index 58836dc167..6661f01e93 100644 --- a/docs/6.x/docs/guides/theming.mdx +++ b/docs/6.x/docs/guides/theming.mdx @@ -244,6 +244,62 @@ export default function Main() { } ``` +## Contrast levels + +Material Design 3 defines three contrast levels, `standard`, `medium` and `high`. The higher levels increase the contrast between text and background colors, which helps users with low vision and makes the app easier to read in bright light. + +Each level is an exported theme that you pass to `PaperProvider`, the same way as the default themes: + +```js +import * as React from 'react'; +import { PaperProvider, HighContrastLightTheme } from 'react-native-paper'; + +export default function Main() { + return ( + + + + ); +} +``` + +The available themes are: + +- `LightTheme` and `DarkTheme` +- `MediumContrastLightTheme` and `MediumContrastDarkTheme` +- `HighContrastLightTheme` and `HighContrastDarkTheme` + +The `medium` and `high` schemes meet the WCAG contrast ratios of 4.5:1 and 7:1 respectively for every text and background role pair. + +Note that passing a `theme` turns off automatic system dark mode, so pick the theme yourself when you follow the system setting: + +```js +import { useColorScheme } from 'react-native'; +import { + PaperProvider, + HighContrastDarkTheme, + HighContrastLightTheme, +} from 'react-native-paper'; + +export default function Main() { + const isDarkMode = useColorScheme() === 'dark'; + + return ( + + + + ); +} +``` + +### Contrast and dynamic colors + +There are matching dynamic themes: `MediumContrastDynamicLightTheme`, `HighContrastDynamicLightTheme`, `MediumContrastDynamicDarkTheme` and `HighContrastDynamicDarkTheme`. + +Android exposes no contrast adjusted version of its system palette, so these fall back to the static schemes. Using the standard contrast system colors at a raised level would quietly lower the contrast you asked for. + ## Adapting React Navigation theme The `adaptNavigationTheme` function takes an existing React Navigation theme and returns a React Navigation theme using the colors from Material Design 3. This theme can be passed to `NavigationContainer` so that React Navigation's UI elements have the same color scheme as Paper. diff --git a/example/src/DrawerItems.tsx b/example/src/DrawerItems.tsx index 49bdd5f99d..eb5b892d66 100644 --- a/example/src/DrawerItems.tsx +++ b/example/src/DrawerItems.tsx @@ -11,6 +11,7 @@ import { Drawer, Palette, Portal, + SegmentedButtons, Switch, Text, TouchableRipple, @@ -104,9 +105,11 @@ function DrawerItems() { toggleCollapsed, toggleCustomFont, toggleRippleEffect, + setContrast, customFontLoaded, rippleEffectEnabled, collapsed, + contrast, rtl: isRTL, theme: { dark: isDarkTheme }, shouldUseDynamicTheme, @@ -191,6 +194,20 @@ function DrawerItems() { + + Contrast + setContrast(value)} + density="small" + buttons={[ + { value: 'standard', label: 'Standard' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + ]} + /> + + RTL @@ -277,6 +294,12 @@ const styles = StyleSheet.create({ height: 56, paddingHorizontal: 28, }, + contrastPreference: { + flexDirection: 'column', + alignItems: 'stretch', + gap: 12, + paddingHorizontal: 28, + }, badge: { alignSelf: 'center', }, diff --git a/example/src/PreferencesContext.tsx b/example/src/PreferencesContext.tsx index b5e381ae05..e6d2ca1acd 100644 --- a/example/src/PreferencesContext.tsx +++ b/example/src/PreferencesContext.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import type { Theme } from 'react-native-paper'; +type ContrastLevel = 'standard' | 'medium' | 'high'; + export const PreferencesContext = React.createContext<{ toggleTheme: () => void; toggleRtl: () => void; @@ -9,7 +11,9 @@ export const PreferencesContext = React.createContext<{ toggleCustomFont: () => void; toggleRippleEffect: () => void; toggleShouldUseDynamicTheme?: () => void; + setContrast: (contrast: ContrastLevel) => void; theme: Theme; + contrast: ContrastLevel; rtl: boolean; collapsed: boolean; customFontLoaded: boolean; diff --git a/example/src/index.tsx b/example/src/index.tsx index afa3941044..ec7b451f21 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -13,11 +13,19 @@ import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; import * as Updates from 'expo-updates'; import { - PaperProvider, DarkTheme, - LightTheme, - DynamicLightTheme, DynamicDarkTheme, + DynamicLightTheme, + HighContrastDarkTheme, + HighContrastDynamicDarkTheme, + HighContrastDynamicLightTheme, + HighContrastLightTheme, + LightTheme, + MediumContrastDarkTheme, + MediumContrastDynamicDarkTheme, + MediumContrastDynamicLightTheme, + MediumContrastLightTheme, + PaperProvider, } from 'react-native-paper'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -26,12 +34,39 @@ import { PreferencesContext } from './PreferencesContext'; import App from './RootNavigator'; import { dynamicThemeSupported } from '../utils'; import { - CombinedDarkTheme, - CombinedDefaultTheme, + createCombinedTheme, createConfiguredFontNavigationTheme, createConfiguredFontTheme, } from '../utils/themes'; +type ContrastLevel = 'standard' | 'medium' | 'high'; + +const THEMES = { + light: { + standard: LightTheme, + medium: MediumContrastLightTheme, + high: HighContrastLightTheme, + }, + dark: { + standard: DarkTheme, + medium: MediumContrastDarkTheme, + high: HighContrastDarkTheme, + }, +}; + +const DYNAMIC_THEMES = { + light: { + standard: DynamicLightTheme, + medium: MediumContrastDynamicLightTheme, + high: HighContrastDynamicLightTheme, + }, + dark: { + standard: DynamicDarkTheme, + medium: MediumContrastDynamicDarkTheme, + high: HighContrastDynamicDarkTheme, + }, +}; + const PERSISTENCE_KEY = 'NAVIGATION_STATE'; const PREFERENCES_KEY = 'APP_PREFERENCES'; @@ -98,15 +133,11 @@ export default function PaperExample() { const [collapsed, setCollapsed] = React.useState(false); const [customFontLoaded, setCustomFont] = React.useState(false); const [rippleEffectEnabled, setRippleEffectEnabled] = React.useState(true); + const [contrast, setContrast] = React.useState('standard'); - const theme = - dynamicThemeSupported && shouldUseDynamicTheme - ? isDarkMode - ? DynamicDarkTheme - : DynamicLightTheme - : isDarkMode - ? DarkTheme - : LightTheme; + const themes = + dynamicThemeSupported && shouldUseDynamicTheme ? DYNAMIC_THEMES : THEMES; + const theme = themes[isDarkMode ? 'dark' : 'light'][contrast]; const direction = rtl ? 'rtl' : 'ltr'; @@ -122,6 +153,13 @@ export default function PaperExample() { if (typeof preferences.rtl === 'boolean') { setRtl(preferences.rtl); } + + if ( + preferences.contrast === 'medium' || + preferences.contrast === 'high' + ) { + setContrast(preferences.contrast); + } } } catch (e) { // ignore error @@ -145,6 +183,7 @@ export default function PaperExample() { JSON.stringify({ theme: isDarkMode ? 'dark' : 'light', rtl, + contrast, }) ); } catch (e) { @@ -165,7 +204,7 @@ export default function PaperExample() { }; void savePrefs(); - }, [direction, isDarkMode, isReady, rtl]); + }, [contrast, direction, isDarkMode, isReady, rtl]); const preferences = React.useMemo( () => ({ @@ -176,9 +215,11 @@ export default function PaperExample() { toggleCollapsed: () => setCollapsed((oldValue) => !oldValue), toggleCustomFont: () => setCustomFont((oldValue) => !oldValue), toggleRippleEffect: () => setRippleEffectEnabled((oldValue) => !oldValue), + setContrast, customFontLoaded, rippleEffectEnabled, shouldUseDynamicTheme, + contrast, theme, collapsed, rtl, @@ -187,6 +228,7 @@ export default function PaperExample() { rtl, theme, collapsed, + contrast, customFontLoaded, shouldUseDynamicTheme, rippleEffectEnabled, @@ -197,7 +239,7 @@ export default function PaperExample() { return null; } - const combinedTheme = isDarkMode ? CombinedDarkTheme : CombinedDefaultTheme; + const combinedTheme = createCombinedTheme(theme, isDarkMode); const configuredFontTheme = createConfiguredFontTheme(combinedTheme); const configuredFontNavigationTheme = createConfiguredFontNavigationTheme(combinedTheme); diff --git a/example/utils/themes.ts b/example/utils/themes.ts index 22fda309e7..6ce2ce059f 100644 --- a/example/utils/themes.ts +++ b/example/utils/themes.ts @@ -3,44 +3,38 @@ import { DefaultTheme as NavigationDefaultTheme, } from '@react-navigation/native'; import type { Theme as ReactNavigationTheme } from '@react-navigation/native'; -import { - adaptNavigationTheme, - DarkTheme, - LightTheme, - configureFonts, -} from 'react-native-paper'; +import { adaptNavigationTheme, configureFonts } from 'react-native-paper'; import type { Theme } from 'react-native-paper'; -const { LightTheme: NavLightTheme, DarkTheme: NavDarkTheme } = - adaptNavigationTheme({ - reactNavigationLight: NavigationDefaultTheme, - reactNavigationDark: NavigationDarkTheme, - }); +/** + * Merges the React Navigation theme into a Paper theme. + * + * The Paper theme is passed in, and also given to `adaptNavigationTheme`, so + * that the selected contrast level is kept. + */ +export const createCombinedTheme = (paperTheme: Theme, isDark: boolean) => { + const { LightTheme: NavLightTheme, DarkTheme: NavDarkTheme } = + adaptNavigationTheme({ + reactNavigationLight: NavigationDefaultTheme, + reactNavigationDark: NavigationDarkTheme, + materialLight: isDark ? undefined : paperTheme, + materialDark: isDark ? paperTheme : undefined, + }); -export const CombinedDefaultTheme = { - ...LightTheme, - ...NavLightTheme, - colors: { - ...LightTheme.colors, - ...NavLightTheme.colors, - }, - fonts: { - ...LightTheme.fonts, - ...NavLightTheme.fonts, - }, -}; + const navTheme = isDark ? NavDarkTheme : NavLightTheme; -export const CombinedDarkTheme = { - ...DarkTheme, - ...NavDarkTheme, - colors: { - ...DarkTheme.colors, - ...NavDarkTheme.colors, - }, - fonts: { - ...DarkTheme.fonts, - ...NavDarkTheme.fonts, - }, + return { + ...paperTheme, + ...navTheme, + colors: { + ...paperTheme.colors, + ...navTheme.colors, + }, + fonts: { + ...paperTheme.fonts, + ...navTheme.fonts, + }, + }; }; export const createConfiguredFontTheme = ( diff --git a/src/core/__tests__/theming.test.tsx b/src/core/__tests__/theming.test.tsx index cb61aef20d..56282a50dd 100644 --- a/src/core/__tests__/theming.test.tsx +++ b/src/core/__tests__/theming.test.tsx @@ -1,6 +1,11 @@ import { describe, expect, it } from '@jest/globals'; -import { DarkTheme, LightTheme } from '../../theme/schemes'; +import { + DarkTheme, + HighContrastDarkTheme, + HighContrastLightTheme, + LightTheme, +} from '../../theme/schemes'; import { adaptNavigationTheme } from '../theming'; const NavigationLightTheme = { @@ -273,4 +278,25 @@ describe('adaptNavigationTheme', () => { expect(navLight).not.toHaveProperty('fonts'); expect(navDark).not.toHaveProperty('fonts'); }); + + it('adapts the colors of a raised-contrast material theme', () => { + const materialLight = HighContrastLightTheme; + const materialDark = HighContrastDarkTheme; + + const { LightTheme: navLight, DarkTheme: navDark } = adaptNavigationTheme({ + reactNavigationLight: NavigationLightTheme, + reactNavigationDark: NavigationDarkTheme, + materialLight, + materialDark, + }); + + // Apps spread the navigation colors over the Paper theme, so these + // must match the contrast level that was asked for. + expect(navLight.colors.primary).toBe(materialLight.colors.primary); + expect(navLight.colors.text).toBe(materialLight.colors.onSurface); + expect(navDark.colors.primary).toBe(materialDark.colors.primary); + + expect(navLight.colors.primary).not.toBe(LightTheme.colors.primary); + expect(navDark.colors.primary).not.toBe(DarkTheme.colors.primary); + }); }); diff --git a/src/theme/__tests__/contrast.test.ts b/src/theme/__tests__/contrast.test.ts new file mode 100644 index 0000000000..76df4d7d39 --- /dev/null +++ b/src/theme/__tests__/contrast.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from '@jest/globals'; +import color from 'color'; + +import { + DarkTheme, + HighContrastDarkTheme, + HighContrastLightTheme, + LightTheme, + MediumContrastDarkTheme, + MediumContrastLightTheme, +} from '../schemes'; +import { palette } from '../tokens/ref/palette'; +import { buildScheme } from '../tokens/sys/color'; +import type { ContrastLevel, ThemeColors } from '../types'; + +const MODES = ['light', 'dark'] as const; +const NON_STANDARD = ['medium', 'high'] as const satisfies ContrastLevel[]; + +const THEMES = { + light: { + standard: LightTheme, + medium: MediumContrastLightTheme, + high: HighContrastLightTheme, + }, + dark: { + standard: DarkTheme, + medium: MediumContrastDarkTheme, + high: HighContrastDarkTheme, + }, +} as const; + +/** + * Text and background role pairs that MD3 requires to be readable. + * @see https://m3.material.io/styles/color/roles + */ +const CONTRAST_PAIRS: [keyof ThemeColors, keyof ThemeColors][] = [ + ['onPrimary', 'primary'], + ['onPrimaryContainer', 'primaryContainer'], + ['onSecondary', 'secondary'], + ['onSecondaryContainer', 'secondaryContainer'], + ['onTertiary', 'tertiary'], + ['onTertiaryContainer', 'tertiaryContainer'], + ['onError', 'error'], + ['onErrorContainer', 'errorContainer'], + ['onSurface', 'surface'], + ['onSurfaceVariant', 'surfaceVariant'], + ['onBackground', 'background'], + ['onSurface', 'surfaceContainer'], + ['onSurface', 'surfaceContainerHighest'], + ['inverseOnSurface', 'inverseSurface'], + ['onPrimaryFixed', 'primaryFixed'], + ['onSecondaryFixed', 'secondaryFixed'], + ['onTertiaryFixed', 'tertiaryFixed'], + ['onPrimaryFixed', 'primaryFixedDim'], + ['onSecondaryFixed', 'secondaryFixedDim'], + ['onTertiaryFixed', 'tertiaryFixedDim'], + ['onPrimaryFixedVariant', 'primaryFixedDim'], + ['onSecondaryFixedVariant', 'secondaryFixedDim'], + ['onTertiaryFixedVariant', 'tertiaryFixedDim'], + ['onPrimaryFixedVariant', 'primaryFixed'], + ['onSecondaryFixedVariant', 'secondaryFixed'], + ['onTertiaryFixedVariant', 'tertiaryFixed'], +]; + +/** WCAG 2.x minimum ratio per MD3 contrast level. */ +const WCAG_TARGET: Record, number> = { + medium: 4.5, + high: 7, +}; + +/** Theme colors are typed as `ColorValue`, but every built-in scheme uses an + * `rgba()` string. Anything else means the scheme is broken. */ +const asColor = (value: unknown) => { + if (typeof value !== 'string') { + throw new Error(`Expected a color string, received ${typeof value}`); + } + + return color(value); +}; + +const ratio = (foreground: unknown, background: unknown) => + asColor(foreground).contrast(asColor(background)); + +describe('contrast levels', () => { + describe.each(MODES)('%s', (mode) => { + it.each(NON_STANDARD)('defines every color role at %s', (contrast) => { + const standard = buildScheme(palette, { mode }); + const scheme = buildScheme(palette, { mode, contrast }); + + // Catches a role that is missing from the generated table. + expect(Object.keys(scheme).sort()).toEqual(Object.keys(standard).sort()); + + Object.entries(scheme).forEach(([role, value]) => { + expect(value).toBeDefined(); + expect(role.length && value).toBeTruthy(); + }); + + expect(Object.keys(scheme.elevation).sort()).toEqual( + Object.keys(standard.elevation).sort() + ); + }); + + it.each(NON_STANDARD)('meets WCAG contrast targets at %s', (contrast) => { + const { colors } = THEMES[mode][contrast]; + const target = WCAG_TARGET[contrast]; + + const failures = CONTRAST_PAIRS.filter( + ([foreground, background]) => + ratio(colors[foreground], colors[background]) < target + ).map(([foreground, background]) => { + const value = ratio(colors[foreground], colors[background]); + return `${foreground} on ${background}: ${value.toFixed(2)} < ${target}`; + }); + + expect(failures).toEqual([]); + }); + + it.each(NON_STANDARD)( + 'raises contrast above standard at %s', + (contrast) => { + const standard = THEMES[mode].standard.colors; + const raised = THEMES[mode][contrast].colors; + + expect(ratio(raised.onPrimary, raised.primary)).toBeGreaterThan( + ratio(standard.onPrimary, standard.primary) + ); + } + ); + }); + + it('derives the pressed state layer from the scheme onSurface', () => { + const { colors } = HighContrastLightTheme; + + expect(colors.stateLayerPressed).toBe( + asColor(colors.onSurface).alpha(0.1).rgb().string() + ); + expect(colors.stateLayerPressed).not.toBe( + LightTheme.colors.stateLayerPressed + ); + }); + + it('keeps the fixed surfaces the same at every contrast level', () => { + // The fixed surfaces stay put so they can be shared across light and dark. + // Their `on*FixedVariant` foregrounds still darken to hold the ratio. + let checked = 0; + + MODES.forEach((mode) => { + const standard = THEMES[mode].standard.colors; + + NON_STANDARD.forEach((contrast) => { + const raised = THEMES[mode][contrast].colors; + + const surfacesOf = (colors: ThemeColors) => + Object.entries(colors).filter( + ([role]) => role.includes('Fixed') && !role.startsWith('on') + ); + + const before = surfacesOf(standard); + checked += before.length; + + expect(surfacesOf(raised)).toStrictEqual(before); + }); + }); + + expect(checked).toBeGreaterThan(0); + }); + + it('keeps the fixed foregrounds readable as contrast rises', () => { + MODES.forEach((mode) => { + const standard = THEMES[mode].standard.colors; + const high = THEMES[mode].high.colors; + + // The variant foreground darkens so it clears 7:1 on the dim surface. + expect( + ratio(high.onPrimaryFixedVariant, high.primaryFixedDim) + ).toBeGreaterThan( + ratio(standard.onPrimaryFixedVariant, standard.primaryFixedDim) + ); + }); + }); + + it('keeps a container distinct from its base role', () => { + // A container collapsing onto its base role means the scheme has clipped. + NON_STANDARD.forEach((contrast) => { + MODES.forEach((mode) => { + const { colors } = THEMES[mode][contrast]; + + expect(colors.primaryContainer).not.toBe(colors.primary); + expect(colors.secondaryContainer).not.toBe(colors.secondary); + expect(colors.tertiaryContainer).not.toBe(colors.tertiary); + expect(colors.errorContainer).not.toBe(colors.error); + expect(colors.outlineVariant).not.toBe(colors.outline); + }); + }); + }); + + it('keeps elevation level0 transparent', () => { + NON_STANDARD.forEach((contrast) => { + expect(THEMES.light[contrast].colors.elevation.level0).toBe( + 'transparent' + ); + }); + }); + + it('leaves the built-in themes at standard contrast', () => { + expect(LightTheme.colors).toStrictEqual( + buildScheme(palette, { mode: 'light' }) + ); + expect(DarkTheme.colors).toStrictEqual( + buildScheme(palette, { mode: 'dark' }) + ); + }); +}); diff --git a/src/theme/schemes/DarkTheme.tsx b/src/theme/schemes/DarkTheme.tsx index 9b7ff60ef4..3ee90f6e94 100644 --- a/src/theme/schemes/DarkTheme.tsx +++ b/src/theme/schemes/DarkTheme.tsx @@ -1,12 +1,28 @@ import { themeDefaults } from './base'; import { tokens } from '../tokens'; 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, { mode: 'dark' }), - shapes: defaultShapes, +}; + +export const MediumContrastDarkTheme: Theme = { + ...themeDefaults, + dark: true, + colors: buildScheme(tokens.md.ref.palette, { + mode: 'dark', + contrast: 'medium', + }), +}; + +export const HighContrastDarkTheme: Theme = { + ...themeDefaults, + dark: true, + colors: buildScheme(tokens.md.ref.palette, { + mode: 'dark', + contrast: 'high', + }), }; diff --git a/src/theme/schemes/DynamicTheme.android.tsx b/src/theme/schemes/DynamicTheme.android.tsx index 0fb9c29b0e..60a1850f11 100644 --- a/src/theme/schemes/DynamicTheme.android.tsx +++ b/src/theme/schemes/DynamicTheme.android.tsx @@ -489,3 +489,17 @@ export const DynamicDarkTheme: Theme = { ...DarkTheme, colors: { ...DarkTheme.colors, ...darkDynamicColors }, }; + +/** + * Android exposes no contrast adjusted version of its system palette, so the + * raised levels fall back to the static schemes. Using the standard contrast + * system colors there would quietly lower the contrast the user asked for. + */ +export { + MediumContrastLightTheme as MediumContrastDynamicLightTheme, + HighContrastLightTheme as HighContrastDynamicLightTheme, +} from './LightTheme'; +export { + MediumContrastDarkTheme as MediumContrastDynamicDarkTheme, + HighContrastDarkTheme as HighContrastDynamicDarkTheme, +} from './DarkTheme'; diff --git a/src/theme/schemes/DynamicTheme.tsx b/src/theme/schemes/DynamicTheme.tsx index a9049b86bf..0f13ef0843 100644 --- a/src/theme/schemes/DynamicTheme.tsx +++ b/src/theme/schemes/DynamicTheme.tsx @@ -1,4 +1,8 @@ export { DarkTheme as DynamicDarkTheme } from './DarkTheme'; export { LightTheme as DynamicLightTheme } from './LightTheme'; +export { MediumContrastLightTheme as MediumContrastDynamicLightTheme } from './LightTheme'; +export { HighContrastLightTheme as HighContrastDynamicLightTheme } from './LightTheme'; +export { MediumContrastDarkTheme as MediumContrastDynamicDarkTheme } from './DarkTheme'; +export { HighContrastDarkTheme as HighContrastDynamicDarkTheme } from './DarkTheme'; export const isDynamicColorSupported = false; diff --git a/src/theme/schemes/LightTheme.tsx b/src/theme/schemes/LightTheme.tsx index 42593d5d42..2d2e6bc534 100644 --- a/src/theme/schemes/LightTheme.tsx +++ b/src/theme/schemes/LightTheme.tsx @@ -1,12 +1,28 @@ import { themeDefaults } from './base'; import { tokens } from '../tokens'; 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, { mode: 'light' }), - shapes: defaultShapes, +}; + +export const MediumContrastLightTheme: Theme = { + ...themeDefaults, + dark: false, + colors: buildScheme(tokens.md.ref.palette, { + mode: 'light', + contrast: 'medium', + }), +}; + +export const HighContrastLightTheme: Theme = { + ...themeDefaults, + dark: false, + colors: buildScheme(tokens.md.ref.palette, { + mode: 'light', + contrast: 'high', + }), }; diff --git a/src/theme/schemes/base.ts b/src/theme/schemes/base.ts index 180ec1c155..c9a89aeb0d 100644 --- a/src/theme/schemes/base.ts +++ b/src/theme/schemes/base.ts @@ -4,7 +4,7 @@ import { defaultShapes } from '../tokens/sys/shape'; import { defaultFonts } from '../tokens/sys/typography'; import type { Theme } from '../types'; -type ThemeDefaults = Omit; +type ThemeDefaults = Omit; export const themeDefaults: ThemeDefaults = { animation: { diff --git a/src/theme/schemes/index.ts b/src/theme/schemes/index.ts index 37407657e2..383f50004d 100644 --- a/src/theme/schemes/index.ts +++ b/src/theme/schemes/index.ts @@ -1,7 +1,19 @@ -export { LightTheme } from './LightTheme'; -export { DarkTheme } from './DarkTheme'; +export { + LightTheme, + MediumContrastLightTheme, + HighContrastLightTheme, +} from './LightTheme'; +export { + DarkTheme, + MediumContrastDarkTheme, + HighContrastDarkTheme, +} from './DarkTheme'; export { DynamicLightTheme, DynamicDarkTheme, + MediumContrastDynamicLightTheme, + HighContrastDynamicLightTheme, + MediumContrastDynamicDarkTheme, + HighContrastDynamicDarkTheme, isDynamicColorSupported, } from './DynamicTheme'; diff --git a/src/theme/tokens/sys/color.ts b/src/theme/tokens/sys/color.ts index a6c22ca0fb..1a9a267f8a 100644 --- a/src/theme/tokens/sys/color.ts +++ b/src/theme/tokens/sys/color.ts @@ -1,7 +1,7 @@ import color from 'color'; import { state } from './state'; -import type { ElevationColors, ThemeColors } from '../../types'; +import type { ContrastLevel, ElevationColors, ThemeColors } from '../../types'; import { palette as defaultPalette } from '../ref/palette'; type Palette = typeof defaultPalette; @@ -10,11 +10,14 @@ type PaletteKey = keyof Palette; /** Roles that map 1:1 to a palette key. Excludes the computed fields. */ type MappedRoles = Omit; -type Contrast = 'standard'; // extend with 'medium' | 'high' when those ship - +/** Role to palette step for each MD3 contrast level. + * + * Raising contrast moves the accent and outline roles toward the extremes of + * their tonal palette. Surfaces and the `*Fixed` roles do not change, since + * MD3 keeps those stable across levels. */ const roleToTone: Record< 'light' | 'dark', - Record> + Record> > = { light: { standard: { @@ -67,6 +70,106 @@ const roleToTone: Record< shadow: 'neutral0', scrim: 'neutral0', }, + medium: { + primary: 'primary30', + onPrimary: 'primary100', + primaryContainer: 'primary40', + onPrimaryContainer: 'primary100', + secondary: 'secondary30', + onSecondary: 'secondary100', + secondaryContainer: 'secondary40', + onSecondaryContainer: 'secondary100', + tertiary: 'tertiary30', + onTertiary: 'tertiary100', + tertiaryContainer: 'tertiary40', + onTertiaryContainer: 'tertiary100', + error: 'error30', + onError: 'error100', + errorContainer: 'error40', + onErrorContainer: 'error100', + surface: 'neutral98', + surfaceDim: 'neutral87', + surfaceBright: 'neutral98', + surfaceContainerLowest: 'neutral100', + surfaceContainerLow: 'neutral96', + surfaceContainer: 'neutral94', + surfaceContainerHigh: 'neutral92', + surfaceContainerHighest: 'neutral90', + surfaceVariant: 'neutralVariant90', + background: 'neutral98', + onSurface: 'neutral10', + onSurfaceVariant: 'neutralVariant30', + onBackground: 'neutral10', + outline: 'neutralVariant40', + outlineVariant: 'neutralVariant60', + inverseSurface: 'neutral20', + inverseOnSurface: 'neutral95', + inversePrimary: 'primary90', + primaryFixed: 'primary90', + primaryFixedDim: 'primary80', + onPrimaryFixed: 'primary10', + onPrimaryFixedVariant: 'primary30', + secondaryFixed: 'secondary90', + secondaryFixedDim: 'secondary80', + onSecondaryFixed: 'secondary10', + onSecondaryFixedVariant: 'secondary30', + tertiaryFixed: 'tertiary90', + tertiaryFixedDim: 'tertiary80', + onTertiaryFixed: 'tertiary10', + onTertiaryFixedVariant: 'tertiary30', + shadow: 'neutral0', + scrim: 'neutral0', + }, + high: { + primary: 'primary20', + onPrimary: 'primary100', + primaryContainer: 'primary30', + onPrimaryContainer: 'primary100', + secondary: 'secondary20', + onSecondary: 'secondary100', + secondaryContainer: 'secondary30', + onSecondaryContainer: 'secondary100', + tertiary: 'tertiary20', + onTertiary: 'tertiary100', + tertiaryContainer: 'tertiary30', + onTertiaryContainer: 'tertiary100', + error: 'error20', + onError: 'error100', + errorContainer: 'error30', + onErrorContainer: 'error100', + surface: 'neutral98', + surfaceDim: 'neutral87', + surfaceBright: 'neutral98', + surfaceContainerLowest: 'neutral100', + surfaceContainerLow: 'neutral96', + surfaceContainer: 'neutral94', + surfaceContainerHigh: 'neutral92', + surfaceContainerHighest: 'neutral90', + surfaceVariant: 'neutralVariant90', + background: 'neutral98', + onSurface: 'neutral0', + onSurfaceVariant: 'neutralVariant20', + onBackground: 'neutral0', + outline: 'neutralVariant20', + outlineVariant: 'neutralVariant40', + inverseSurface: 'neutral20', + inverseOnSurface: 'neutral95', + inversePrimary: 'primary95', + primaryFixed: 'primary90', + primaryFixedDim: 'primary80', + onPrimaryFixed: 'primary10', + onPrimaryFixedVariant: 'primary20', + secondaryFixed: 'secondary90', + secondaryFixedDim: 'secondary80', + onSecondaryFixed: 'secondary10', + onSecondaryFixedVariant: 'secondary20', + tertiaryFixed: 'tertiary90', + tertiaryFixedDim: 'tertiary80', + onTertiaryFixed: 'tertiary10', + onTertiaryFixedVariant: 'tertiary20', + shadow: 'neutral0', + scrim: 'neutral0', + }, }, dark: { standard: { @@ -119,40 +222,143 @@ const roleToTone: Record< shadow: 'neutral0', scrim: 'neutral0', }, + medium: { + primary: 'primary90', + onPrimary: 'primary10', + primaryContainer: 'primary70', + onPrimaryContainer: 'primary0', + secondary: 'secondary90', + onSecondary: 'secondary10', + secondaryContainer: 'secondary70', + onSecondaryContainer: 'secondary0', + tertiary: 'tertiary90', + onTertiary: 'tertiary10', + tertiaryContainer: 'tertiary70', + onTertiaryContainer: 'tertiary0', + error: 'error90', + onError: 'error10', + errorContainer: 'error70', + onErrorContainer: 'error0', + surface: 'neutral6', + surfaceDim: 'neutral6', + surfaceBright: 'neutral24', + surfaceContainerLowest: 'neutral4', + surfaceContainerLow: 'neutral10', + surfaceContainer: 'neutral12', + surfaceContainerHigh: 'neutral17', + surfaceContainerHighest: 'neutral22', + surfaceVariant: 'neutralVariant30', + background: 'neutral6', + onSurface: 'neutral100', + onSurfaceVariant: 'neutralVariant90', + onBackground: 'neutral100', + outline: 'neutralVariant70', + outlineVariant: 'neutralVariant50', + inverseSurface: 'neutral90', + inverseOnSurface: 'neutral20', + inversePrimary: 'primary30', + primaryFixed: 'primary90', + primaryFixedDim: 'primary80', + onPrimaryFixed: 'primary10', + onPrimaryFixedVariant: 'primary30', + secondaryFixed: 'secondary90', + secondaryFixedDim: 'secondary80', + onSecondaryFixed: 'secondary10', + onSecondaryFixedVariant: 'secondary30', + tertiaryFixed: 'tertiary90', + tertiaryFixedDim: 'tertiary80', + onTertiaryFixed: 'tertiary10', + onTertiaryFixedVariant: 'tertiary30', + shadow: 'neutral0', + scrim: 'neutral0', + }, + high: { + primary: 'primary95', + onPrimary: 'primary0', + primaryContainer: 'primary80', + onPrimaryContainer: 'primary0', + secondary: 'secondary95', + onSecondary: 'secondary0', + secondaryContainer: 'secondary80', + onSecondaryContainer: 'secondary0', + tertiary: 'tertiary95', + onTertiary: 'tertiary0', + tertiaryContainer: 'tertiary80', + onTertiaryContainer: 'tertiary0', + error: 'error95', + onError: 'error0', + errorContainer: 'error80', + onErrorContainer: 'error0', + surface: 'neutral6', + surfaceDim: 'neutral6', + surfaceBright: 'neutral24', + surfaceContainerLowest: 'neutral4', + surfaceContainerLow: 'neutral10', + surfaceContainer: 'neutral12', + surfaceContainerHigh: 'neutral17', + surfaceContainerHighest: 'neutral22', + surfaceVariant: 'neutralVariant30', + background: 'neutral6', + onSurface: 'neutral100', + onSurfaceVariant: 'neutralVariant95', + onBackground: 'neutral100', + outline: 'neutralVariant80', + outlineVariant: 'neutralVariant60', + inverseSurface: 'neutral90', + inverseOnSurface: 'neutral20', + inversePrimary: 'primary20', + primaryFixed: 'primary90', + primaryFixedDim: 'primary80', + onPrimaryFixed: 'primary10', + onPrimaryFixedVariant: 'primary20', + secondaryFixed: 'secondary90', + secondaryFixedDim: 'secondary80', + onSecondaryFixed: 'secondary10', + onSecondaryFixedVariant: 'secondary20', + tertiaryFixed: 'tertiary90', + tertiaryFixedDim: 'tertiary80', + onTertiaryFixed: 'tertiary10', + onTertiaryFixedVariant: 'tertiary20', + shadow: 'neutral0', + scrim: 'neutral0', + }, }, }; const elevationToTone: Record< 'light' | 'dark', - Record, PaletteKey>> + Record, PaletteKey> > = { light: { - standard: { - level1: 'neutral96', - level2: 'neutral94', - level3: 'neutral92', - level4: 'neutral92', - level5: 'neutral90', - }, + level1: 'neutral96', + level2: 'neutral94', + level3: 'neutral92', + level4: 'neutral92', + level5: 'neutral90', }, dark: { - standard: { - level1: 'neutral10', - level2: 'neutral12', - level3: 'neutral17', - level4: 'neutral17', - level5: 'neutral22', - }, + level1: 'neutral10', + level2: 'neutral12', + level3: 'neutral17', + level4: 'neutral17', + level5: 'neutral22', }, }; +/** Works out the press state layer up front, because changing alpha at + * runtime breaks PlatformColor on Android. + * @see ThemeColors.stateLayerPressed */ +const withPressedOpacity = (onSurface: string) => + color(onSurface).alpha(state.opacity.pressed).rgb().string(); + +/** Builds the color scheme for a mode and contrast level. */ export function buildScheme( palette: Palette, - opts: { mode: 'light' | 'dark'; contrast?: Contrast } + opts: { mode: 'light' | 'dark'; contrast?: ContrastLevel } ): ThemeColors { const contrast = opts.contrast ?? 'standard'; const tones = roleToTone[opts.mode][contrast]; - const elevTones = elevationToTone[opts.mode][contrast]; + const elevTones = elevationToTone[opts.mode]; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const mapped = Object.fromEntries( @@ -161,10 +367,7 @@ export function buildScheme( return { ...mapped, - stateLayerPressed: color(palette[tones.onSurface]) - .alpha(state.opacity.pressed) - .rgb() - .string(), + stateLayerPressed: withPressedOpacity(palette[tones.onSurface]), elevation: { level0: 'transparent', level1: palette[elevTones.level1], diff --git a/src/theme/types/theme.ts b/src/theme/types/theme.ts index a4ce2288ae..109d7575e1 100644 --- a/src/theme/types/theme.ts +++ b/src/theme/types/theme.ts @@ -6,6 +6,8 @@ import type { MotionConfig } from './motion'; import type { ThemeShapes } from './shape'; import type { Typescale } from './typography'; +export type ContrastLevel = 'standard' | 'medium' | 'high'; + export type Theme = { dark: boolean; animation: {