diff --git a/package-lock.json b/package-lock.json index 360424e..c50f492 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@emotion/cache": "^11.14.0", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@hello-pangea/dnd": "^18.0.1", @@ -77,6 +78,8 @@ "reactjs-social-login": "^2.6.3", "recharts": "^2.15.0", "reusify": "^1.0.4", + "stylis": "^4.4.0", + "stylis-plugin-rtl": "^2.1.1", "tesseract.js": "^7.0.0", "vite-plugin-pwa": "^0.20.0" }, @@ -2311,8 +2314,16 @@ "stylis": "4.2.0" } }, + "node_modules/@emotion/babel-plugin/node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, "node_modules/@emotion/cache": { "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", "license": "MIT", "dependencies": { "@emotion/memoize": "^0.9.0", @@ -2322,6 +2333,12 @@ "stylis": "4.2.0" } }, + "node_modules/@emotion/cache/node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, "node_modules/@emotion/hash": { "version": "0.9.2", "license": "MIT" @@ -7480,6 +7497,15 @@ "node": ">=4" } }, + "node_modules/cssjanus": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/cssjanus/-/cssjanus-2.3.1.tgz", + "integrity": "sha512-gWZQ/S0tthU2KCc55C5zbjxQN0XPK1sT0qufCqwCNUauGIsPDOfdBMBWosP+Kd4wf2A2Nxm/z6JAP6yNGnS7QQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/csstype": { "version": "3.1.3", "license": "MIT" @@ -14063,9 +14089,23 @@ } }, "node_modules/stylis": { - "version": "4.2.0", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", "license": "MIT" }, + "node_modules/stylis-plugin-rtl": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/stylis-plugin-rtl/-/stylis-plugin-rtl-2.1.1.tgz", + "integrity": "sha512-q6xIkri6fBufIO/sV55md2CbgS5c6gg9EhSVATtHHCdOnbN/jcI0u3lYhNVeuI65c4lQPo67g8xmq5jrREvzlg==", + "license": "MIT", + "dependencies": { + "cssjanus": "^2.0.1" + }, + "peerDependencies": { + "stylis": "4.x" + } + }, "node_modules/sucrase": { "version": "3.35.0", "dev": true, diff --git a/package.json b/package.json index 714c5e9..28d443c 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@emotion/cache": "^11.14.0", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@hello-pangea/dnd": "^18.0.1", @@ -109,6 +110,8 @@ "reactjs-social-login": "^2.6.3", "recharts": "^2.15.0", "reusify": "^1.0.4", + "stylis": "^4.4.0", + "stylis-plugin-rtl": "^2.1.1", "tesseract.js": "^7.0.0", "vite-plugin-pwa": "^0.20.0" }, diff --git a/src/contexts/Contexts.jsx b/src/contexts/Contexts.jsx index c36f885..1947abd 100644 --- a/src/contexts/Contexts.jsx +++ b/src/contexts/Contexts.jsx @@ -8,8 +8,10 @@ import ThemeContext from './ThemeContext' const Contexts = ({ children }) => { const contexts = [ AlertsProvider, - ThemeContext, + // Above ThemeContext: the theme reads the active language to pick the + // text direction and the matching emotion cache. LocalizationProvider, + ThemeContext, QueryContext, NotificationProvider, RouterContext, diff --git a/src/contexts/LocalizationContext.jsx b/src/contexts/LocalizationContext.jsx index 624f36f..b52d23f 100644 --- a/src/contexts/LocalizationContext.jsx +++ b/src/contexts/LocalizationContext.jsx @@ -1,8 +1,9 @@ -import useStickyState from '@/hooks/useStickyState' import moment from 'moment' import { createContext, useContext, useEffect } from 'react' import { useTranslation } from 'react-i18next' +import useStickyState from '@/hooks/useStickyState' + const LocalizationContext = createContext() export const DATE_FORMATS = { @@ -21,6 +22,7 @@ export const TIME_FORMATS = { export const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur'] export const AVAILABLE_LANGUAGES = [ + { code: 'ar', name: 'Arabic', nativeName: 'العربية' }, { code: 'de', name: 'German', nativeName: 'Deutsch' }, { code: 'en', name: 'English', nativeName: 'English' }, { code: 'es', name: 'Spanish', nativeName: 'Español' }, diff --git a/src/contexts/ThemeContext.jsx b/src/contexts/ThemeContext.jsx index 7728614..955056f 100644 --- a/src/contexts/ThemeContext.jsx +++ b/src/contexts/ThemeContext.jsx @@ -1,9 +1,16 @@ +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', @@ -24,7 +31,7 @@ const primaryPalette = getPalette(primaryColor) const CONTROL_RADIUS = '12px' const ICON_BUTTON_RADIUS = '10px' -const theme = extendTheme({ +const themeConfig = { radius: { xs: '6px', sm: '8px', @@ -140,20 +147,93 @@ const theme = extendTheme({ }, JoyButtonGroup: { styleOverrides: { - root: { + 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) : {}, }, }, }, -}) +} -const ThemeContext = ({ children }) => ( - - - {children} - -) +// 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 ( + + + + {children} + + + ) +} ThemeContext.propTypes = { children: PropType.node, diff --git a/src/index.css b/src/index.css index 76a558d..a00da6d 100644 --- a/src/index.css +++ b/src/index.css @@ -55,69 +55,17 @@ html { will-change: auto; } -/* RTL Support */ -[dir='rtl'] { - direction: rtl; - text-align: right; -} - -[dir='rtl'] .rtl-mirror { - transform: scaleX(-1); -} - -/* Handle margins and paddings for RTL */ -[dir='rtl'] .ml-auto { - margin-left: 0; - margin-right: auto; -} - -[dir='rtl'] .mr-auto { - margin-right: 0; - margin-left: auto; -} - -/* Flip icons and arrows in RTL */ +/* + * RTL Support + * + * Component styling is mirrored by stylis-plugin-rtl (see ThemeContext), which + * flips the physical properties emitted by `sx`. Do not add per-component + * `[dir='rtl']` overrides here — they fight the plugin and double-flip. + * + * Only opt-in utilities belong in this block: + * .rtl-flip — mirror an icon that encodes a direction (arrows, chevrons). + * Do NOT use it on symbols that read the same either way. + */ [dir='rtl'] .rtl-flip { transform: scaleX(-1); } - -/* Ensure proper text alignment in RTL */ -[dir='rtl'] input, -[dir='rtl'] textarea { - text-align: right; -} - -/* Handle border radius for RTL */ -[dir='rtl'] .rounded-l-none { - border-radius: 0 0.375rem 0.375rem 0; -} - -[dir='rtl'] .rounded-r-none { - border-radius: 0.375rem 0 0 0.375rem; -} - -/* Fix flex alignment for RTL */ -[dir='rtl'] .flex { - direction: rtl; -} - -/* Ensure cards and containers align properly in RTL */ -[dir='rtl'] .MuiCard-root, -[dir='rtl'] .MuiBox-root, -[dir='rtl'] .MuiStack-root { - text-align: right; -} - -/* Fix list item alignment in RTL */ -[dir='rtl'] .MuiListItem-root, -[dir='rtl'] .MuiListItemButton-root { - flex-direction: row-reverse; -} - -/* Fix gap alignment in RTL */ -[dir='rtl'] .gap-1, -[dir='rtl'] .gap-2, -[dir='rtl'] .gap-3, -[dir='rtl'] .gap-4 { - direction: rtl; -} diff --git a/src/search/GlobalSearchPalette.jsx b/src/search/GlobalSearchPalette.jsx index ec2b520..dd7125b 100644 --- a/src/search/GlobalSearchPalette.jsx +++ b/src/search/GlobalSearchPalette.jsx @@ -24,6 +24,7 @@ import { import Fuse from 'fuse.js' import PropTypes from 'prop-types' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import AppModal from '../components/common/AppModal' @@ -38,15 +39,6 @@ const GROUPS = [ 'settings', 'actions', ] -const GROUP_LABELS = { - tasks: 'Tasks', - history: 'Notes', - projects: 'Projects', - labels: 'Labels', - people: 'People', - settings: 'Settings', - actions: 'Quick actions', -} const ICONS = { tasks: , @@ -58,33 +50,33 @@ const ICONS = { actions: , } -const QUICK_ACTIONS = [ +const buildQuickActions = t => [ { id: 'action:create', provider: 'actions', - title: 'Create a task', - subtitle: 'Quick action', + title: t('search.actions.createTask'), + subtitle: t('search.actions.quickAction'), route: '/chores/create', }, { id: 'action:tasks', provider: 'actions', - title: 'View all tasks', - subtitle: 'Navigation', + title: t('search.actions.viewAllTasks'), + subtitle: t('search.actions.navigation'), route: '/chores', }, { id: 'action:archived', provider: 'actions', - title: 'View archived tasks', - subtitle: 'Navigation', + title: t('search.actions.viewArchivedTasks'), + subtitle: t('search.actions.navigation'), route: '/archived', }, { id: 'action:settings', provider: 'actions', - title: 'Open settings', - subtitle: 'Navigation', + title: t('search.actions.openSettings'), + subtitle: t('search.actions.navigation'), route: '/settings', }, ] @@ -142,6 +134,8 @@ const Highlight = ({ query, text }) => { } const SearchContainer = ({ children, onClose, presentation }) => { + const { t } = useTranslation() + if (presentation === 'page') { return ( { open onClose={onClose} disableRestoreFocus - title='Search' + title={t('search.title')} size='lg' maxHeight='min(720px, calc(100dvh - 48px))' contentSx={{ @@ -189,6 +183,7 @@ const GlobalSearchPalette = ({ presentation = 'modal', }) => { const navigate = useNavigate() + const { t } = useTranslation() const focusInputRef = useCallback(node => { if (node) requestAnimationFrame(() => node.focus()) }, []) @@ -232,7 +227,7 @@ const GlobalSearchPalette = ({ const recentResults = recents .map(item => currentById.get(item.id) || item) .filter(item => item.provider !== 'history' || currentById.has(item.id)) - return [...recentResults, ...QUICK_ACTIONS] + return [...recentResults, ...buildQuickActions(t)] } const grouped = GROUPS.filter(group => group !== 'actions').flatMap(group => @@ -258,12 +253,12 @@ const GlobalSearchPalette = ({ grouped.push({ id: 'action:filter-tasks', provider: 'actions', - title: `Show tasks matching “${query.trim()}”`, - subtitle: 'Filter the task list', + title: t('search.actions.filterTasks', { query: query.trim() }), + subtitle: t('search.actions.filterTasksSubtitle'), route: `/chores?search=${encodeURIComponent(query.trim())}`, }) return grouped - }, [documents, query, recents, searchIndexes]) + }, [documents, query, recents, searchIndexes, t]) useEffect(() => { selectedResultRef.current?.scrollIntoView({ @@ -302,8 +297,7 @@ const GlobalSearchPalette = ({ slotProps={{ input: { ref: focusInputRef, - 'aria-label': - 'Search tasks, history, projects, labels and settings', + 'aria-label': t('search.inputAriaLabel'), }, }} value={query} @@ -312,14 +306,14 @@ const GlobalSearchPalette = ({ setSelectedIndex(0) }} onKeyDown={onInputKeyDown} - placeholder='Search Donetick' + placeholder={t('search.placeholder')} startDecorator={} endDecorator={ isLoading ? ( ) : presentation === 'modal' ? ( - Esc + {t('search.escape')} ) : null } @@ -333,7 +327,7 @@ const GlobalSearchPalette = ({ level='body-xs' sx={{ color: 'text.tertiary', mt: 1, px: 0.5 }} > - Searching content available on this device + {t('search.deviceNote')} @@ -350,9 +344,9 @@ const GlobalSearchPalette = ({ - No direct matches + {t('search.empty.title')} - You can still filter the task list with this search. + {t('search.empty.subtitle')} )} @@ -381,8 +375,8 @@ const GlobalSearchPalette = ({ }} > {!query.trim() && result.provider !== 'actions' - ? 'Recent' - : GROUP_LABELS[result.provider]} + ? t('search.recent') + : t(`search.groups.${result.provider}`)} )} - ↑↓ Navigate - ↵ Open + + ↑↓ {t('search.footer.navigate')} + + ↵ {t('search.footer.open')} {query.trim() - ? `${Math.max(0, results.length - 1)} results` - : 'Type to search'} + ? t('search.footer.results', { + count: Math.max(0, results.length - 1), + }) + : t('search.footer.typeToSearch')} diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx index 39f3cfd..1541fc4 100644 --- a/src/views/components/NavBar.jsx +++ b/src/views/components/NavBar.jsx @@ -31,7 +31,6 @@ import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { version } from '../../../package.json' import UserProfileAvatar from '../../components/UserProfileAvatar' import Z_INDEX from '../../constants/zIndex' -import { useLocalization } from '../../contexts/LocalizationContext' import { useResource } from '../../queries/ResourceQueries' import { useGlobalSearch } from '../../search/GlobalSearchContext' import { apiClient } from '../../utils/ApiClient' @@ -41,7 +40,6 @@ import SyncStatusIndicator from './SyncStatusIndicator' const publicPages = ['/landing', '/privacy', '/terms'] const NavBar = () => { const { t } = useTranslation('common') - const { isRTL } = useLocalization() const { data: resource } = useResource() const { openSearch } = useGlobalSearch() @@ -124,7 +122,7 @@ const NavBar = () => { aria-label='Back from search' title={t('back')} > - + ) } @@ -156,7 +154,7 @@ const NavBar = () => { : t('back') } > - + ) } @@ -224,14 +222,19 @@ const NavBar = () => {