Files
donetick/src/contexts/ThemeContext.jsx

243 lines
7.7 KiB
JavaScript

import createCache from '@emotion/cache'
import { CacheProvider } from '@emotion/react'
import { CssBaseline } from '@mui/joy'
import { CssVarsProvider, extendTheme } from '@mui/joy/styles'
import PropType from 'prop-types'
import { useMemo } from 'react'
import { prefixer } from 'stylis'
import rtlPlugin from 'stylis-plugin-rtl'
import { COLORS, THEME_BACKGROUND } from '@/constants/theme'
import { useLocalization } from './LocalizationContext'
const primaryColor = 'cyan'
const shades = [
'50',
...Array.from({ length: 9 }, (_, i) => String((i + 1) * 100)),
]
const getPalette = (key = primaryColor) =>
shades.reduce((palette, shade) => {
palette[shade] = COLORS[key][shade]
return palette
}, {})
const primaryPalette = getPalette(primaryColor)
// Fallbacks only. A parent that owns the radius (ButtonGroup, Input/Select
// decorator slots, CardActions) sets --Button-radius / --IconButton-radius and
// takes precedence, which is what keeps connected groups looking connected.
const CONTROL_RADIUS = '12px'
const ICON_BUTTON_RADIUS = '10px'
const themeConfig = {
radius: {
xs: '6px',
sm: '8px',
md: '10px',
lg: '12px',
xl: '16px',
},
colorSchemes: {
light: {
palette: {
background: {
body: THEME_BACKGROUND.light,
},
primary: primaryPalette,
success: {
50: '#f3faf7',
100: '#def5eb',
200: '#b7e7d5',
300: '#8ed9be',
400: '#6ecdb0',
500: '#4ec1a2',
600: '#46b89a',
700: '#3cae91',
800: '#32a487',
900: '#229d76',
},
danger: {
50: '#fef2f2',
100: '#fde8e8',
200: '#fbd5d5',
300: '#f9c1c1',
400: '#f6a8a8',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
},
warning: {
50: '#fffdf7',
100: '#fef8e1',
200: '#fdecb2',
300: '#fcd982',
400: '#fbcf52',
500: '#f9c222',
600: '#f6b81e',
700: '#f3ae1a',
800: '#f0a416',
900: '#e99b0e',
},
},
},
dark: {
palette: {
background: {
body: THEME_BACKGROUND.dark,
},
primary: primaryPalette,
},
},
},
components: {
JoyButton: {
styleOverrides: {
root: ({ ownerState }) => ({
minHeight:
ownerState.size === 'lg'
? '44px'
: ownerState.size === 'sm'
? '36px'
: '40px',
// Read through the CSS variable so parents that own the radius
// (ButtonGroup, Input/Select decorators, Card actions) still win.
borderRadius: `var(--Button-radius, ${CONTROL_RADIUS})`,
fontWeight: 600,
transition:
'background-color 140ms ease, border-color 140ms ease, color 140ms ease, box-shadow 140ms ease, transform 100ms ease',
'&:active:not(:disabled)': {
transform: 'scale(0.98)',
},
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
'&:active:not(:disabled)': { transform: 'none' },
},
}),
},
},
JoyIconButton: {
styleOverrides: {
root: ({ ownerState }) => ({
borderRadius: `var(--IconButton-radius, ${ICON_BUTTON_RADIUS})`,
transition:
'background-color 140ms ease, border-color 140ms ease, color 140ms ease, transform 100ms ease',
'&:active:not(:disabled)': {
transform: 'scale(0.96)',
},
// Touch target only for the default size. `sm`/`lg` are explicit
// choices by the call site and keep Joy's own sizing.
...(ownerState.size === 'md' && {
minWidth: '40px',
minHeight: '40px',
'@media (max-width: 768px)': {
minWidth: '44px',
minHeight: '44px',
},
}),
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
'&:active:not(:disabled)': { transform: 'none' },
},
}),
},
},
JoyButtonGroup: {
styleOverrides: {
root: ({ ownerState, theme }) => ({
'--ButtonGroup-radius': CONTROL_RADIUS,
...(theme.direction === 'rtl' && buttonGroupRtlGeometry(ownerState)),
}),
},
},
// ToggleButtonGroup is styled(StyledButtonGroup) under its own slot name, so
// it inherits the same geometry — and the same RTL problem — but not the
// JoyButtonGroup override. Its radius is left at Joy's default on purpose;
// only the direction-sensitive geometry needs correcting.
JoyToggleButtonGroup: {
styleOverrides: {
root: ({ ownerState, theme }) =>
theme.direction === 'rtl' ? buttonGroupRtlGeometry(ownerState) : {},
},
},
},
}
// Joy packs a ButtonGroup's direction-sensitive geometry into CSS custom
// properties: --Button-radius as a four-corner shorthand, --Button-margin as the
// negative overlap that collapses the seam between siblings. stylis-plugin-rtl
// mirrors real properties only — it cannot know what a custom property will end
// up feeding — so those two survive into RTL still in LTR order, while the
// separator borders declared alongside them *do* flip. The result is rounded
// corners on the wrong ends and the overlap pulling the wrong way. Re-mirror
// them here. Vertical groups have no horizontal geometry, so they are left be.
const GROUP_RADIUS = 'var(--ButtonGroup-radius)'
const CHILD_RADIUS = 'var(--unstable_childRadius)'
const OVERLAP = 'calc(var(--ButtonGroup-separatorSize) * -1)'
// Corners read clockwise from top-left. [data-first-child] is the DOM-first
// button, which in RTL renders at the *right* end of the group, so it is the one
// that needs its right corners rounded — and vice versa for [data-last-child].
const ROUNDED_RIGHT = `${CHILD_RADIUS} ${GROUP_RADIUS} ${GROUP_RADIUS} ${CHILD_RADIUS}`
const ROUNDED_LEFT = `${GROUP_RADIUS} ${CHILD_RADIUS} ${CHILD_RADIUS} ${GROUP_RADIUS}`
const buttonGroupRtlGeometry = ownerState => {
if (ownerState.orientation === 'vertical') return {}
return {
'& > [data-first-child]': {
'--Button-radius': ROUNDED_RIGHT,
'--IconButton-radius': ROUNDED_RIGHT,
},
'& > [data-last-child]': {
'--Button-radius': ROUNDED_LEFT,
'--IconButton-radius': ROUNDED_LEFT,
},
// Each non-first button overlaps the sibling to its right in RTL.
'& > :not([data-first-child]):not(:only-child)': {
'--Button-margin': `0 ${OVERLAP} 0 0`,
'--IconButton-margin': `0 ${OVERLAP} 0 0`,
},
}
}
// One theme and one emotion cache per direction, built once and reused. The RTL
// cache runs every rule through stylis-plugin-rtl, which mirrors the physical
// properties (margin-left, left, text-align, translateX, …) that `sx` emits, so
// components written for LTR lay out correctly without per-component overrides.
const byDirection = {
ltr: {
theme: extendTheme({ ...themeConfig, direction: 'ltr' }),
cache: createCache({ key: 'dt', stylisPlugins: [prefixer] }),
},
rtl: {
theme: extendTheme({ ...themeConfig, direction: 'rtl' }),
cache: createCache({ key: 'dt-rtl', stylisPlugins: [prefixer, rtlPlugin] }),
},
}
const ThemeContext = ({ children }) => {
const { isRTL } = useLocalization()
const { cache, theme } = useMemo(
() => byDirection[isRTL ? 'rtl' : 'ltr'],
[isRTL],
)
return (
<CacheProvider value={cache}>
<CssVarsProvider theme={theme}>
<CssBaseline />
{children}
</CssVarsProvider>
</CacheProvider>
)
}
ThemeContext.propTypes = {
children: PropType.node,
}
export default ThemeContext